diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php index 280ecab..d786ab9 100644 --- a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php @@ -36,6 +36,7 @@ final class RiskCapItemsReplaceController extends Controller $data = $request->validate([ 'items' => ['required', 'array', 'min:1'], + 'items.*.provider_code' => ['sometimes', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/'], 'items.*.draw_id' => ['sometimes', 'nullable', 'integer', 'exists:draws,id'], 'items.*.normalized_number' => ['required', 'string', 'size:4', 'regex:/^[0-9]{4}$/'], 'items.*.cap_amount' => ['required', 'integer', 'min:1'], diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportProviderProfitController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportProviderProfitController.php new file mode 100644 index 0000000..c9f6594 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportProviderProfitController.php @@ -0,0 +1,41 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $validated = $request->validated(); + $paging = AdminApiList::readPaging($request); + $range = $service->resolveDateRange($validated); + $scope = AdminScopePolicy::resolveContext($request, $admin, 'site_code', 'agent_node_id'); + $paginator = $service->providerProfitPaginated( + $range['date_from'], $range['date_to'], $paging['page'], $paging['perPage'], $scope, + ); + + return AdminApiList::jsonWith($paginator, static function (object $row): array { + return [ + 'provider_code' => (string) $row->provider_code, + 'provider_name' => (string) $row->provider_name, + 'ticket_item_count' => (int) $row->ticket_item_count, + 'total_bet_minor' => (int) $row->total_bet_minor, + 'total_payout_minor' => (int) $row->total_payout_minor, + 'approx_house_gross_minor' => (int) $row->approx_house_gross_minor, + ]; + }, [ + 'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php index 3769b65..b9b7ff2 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\RiskPool; use App\Models\TicketOrder; @@ -25,9 +26,13 @@ final class AdminRiskPoolIndexController extends Controller $highRiskOnly = $request->boolean('high_risk_only'); $activeOnly = $request->boolean('active_only'); $number = trim((string) $request->query('normalized_number', '')); + $providerCode = strtoupper(trim((string) $request->query('provider_code', ''))); $sort = trim((string) $request->query('sort', 'usage_desc')); $q = RiskPool::query()->where('draw_id', $draw->id); + if ($providerCode !== '') { + $q->where('provider_code', $providerCode); + } if ($soldOutOnly) { $q->where('sold_out_status', 1); @@ -47,11 +52,12 @@ final class AdminRiskPoolIndexController extends Controller } match ($sort) { - 'locked_desc' => $q->orderByDesc('locked_amount')->orderBy('normalized_number'), - 'remaining_asc' => $q->orderBy('remaining_amount')->orderBy('normalized_number'), - 'number_asc' => $q->orderBy('normalized_number'), + 'locked_desc' => $q->orderByDesc('locked_amount')->orderBy('provider_code')->orderBy('normalized_number'), + 'remaining_asc' => $q->orderBy('remaining_amount')->orderBy('provider_code')->orderBy('normalized_number'), + 'number_asc' => $q->orderBy('provider_code')->orderBy('normalized_number'), default => $q->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC') ->orderByDesc('locked_amount') + ->orderBy('provider_code') ->orderBy('normalized_number'), }; @@ -76,6 +82,7 @@ final class AdminRiskPoolIndexController extends Controller $locked = (int) $pool->locked_amount; return [ + 'provider_code' => $pool->provider_code ?? BetProvider::DEFAULT_CODE, 'normalized_number' => $pool->normalized_number, 'total_cap_amount' => $cap, 'locked_amount' => $locked, diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php index f33d626..2242964 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\TicketOrder; use Illuminate\Http\Request; @@ -58,6 +59,7 @@ final class AdminRiskPoolLockLogIndexController extends Controller { return [ 'id' => (int) $log->id, + 'provider_code' => $log->provider_code ?? BetProvider::DEFAULT_CODE, 'normalized_number' => $log->normalized_number, 'action_type' => $log->action_type, 'amount' => (int) $log->amount, @@ -76,6 +78,7 @@ final class AdminRiskPoolLockLogIndexController extends Controller $lastAt = $row->last_at ?? null; return [ + 'provider_code' => (string) ($row->provider_code ?? BetProvider::DEFAULT_CODE), 'ticket_item_id' => (int) $row->ticket_item_id, 'ticket_no' => (string) $row->ticket_no, 'play_code' => (string) $row->play_code, diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php index e91dff7..de65cb4 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\RiskPool; use App\Lottery\ErrorCode; @@ -21,7 +22,8 @@ final class AdminRiskPoolManualStatusController extends Controller ) {} public function close(Request $request, Draw $draw, string $number_4d): JsonResponse { - $pool = $this->updateStatus($draw, $number_4d, true, 'close', 'admin_manual_close'); + $providerCode = strtoupper(trim((string) $request->query('provider_code', BetProvider::DEFAULT_CODE))); + $pool = $this->updateStatus($draw, $providerCode, $number_4d, true, 'close', 'admin_manual_close'); if ($pool === null) { return ApiMessage::errorResponse($request, 'not_found', ErrorCode::ClientHttpError->value, null, 404); @@ -32,7 +34,8 @@ final class AdminRiskPoolManualStatusController extends Controller public function recover(Request $request, Draw $draw, string $number_4d): JsonResponse { - $pool = $this->updateStatus($draw, $number_4d, false, 'recover', 'admin_manual_recover'); + $providerCode = strtoupper(trim((string) $request->query('provider_code', BetProvider::DEFAULT_CODE))); + $pool = $this->updateStatus($draw, $providerCode, $number_4d, false, 'recover', 'admin_manual_recover'); if ($pool === null) { return ApiMessage::errorResponse($request, 'not_found', ErrorCode::ClientHttpError->value, null, 404); @@ -48,14 +51,16 @@ final class AdminRiskPoolManualStatusController extends Controller private function updateStatus( Draw $draw, + string $providerCode, string $number4d, bool $soldOut, string $actionType, string $reason, ): ?RiskPool { - return DB::transaction(function () use ($draw, $number4d, $soldOut, $actionType, $reason): ?RiskPool { + return DB::transaction(function () use ($draw, $providerCode, $number4d, $soldOut, $actionType, $reason): ?RiskPool { $pool = RiskPool::query() ->where('draw_id', $draw->id) + ->where('provider_code', $providerCode) ->where('normalized_number', $number4d) ->lockForUpdate() ->first(); @@ -76,11 +81,12 @@ final class AdminRiskPoolManualStatusController extends Controller ])->save(); if ($targetStatus === 1) { - $this->riskPoolService->publishManualSoldOut($draw, $number4d); + $this->riskPoolService->publishManualSoldOut($draw, $number4d, $providerCode); } RiskPoolLockLog::query()->create([ 'draw_id' => $draw->id, + 'provider_code' => $providerCode, 'normalized_number' => $number4d, 'ticket_item_id' => null, 'action_type' => $actionType, @@ -106,6 +112,7 @@ final class AdminRiskPoolManualStatusController extends Controller $locked = (int) $pool->locked_amount; return [ + 'provider_code' => $pool->provider_code ?? BetProvider::DEFAULT_CODE, 'normalized_number' => $pool->normalized_number, 'total_cap_amount' => $cap, 'locked_amount' => $locked, diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php index 387cfc6..507f495 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\RiskPool; use App\Lottery\ErrorCode; @@ -30,8 +31,11 @@ final class AdminRiskPoolShowController extends Controller ); } + $providerCode = strtoupper(trim((string) $request->query('provider_code', BetProvider::DEFAULT_CODE))); + $pool = RiskPool::query() ->where('draw_id', $draw->id) + ->where('provider_code', $providerCode) ->where('normalized_number', $number_4d) ->first(); @@ -49,6 +53,7 @@ final class AdminRiskPoolShowController extends Controller /** @var LengthAwarePaginator $paginator */ $paginator = RiskPoolLockLog::query() ->where('draw_id', $draw->id) + ->where('provider_code', $providerCode) ->where('normalized_number', $number_4d) ->with(['ticketItem:id,ticket_no,play_code,player_id']) ->orderByDesc('created_at') @@ -66,6 +71,7 @@ final class AdminRiskPoolShowController extends Controller 'draw_no' => $draw->draw_no, 'currency_code' => $currencyCode !== '' ? $currencyCode : null, 'pool' => [ + 'provider_code' => $pool->provider_code ?? BetProvider::DEFAULT_CODE, 'normalized_number' => $pool->normalized_number, 'total_cap_amount' => $cap, 'locked_amount' => $locked, @@ -84,6 +90,7 @@ final class AdminRiskPoolShowController extends Controller { return [ 'id' => (int) $log->id, + 'provider_code' => $log->provider_code ?? BetProvider::DEFAULT_CODE, 'action_type' => $log->action_type, 'amount' => (int) $log->amount, 'source_reason' => $log->source_reason, diff --git a/app/Http/Requests/Admin/ReportJobStoreRequest.php b/app/Http/Requests/Admin/ReportJobStoreRequest.php index d87c2f9..f2d2851 100644 --- a/app/Http/Requests/Admin/ReportJobStoreRequest.php +++ b/app/Http/Requests/Admin/ReportJobStoreRequest.php @@ -47,6 +47,7 @@ final class ReportJobStoreRequest extends ApiFormRequest 'wallet_transfer_report', 'hot_number_risk_report', 'play_dimension_report', + 'provider_profit_report', 'sold_out_number_report', 'audit_operation_report', 'wallet_txns_daily', diff --git a/app/Models/RiskCapItem.php b/app/Models/RiskCapItem.php index 7d08885..571f284 100644 --- a/app/Models/RiskCapItem.php +++ b/app/Models/RiskCapItem.php @@ -14,6 +14,7 @@ final class RiskCapItem extends Model { protected $fillable = [ 'version_id', + 'provider_code', 'draw_id', 'normalized_number', 'cap_amount', diff --git a/app/Models/RiskPool.php b/app/Models/RiskPool.php index f029a23..5de55c3 100644 --- a/app/Models/RiskPool.php +++ b/app/Models/RiskPool.php @@ -10,6 +10,7 @@ final class RiskPool extends Model { protected $fillable = [ 'draw_id', + 'provider_code', 'normalized_number', 'total_cap_amount', 'locked_amount', diff --git a/app/Models/RiskPoolLockLog.php b/app/Models/RiskPoolLockLog.php index 18418a7..39792d9 100644 --- a/app/Models/RiskPoolLockLog.php +++ b/app/Models/RiskPoolLockLog.php @@ -12,6 +12,7 @@ final class RiskPoolLockLog extends Model protected $fillable = [ 'draw_id', + 'provider_code', 'normalized_number', 'ticket_item_id', 'action_type', diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index dac22ca..4200ebf 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -478,6 +478,39 @@ final class AdminReportQueryService return $query->paginate($perPage, ['*'], 'page', $page); } + /** + * 开注商经营汇总。多开注商会将一笔订单拆为多个注项,故必须以 ticket_items 为统计口径。 + */ + public function providerProfitPaginated( + string $dateFrom, + string $dateTo, + int $page, + int $perPage, + AdminUser|AdminScopeContext|null $scope = null, + ): LengthAwarePaginator { + $context = $this->normalizeScope($scope); + $providerCodeSql = "COALESCE(NULLIF(ti.provider_code, ''), 'SG')"; + + $query = DB::table('ticket_items as ti') + ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') + ->join('draws as d', 'd.id', '=', 'ti.draw_id') + ->leftJoin('bet_providers as bp', 'bp.code', '=', 'ti.provider_code') + ->whereDate('d.business_date', '>=', $dateFrom) + ->whereDate('d.business_date', '<=', $dateTo) + ->selectRaw($providerCodeSql.' as provider_code') + ->selectRaw("COALESCE(NULLIF(MAX(ti.provider_name), ''), MAX(bp.name), ".$providerCodeSql.") as provider_name") + ->selectRaw('COUNT(ti.id) as ticket_item_count') + ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') + ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') + ->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as approx_house_gross_minor') + ->groupByRaw($providerCodeSql) + ->orderByDesc('total_bet_minor'); + + AdminDataScope::applyToTicketOrdersViaPlayer($query, $context?->admin, 'o'); + + return $query->paginate($perPage, ['*'], 'page', $page); + } + /** * @return list> */ @@ -492,6 +525,7 @@ final class AdminReportQueryService 'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope), 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope), 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope), + 'provider_profit_report' => $this->providerProfitExportRows($dateFrom, $dateTo, $scope), 'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo), 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope), @@ -531,6 +565,7 @@ final class AdminReportQueryService 'wallet_transfer_report', 'wallet_txns_daily', 'transfer_orders_daily' => '玩家转入转出报表', 'hot_number_risk_report' => '热门号码风险报表', 'play_dimension_report' => '玩法维度报表', + 'provider_profit_report' => '开注商经营报表', 'sold_out_number_report' => '售罄号码报表', 'audit_operation_report' => '后台操作审计报表', default => $reportType, @@ -603,6 +638,16 @@ final class AdminReportQueryService return $rows; } + /** @return list> */ + private function providerProfitExportRows(string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array + { + $rows = [['开注商编码', '开注商名称', '注项数', '下注', '派彩', '平台毛利']]; + foreach ($this->providerProfitPaginated($dateFrom, $dateTo, 1, 10_000, $scope)->items() as $row) { + $rows[] = [(string) $row->provider_code, (string) $row->provider_name, (int) $row->ticket_item_count, (int) $row->total_bet_minor, (int) $row->total_payout_minor, (int) $row->approx_house_gross_minor]; + } + return $rows; + } + /** * @return list> */ diff --git a/app/Services/Admin/AdminRiskPoolLockLogQueryService.php b/app/Services/Admin/AdminRiskPoolLockLogQueryService.php index 4b5ea10..fb47966 100644 --- a/app/Services/Admin/AdminRiskPoolLockLogQueryService.php +++ b/app/Services/Admin/AdminRiskPoolLockLogQueryService.php @@ -20,6 +20,7 @@ final class AdminRiskPoolLockLogQueryService { $action = trim((string) $request->query('action_type', '')); $number = trim((string) $request->query('normalized_number', '')); + $providerCode = strtoupper(trim((string) $request->query('provider_code', ''))); $ticketItemId = (int) $request->integer('ticket_item_id', 0); $q = RiskPoolLockLog::query() @@ -36,6 +37,10 @@ final class AdminRiskPoolLockLogQueryService $q->where('normalized_number', $number); } + if ($providerCode !== '') { + $q->where('provider_code', $providerCode); + } + if ($ticketItemId > 0) { $q->where('ticket_item_id', $ticketItemId); } @@ -50,6 +55,7 @@ final class AdminRiskPoolLockLogQueryService { $action = trim((string) $request->query('action_type', '')); $number = trim((string) $request->query('normalized_number', '')); + $providerCode = strtoupper(trim((string) $request->query('provider_code', ''))); $ticketItemId = (int) $request->integer('ticket_item_id', 0); $bindings = ['draw_id' => $draw->id]; @@ -65,6 +71,11 @@ final class AdminRiskPoolLockLogQueryService $bindings['normalized_number'] = $number; } + if ($providerCode !== '') { + $filters .= ' AND l.provider_code = :provider_code'; + $bindings['provider_code'] = $providerCode; + } + if ($ticketItemId > 0) { $filters .= ' AND l.ticket_item_id = :ticket_item_id'; $bindings['ticket_item_id'] = $ticketItemId; @@ -86,6 +97,7 @@ final class AdminRiskPoolLockLogQueryService $aggregateSql = <<where('version_id', $riskVersion->id) + ->whereIn('provider_code', ['GLOBAL', $providerCode]) ->orderBy('normalized_number') ->get(); @@ -92,7 +93,9 @@ final class EffectivePlayCatalogService 'risk_cap' => $this->serializeVersionHead($riskVersion), ], 'plays' => $plays, - 'risk_cap_items' => $riskItems->map(fn (RiskCapItem $r) => $this->serializeRiskItem($r))->all(), + 'risk_cap_items' => $this->resolveProviderRiskItems($riskItems, $providerCode) + ->map(fn (RiskCapItem $r) => $this->serializeRiskItem($r)) + ->all(), ]; } @@ -120,6 +123,29 @@ final class EffectivePlayCatalogService return collect(array_values($selected)); } + /** + * @param Collection $items + * @return Collection + */ + private function resolveProviderRiskItems(Collection $items, string $providerCode): Collection + { + $selected = []; + + foreach ($items->sortBy(fn (RiskCapItem $row): int => (string) ($row->provider_code ?? 'GLOBAL') === $providerCode ? 0 : 1) as $row) { + $key = implode('|', [ + $row->draw_id === null ? 'null' : (string) $row->draw_id, + (string) $row->normalized_number, + (string) $row->cap_type, + ]); + + if (! isset($selected[$key])) { + $selected[$key] = $row; + } + } + + return collect(array_values($selected)); + } + /** * 大厅列表展示单档赔率:优先头奖档 {@see first},兼容历史 {@see default}。 * @@ -210,6 +236,7 @@ final class EffectivePlayCatalogService private function serializeRiskItem(RiskCapItem $r): array { return [ + 'provider_code' => $r->provider_code ?? 'GLOBAL', 'draw_id' => $r->draw_id, 'normalized_number' => $r->normalized_number, 'cap_amount' => (int) $r->cap_amount, diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php index 59ee18a..15a74de 100644 --- a/app/Services/Config/RiskCapStreamService.php +++ b/app/Services/Config/RiskCapStreamService.php @@ -58,6 +58,7 @@ final class RiskCapStreamService foreach ($source->items()->orderBy('normalized_number')->get() as $row) { RiskCapItem::query()->create([ 'version_id' => $draft->id, + 'provider_code' => $row->provider_code ?? 'GLOBAL', 'draw_id' => $row->draw_id, 'normalized_number' => $row->normalized_number, 'cap_amount' => $row->cap_amount, @@ -67,6 +68,7 @@ final class RiskCapStreamService } else { RiskCapItem::query()->create([ 'version_id' => $draft->id, + 'provider_code' => 'GLOBAL', 'draw_id' => null, 'normalized_number' => '0000', 'cap_amount' => 50_000_000_000, @@ -89,6 +91,7 @@ final class RiskCapStreamService foreach ($items as $row) { RiskCapItem::query()->create([ 'version_id' => $draft->id, + 'provider_code' => strtoupper((string) ($row['provider_code'] ?? 'GLOBAL')), 'draw_id' => isset($row['draw_id']) ? (int) $row['draw_id'] : null, 'normalized_number' => (string) $row['normalized_number'], 'cap_amount' => (int) $row['cap_amount'], @@ -192,9 +195,10 @@ final class RiskCapStreamService $capAmount = (int) $row->cap_amount; $drawId = $row->draw_id === null ? '__null__' : (string) $row->draw_id; $capType = (string) $row->cap_type; + $providerCode = strtoupper((string) ($row->provider_code ?? 'GLOBAL')); $key = $capType === 'default' - ? 'default|'.$drawId - : $drawId.'|'.$normalizedNumber; + ? 'default|'.$providerCode.'|'.$drawId + : $providerCode.'|'.$drawId.'|'.$normalizedNumber; if (! preg_match('/^[0-9]{4}$/', $normalizedNumber)) { $errors["items.$index.normalized_number"][] = '号码必须是 4 位数字'; @@ -204,6 +208,10 @@ final class RiskCapStreamService $errors["items.$index.cap_amount"][] = '封顶金额必须大于 0'; } + if (! preg_match('/^[A-Z0-9_-]{2,32}$/', $providerCode)) { + $errors["items.$index.provider_code"][] = '开注商编码格式不正确'; + } + if ($capType === 'default' && $row->draw_id !== null) { $errors["items.$index.cap_type"][] = '默认封顶不能绑定具体期号'; } diff --git a/app/Services/Draw/DrawCancelBetRefundService.php b/app/Services/Draw/DrawCancelBetRefundService.php index a0fa1b6..2a2f8d4 100644 --- a/app/Services/Draw/DrawCancelBetRefundService.php +++ b/app/Services/Draw/DrawCancelBetRefundService.php @@ -69,7 +69,12 @@ final class DrawCancelBetRefundService } if ($locks !== []) { - $this->riskPool->release((int) $draw->id, $item, $locks); + $this->riskPool->release( + (int) $draw->id, + (string) ($item->provider_code ?: \App\Models\BetProvider::DEFAULT_CODE), + $item, + $locks, + ); } $item->forceFill([ diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index 491d16c..c47d2dd 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -3,6 +3,7 @@ namespace App\Services\Draw; use Carbon\Carbon; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\RiskPool; use App\Lottery\DrawStatus; @@ -335,6 +336,7 @@ final class DrawHallSnapshotBuilder return Cache::remember($cacheKey, self::RISK_ALERTS_CACHE_TTL_SECONDS, function () use ($drawId): array { return RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', BetProvider::DEFAULT_CODE) ->where(function ($q): void { $q->where('sold_out_status', 1) ->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8'); diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index fd192ad..f3a3f20 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -230,7 +230,12 @@ final class SettlementOrchestrator 'amount' => (int) $c->estimated_payout, ]; } - $this->riskPool->release((int) $locked->id, $item, $locks); + $this->riskPool->release( + (int) $locked->id, + (string) ($item->provider_code ?: BetProvider::DEFAULT_CODE), + $item, + $locks, + ); } $batchRow->forceFill([ diff --git a/app/Services/Ticket/PlayCatalogResolver.php b/app/Services/Ticket/PlayCatalogResolver.php index 88f9432..3898067 100644 --- a/app/Services/Ticket/PlayCatalogResolver.php +++ b/app/Services/Ticket/PlayCatalogResolver.php @@ -151,16 +151,21 @@ final class PlayCatalogResolver return collect(array_values($selected)); } - public function resolveCapAmount(int $drawId, string $number4d): int + public function resolveCapAmount(int $drawId, string $number4d, ?string $providerCode = null): int { $riskVersion = RiskCapVersion::query() ->where('status', ConfigVersionStatus::Active->value) ->firstOrFail(); + $providerCode = strtoupper(trim((string) ($providerCode ?: 'GLOBAL'))); + $providerScopes = array_values(array_unique(['GLOBAL', $providerCode])); + $specific = RiskCapItem::query() ->where('version_id', $riskVersion->id) + ->whereIn('provider_code', $providerScopes) ->where('draw_id', $drawId) ->where('normalized_number', $number4d) + ->orderByRaw('case when provider_code = ? then 0 else 1 end', [$providerCode]) ->orderByDesc('id') ->first(); @@ -170,8 +175,10 @@ final class PlayCatalogResolver $generic = RiskCapItem::query() ->where('version_id', $riskVersion->id) + ->whereIn('provider_code', $providerScopes) ->whereNull('draw_id') ->where('normalized_number', $number4d) + ->orderByRaw('case when provider_code = ? then 0 else 1 end', [$providerCode]) ->orderByDesc('id') ->first(); @@ -181,8 +188,10 @@ final class PlayCatalogResolver $default = RiskCapItem::query() ->where('version_id', $riskVersion->id) + ->whereIn('provider_code', $providerScopes) ->whereNull('draw_id') ->where('cap_type', 'default') + ->orderByRaw('case when provider_code = ? then 0 else 1 end', [$providerCode]) ->orderByDesc('id') ->first(); diff --git a/app/Services/Ticket/RiskPoolRealtimePublisher.php b/app/Services/Ticket/RiskPoolRealtimePublisher.php index c72d402..e71367f 100644 --- a/app/Services/Ticket/RiskPoolRealtimePublisher.php +++ b/app/Services/Ticket/RiskPoolRealtimePublisher.php @@ -2,6 +2,7 @@ namespace App\Services\Ticket; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\RiskPool; use App\Services\Draw\LotteryHallRealtimeBroadcaster; @@ -22,12 +23,17 @@ final class RiskPoolRealtimePublisher public function publishAfterLock( int $drawId, + string $providerCode, string $normalizedNumber, int $soldOutStatusBefore, int $lockedAmountBefore, int $totalCapBefore, RiskPool $poolAfter, ): void { + if (strtoupper(trim($providerCode)) !== BetProvider::DEFAULT_CODE) { + return; + } + $drawNo = $this->resolveDrawNo($drawId); $normalizedNumber = strtoupper(trim($normalizedNumber)); @@ -55,8 +61,12 @@ final class RiskPoolRealtimePublisher } } - public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void + public function publishManualSoldOut(Draw $draw, string $normalizedNumber, string $providerCode): void { + if (strtoupper(trim($providerCode)) !== BetProvider::DEFAULT_CODE) { + return; + } + $this->hallRealtime->notifyRiskSoldOut( (int) $draw->id, (string) $draw->draw_no, diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index c9d4175..fd6eae2 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -2,6 +2,7 @@ namespace App\Services\Ticket; +use App\Models\BetProvider; use App\Models\Draw; use App\Models\RiskPool; use App\Lottery\ErrorCode; @@ -22,11 +23,12 @@ final class RiskPoolService * @param list $locks * @return list */ - public function preview(int $drawId, array $locks): array + public function preview(int $drawId, string $providerCode, array $locks): array { + $providerCode = $this->normalizeProviderCode($providerCode); $rows = []; foreach ($locks as $lock) { - $pool = $this->firstOrMakePool($drawId, $lock['number_4d']); + $pool = $this->firstOrMakePool($drawId, $providerCode, $lock['number_4d']); if ((int) $pool->sold_out_status === 1) { throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } @@ -53,19 +55,20 @@ final class RiskPoolService /** * @param list $locks */ - public function acquire(int $drawId, ?TicketItem $ticketItem, array $locks): int + public function acquire(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): int { + $providerCode = $this->normalizeProviderCode($providerCode); if ($this->shouldUseRedisAtomicLocks()) { - return $this->acquireWithRedisLua($drawId, $ticketItem, $locks); + return $this->acquireWithRedisLua($drawId, $providerCode, $ticketItem, $locks); } - return $this->acquireWithDatabaseLocks($drawId, $ticketItem, $locks); + return $this->acquireWithDatabaseLocks($drawId, $providerCode, $ticketItem, $locks); } /** * @param list $locks */ - private function acquireWithDatabaseLocks(int $drawId, ?TicketItem $ticketItem, array $locks): int + private function acquireWithDatabaseLocks(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): int { $acquired = []; $total = 0; @@ -74,14 +77,16 @@ final class RiskPoolService foreach ($locks as $lock) { $pool = RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', $providerCode) ->where('normalized_number', $lock['number_4d']) ->lockForUpdate() ->first(); if ($pool === null) { - $pool = $this->createPool($drawId, $lock['number_4d']); + $pool = $this->createPool($drawId, $providerCode, $lock['number_4d']); $pool = RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', $providerCode) ->where('normalized_number', $lock['number_4d']) ->lockForUpdate() ->firstOrFail(); @@ -105,6 +110,7 @@ final class RiskPoolService $this->riskRealtime->publishAfterLock( $drawId, + $providerCode, $lock['number_4d'], $soldOutBefore, $lockedBefore, @@ -114,6 +120,7 @@ final class RiskPoolService RiskPoolLockLog::query()->create([ 'draw_id' => $drawId, + 'provider_code' => $providerCode, 'normalized_number' => $lock['number_4d'], 'ticket_item_id' => $ticketItem?->id, 'action_type' => 'lock', @@ -126,7 +133,7 @@ final class RiskPoolService $total += $amount; } } catch (\Throwable $e) { - $this->releaseDatabaseLocks($drawId, $ticketItem, $acquired, 'ticket_failed_line'); + $this->releaseDatabaseLocks($drawId, $providerCode, $ticketItem, $acquired, 'ticket_failed_line'); throw $e; } @@ -139,7 +146,7 @@ final class RiskPoolService * * @param list $locks */ - private function acquireWithRedisLua(int $drawId, ?TicketItem $ticketItem, array $locks): int + private function acquireWithRedisLua(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): int { $acquired = []; $total = 0; @@ -148,16 +155,16 @@ final class RiskPoolService foreach ($locks as $lock) { $number4d = $lock['number_4d']; $amount = (int) $lock['amount']; - $this->acquireRedisLockForCombination($drawId, $number4d, $amount); + $this->acquireRedisLockForCombination($drawId, $providerCode, $number4d, $amount); $acquired[] = ['number_4d' => $number4d, 'amount' => $amount]; $total += $amount; - $this->syncDatabaseAfterRedisAcquire($drawId, $ticketItem, $number4d, $amount); + $this->syncDatabaseAfterRedisAcquire($drawId, $providerCode, $ticketItem, $number4d, $amount); } } catch (\Throwable $e) { - $this->releaseRedisLocks($drawId, $acquired); - $this->releaseDatabaseLocks($drawId, $ticketItem, $acquired, 'ticket_failed_line'); + $this->releaseRedisLocks($drawId, $providerCode, $acquired); + $this->releaseDatabaseLocks($drawId, $providerCode, $ticketItem, $acquired, 'ticket_failed_line'); throw $e; } @@ -170,34 +177,35 @@ final class RiskPoolService * * @param list $locks */ - public function compensateRedisAcquires(int $drawId, array $locks): void + public function compensateRedisAcquires(int $drawId, string $providerCode, array $locks): void { if ($locks === [] || ! $this->shouldUseRedisAtomicLocks()) { return; } - $this->releaseRedisLocks($drawId, $locks); + $this->releaseRedisLocks($drawId, $this->normalizeProviderCode($providerCode), $locks); } /** * @param list $locks */ - public function release(int $drawId, ?TicketItem $ticketItem, array $locks): void + public function release(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): void { + $providerCode = $this->normalizeProviderCode($providerCode); if ($this->shouldUseRedisAtomicLocks()) { - $this->releaseRedisLocks($drawId, $locks); + $this->releaseRedisLocks($drawId, $providerCode, $locks); } foreach ($locks as $lock) { - $this->releaseDatabaseLocks($drawId, $ticketItem, [$lock], 'ticket_rollback'); + $this->releaseDatabaseLocks($drawId, $providerCode, $ticketItem, [$lock], 'ticket_rollback'); } } - private function acquireRedisLockForCombination(int $drawId, string $number4d, int $amount): void + private function acquireRedisLockForCombination(int $drawId, string $providerCode, string $number4d, int $amount): void { for ($attempt = 0; $attempt < 2; $attempt++) { - $pool = $this->firstOrMakePool($drawId, $number4d); - $key = $this->redisPoolKey($drawId, $number4d); + $pool = $this->firstOrMakePool($drawId, $providerCode, $number4d); + $key = $this->redisPoolKey($drawId, $providerCode, $number4d); Redis::eval( $this->initLua(), @@ -229,6 +237,7 @@ final class RiskPoolService if ($attempt === 0 && in_array($result['code'] ?? '', ['VERSION_CONFLICT', 'POOL_NOT_INITIALIZED'], true)) { $freshPool = RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', $providerCode) ->where('normalized_number', $number4d) ->firstOrFail(); $this->syncRedisStateFromPool($freshPool); @@ -253,9 +262,9 @@ final class RiskPoolService ); } - public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void + public function publishManualSoldOut(Draw $draw, string $normalizedNumber, string $providerCode): void { - $this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber); + $this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber, $this->normalizeProviderCode($providerCode)); } /** 后台改池或释池后,将 Redis 风控快照与 DB 对齐。 */ @@ -272,7 +281,7 @@ final class RiskPoolService Redis::eval( $this->overwriteStateLua(), 1, - $this->redisPoolKey((int) $pool->draw_id, (string) $pool->normalized_number), + $this->redisPoolKey((int) $pool->draw_id, (string) ($pool->provider_code ?? BetProvider::DEFAULT_CODE), (string) $pool->normalized_number), $total, $locked, $remaining, @@ -290,9 +299,11 @@ final class RiskPoolService return (bool) config('lottery.risk_pool.use_redis_lua', true); } - private function redisPoolKey(int $drawId, string $number4d): string + private function redisPoolKey(int $drawId, string $providerCode, string $number4d): string { - return "risk_pool:draw:{$drawId}:number:{$number4d}"; + $providerCode = $this->normalizeProviderCode($providerCode); + + return "risk_pool:draw:{$drawId}:provider:{$providerCode}:number:{$number4d}"; } private function redisPoolTtlSeconds(): int @@ -363,10 +374,11 @@ return releaseAmount LUA; } - private function syncDatabaseAfterRedisAcquire(int $drawId, ?TicketItem $ticketItem, string $number4d, int $amount): void + private function syncDatabaseAfterRedisAcquire(int $drawId, string $providerCode, ?TicketItem $ticketItem, string $number4d, int $amount): void { $pool = RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', $providerCode) ->where('normalized_number', $number4d) ->lockForUpdate() ->firstOrFail(); @@ -388,6 +400,7 @@ LUA; $this->riskRealtime->publishAfterLock( $drawId, + $providerCode, $number4d, $soldOutBefore, $lockedBefore, @@ -397,6 +410,7 @@ LUA; RiskPoolLockLog::query()->create([ 'draw_id' => $drawId, + 'provider_code' => $providerCode, 'normalized_number' => $number4d, 'ticket_item_id' => $ticketItem?->id, 'action_type' => 'lock', @@ -409,13 +423,13 @@ LUA; /** * @param list $locks */ - private function releaseRedisLocks(int $drawId, array $locks): void + private function releaseRedisLocks(int $drawId, string $providerCode, array $locks): void { foreach ($locks as $lock) { Redis::eval( $this->releaseLua(), 1, - $this->redisPoolKey($drawId, $lock['number_4d']), + $this->redisPoolKey($drawId, $providerCode, $lock['number_4d']), (int) $lock['amount'], $this->redisPoolTtlSeconds(), ); @@ -447,11 +461,12 @@ LUA; /** * @param list $locks */ - private function releaseDatabaseLocks(int $drawId, ?TicketItem $ticketItem, array $locks, string $sourceReason): void + private function releaseDatabaseLocks(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks, string $sourceReason): void { foreach ($locks as $lock) { $pool = RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', $providerCode) ->where('normalized_number', $lock['number_4d']) ->lockForUpdate() ->first(); @@ -470,6 +485,7 @@ LUA; RiskPoolLockLog::query()->create([ 'draw_id' => $drawId, + 'provider_code' => $providerCode, 'normalized_number' => $lock['number_4d'], 'ticket_item_id' => $ticketItem?->id, 'action_type' => 'release', @@ -480,10 +496,11 @@ LUA; } } - private function firstOrMakePool(int $drawId, string $number4d): RiskPool + private function firstOrMakePool(int $drawId, string $providerCode, string $number4d): RiskPool { $pool = RiskPool::query() ->where('draw_id', $drawId) + ->where('provider_code', $providerCode) ->where('normalized_number', $number4d) ->first(); @@ -491,15 +508,18 @@ LUA; return $pool; } - return $this->createPool($drawId, $number4d); + return $this->createPool($drawId, $providerCode, $number4d); } - private function createPool(int $drawId, string $number4d): RiskPool + private function createPool(int $drawId, string $providerCode, string $number4d): RiskPool { - $cap = $this->catalogResolver->resolveCapAmount($drawId, $number4d); + $cap = $this->catalogResolver->resolveCapAmount($drawId, $number4d, $providerCode); - return RiskPool::query()->create([ + return RiskPool::query()->firstOrCreate([ 'draw_id' => $drawId, + 'provider_code' => $providerCode, + 'normalized_number' => $number4d, + ], [ 'normalized_number' => $number4d, 'total_cap_amount' => $cap, 'locked_amount' => 0, @@ -508,4 +528,11 @@ LUA; 'version' => 0, ]); } + + private function normalizeProviderCode(?string $providerCode): string + { + $normalized = strtoupper(trim((string) $providerCode)); + + return $normalized !== '' ? $normalized : BetProvider::DEFAULT_CODE; + } } diff --git a/app/Services/Ticket/TicketPendingConfirmReconcileService.php b/app/Services/Ticket/TicketPendingConfirmReconcileService.php index 70eb997..552875d 100644 --- a/app/Services/Ticket/TicketPendingConfirmReconcileService.php +++ b/app/Services/Ticket/TicketPendingConfirmReconcileService.php @@ -153,7 +153,12 @@ final class TicketPendingConfirmReconcileService } if ($locks !== []) { - $this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks); + $this->riskPool->release( + (int) $lockedOrder->draw_id, + (string) ($item->provider_code ?: \App\Models\BetProvider::DEFAULT_CODE), + $item, + $locks, + ); } $item->forceFill([ diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 47ccadb..86731e5 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -90,6 +90,7 @@ final class TicketPlacementService foreach ($riskRedisCompensation as $entry) { $this->riskPoolService->compensateRedisAcquires( (int) $entry['draw_id'], + (string) $entry['provider_code'], $entry['locks'], ); } @@ -288,7 +289,7 @@ final class TicketPlacementService } try { - $lockedAmount = $this->riskPoolService->acquire((int) $draw->id, $item, $locks); + $lockedAmount = $this->riskPoolService->acquire((int) $draw->id, (string) $evaluated['provider_code'], $item, $locks); } catch (TicketOperationException $e) { if ($e->lotteryCode !== ErrorCode::RiskPoolSoldOut->value) { throw $e; @@ -319,6 +320,7 @@ final class TicketPlacementService $successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout']; $riskRedisCompensation[] = [ 'draw_id' => (int) $draw->id, + 'provider_code' => (string) $evaluated['provider_code'], 'locks' => $locks, ]; } @@ -412,7 +414,12 @@ final class TicketPlacementService 'amount' => (int) $combo->estimated_payout, ]; } - $this->riskPoolService->release((int) $order->draw_id, $item, $locks); + $this->riskPoolService->release( + (int) $order->draw_id, + (string) ($item->provider_code ?: \App\Models\BetProvider::DEFAULT_CODE), + $item, + $locks, + ); $item->forceFill([ 'status' => 'refunded', 'fail_reason_code' => (string) ErrorCode::BetInsufficientBalance->value, diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index eeb281c..20b5398 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -79,10 +79,12 @@ final class TicketPreviewService 'number_4d' => $combo['number_4d'], 'amount' => $combo['estimated_payout'], ], $evaluated['combinations']); - $riskPreview = $this->riskPoolService->preview((int) $draw->id, $locks); + $riskPreview = $this->riskPoolService->preview((int) $draw->id, (string) $provider['code'], $locks); foreach ($riskPreview as $riskRow) { if ($riskRow['warning']) { $warningRows[] = [ + 'provider_code' => $provider['code'], + 'provider_name' => $provider['name'], 'number_4d' => $riskRow['number_4d'], 'message' => '该号码赔付池已使用 80% 以上,可能即将售罄', ]; diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index c4ed471..f363281 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -571,6 +571,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], + ['code' => 'admin.reports.provider-profit', 'module_code' => 'report', 'name' => '开注商经营报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/provider-profit', 'route_name' => 'api.v1.admin.reports.provider-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']], ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']], diff --git a/app/Support/AdminConfigPresenter.php b/app/Support/AdminConfigPresenter.php index b22b046..6a38031 100644 --- a/app/Support/AdminConfigPresenter.php +++ b/app/Support/AdminConfigPresenter.php @@ -148,6 +148,7 @@ final class AdminConfigPresenter { return [ 'id' => (int) $r->id, + 'provider_code' => $r->provider_code ?? 'GLOBAL', 'draw_id' => $r->draw_id, 'normalized_number' => $r->normalized_number, 'cap_amount' => (int) $r->cap_amount, diff --git a/database/migrations/2026_07_09_190000_add_provider_code_to_risk_tables.php b/database/migrations/2026_07_09_190000_add_provider_code_to_risk_tables.php new file mode 100644 index 0000000..68f8d26 --- /dev/null +++ b/database/migrations/2026_07_09_190000_add_provider_code_to_risk_tables.php @@ -0,0 +1,77 @@ +string('provider_code', 32)->default(BetProvider::DEFAULT_CODE)->after('draw_id'); + }); + + DB::table('risk_pools')->update([ + 'provider_code' => BetProvider::DEFAULT_CODE, + ]); + + Schema::table('risk_pools', function (Blueprint $table): void { + $table->dropUnique('uk_risk_pools_draw_number'); + $table->dropIndex('idx_risk_pools_draw_soldout'); + $table->unique(['draw_id', 'provider_code', 'normalized_number'], 'uk_risk_pools_draw_provider_number'); + $table->index(['draw_id', 'provider_code', 'sold_out_status'], 'idx_risk_pools_draw_provider_soldout'); + }); + + Schema::table('risk_pool_lock_logs', function (Blueprint $table): void { + $table->string('provider_code', 32)->default(BetProvider::DEFAULT_CODE)->after('draw_id'); + }); + + DB::table('risk_pool_lock_logs')->update([ + 'provider_code' => BetProvider::DEFAULT_CODE, + ]); + + Schema::table('risk_pool_lock_logs', function (Blueprint $table): void { + $table->dropIndex('idx_risk_lock_logs_draw_number'); + $table->index(['draw_id', 'provider_code', 'normalized_number'], 'idx_risk_lock_logs_draw_provider_number'); + }); + + Schema::table('risk_cap_items', function (Blueprint $table): void { + $table->string('provider_code', 32)->default('GLOBAL')->after('version_id'); + }); + + DB::table('risk_cap_items')->update([ + 'provider_code' => 'GLOBAL', + ]); + + Schema::table('risk_cap_items', function (Blueprint $table): void { + $table->dropIndex('idx_risk_cap_items_lookup'); + $table->index(['version_id', 'provider_code', 'draw_id', 'normalized_number'], 'idx_risk_cap_items_version_provider_lookup'); + }); + } + + public function down(): void + { + Schema::table('risk_cap_items', function (Blueprint $table): void { + $table->dropIndex('idx_risk_cap_items_version_provider_lookup'); + $table->index(['version_id', 'draw_id', 'normalized_number'], 'idx_risk_cap_items_lookup'); + $table->dropColumn('provider_code'); + }); + + Schema::table('risk_pool_lock_logs', function (Blueprint $table): void { + $table->dropIndex('idx_risk_lock_logs_draw_provider_number'); + $table->index(['draw_id', 'normalized_number'], 'idx_risk_lock_logs_draw_number'); + $table->dropColumn('provider_code'); + }); + + Schema::table('risk_pools', function (Blueprint $table): void { + $table->dropUnique('uk_risk_pools_draw_provider_number'); + $table->dropIndex('idx_risk_pools_draw_provider_soldout'); + $table->unique(['draw_id', 'normalized_number'], 'uk_risk_pools_draw_number'); + $table->index(['draw_id', 'sold_out_status'], 'idx_risk_pools_draw_soldout'); + $table->dropColumn('provider_code'); + }); + } +}; diff --git a/routes/api/v1/admin/report.php b/routes/api/v1/admin/report.php index afb28ac..a7d56ea 100644 --- a/routes/api/v1/admin/report.php +++ b/routes/api/v1/admin/report.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportDailyProfitController; use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayDimensionController; use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayerWinLossController; +use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportProviderProfitController; use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController; use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController; use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController; @@ -17,6 +18,8 @@ Route::middleware('admin.api-resource')->group(function (): void { ->name('api.v1.admin.reports.player-win-loss'); Route::get('reports/play-dimension', AdminReportPlayDimensionController::class) ->name('api.v1.admin.reports.play-dimension'); + Route::get('reports/provider-profit', AdminReportProviderProfitController::class) + ->name('api.v1.admin.reports.provider-profit'); Route::get('report-jobs', ReportJobIndexController::class) ->name('api.v1.admin.report-jobs.index'); diff --git a/tests/Feature/AdminProviderProfitReportTest.php b/tests/Feature/AdminProviderProfitReportTest.php new file mode 100644 index 0000000..b2c2f3b --- /dev/null +++ b/tests/Feature/AdminProviderProfitReportTest.php @@ -0,0 +1,45 @@ +create([ + 'site_code' => 'main', 'site_player_id' => 'provider-report-1', 'username' => 'provider_report', + 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, + ]); + $draw = Draw::query()->create([ + 'draw_no' => 'RPT-PROVIDER-001', 'business_date' => '2026-07-10', 'sequence_no' => 1, 'status' => 'settled', + 'start_time' => now()->subHour(), 'close_time' => now(), 'draw_time' => now()->addHour(), + 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 1, 'settle_version' => 1, 'is_reopened' => false, + ]); + $order = TicketOrder::query()->create([ + 'order_no' => 'RPT-PROVIDER-ORD', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', + 'total_bet_amount' => 3000, 'total_rebate_amount' => 0, 'total_actual_deduct' => 3000, + 'total_estimated_payout' => 0, 'status' => 'settled', 'submit_source' => 'h5', 'client_trace_id' => 'provider-report-trace', + ]); + foreach ([['SG', 'Singapore', 1000, 100], ['MY', 'Malaysia', 2000, 500]] as [$code, $name, $bet, $payout]) { + TicketItem::query()->create([ + 'ticket_no' => 'RPT-'.$code, 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, + 'provider_code' => $code, 'provider_name' => $name, 'original_number' => '1234', 'normalized_number' => '1234', + 'play_code' => 'big', 'dimension' => 4, 'digit_slot' => null, 'bet_mode' => 'single', 'unit_bet_amount' => $bet, + 'total_bet_amount' => $bet, 'rebate_rate_snapshot' => '0.0000', 'commission_rate_snapshot' => '0.0000', + 'actual_deduct_amount' => $bet, 'win_amount' => $payout, 'jackpot_win_amount' => 0, 'status' => 'settled', + ]); + } + + $rows = app(AdminReportQueryService::class)->providerProfitPaginated('2026-07-10', '2026-07-10', 1, 20)->items(); + + expect($rows)->toHaveCount(2) + ->and($rows[0]->provider_code)->toBe('MY') + ->and((int) $rows[0]->total_bet_minor)->toBe(2000) + ->and((int) $rows[0]->approx_house_gross_minor)->toBe(1500) + ->and($rows[1]->provider_code)->toBe('SG') + ->and((int) $rows[1]->total_payout_minor)->toBe(100); +}); diff --git a/tests/Feature/AdminRiskPoolApiTest.php b/tests/Feature/AdminRiskPoolApiTest.php index 5ad0907..7844d72 100644 --- a/tests/Feature/AdminRiskPoolApiTest.php +++ b/tests/Feature/AdminRiskPoolApiTest.php @@ -165,16 +165,17 @@ test('admin can manually close and recover a risk pool number', function (): voi $this->assertDatabaseHas('risk_pool_lock_logs', [ 'draw_id' => $draw->id, + 'provider_code' => 'SG', 'normalized_number' => '2468', 'action_type' => 'close', 'amount' => 0, 'source_reason' => 'admin_manual_close', ]); - expect(fn () => app(RiskPoolService::class)->preview($draw->id, [['number_4d' => '2468', 'amount' => 1]])) + expect(fn () => app(RiskPoolService::class)->preview($draw->id, 'SG', [['number_4d' => '2468', 'amount' => 1]])) ->toThrow(TicketOperationException::class, 'risk_sold_out'); - expect(fn () => app(RiskPoolService::class)->acquire($draw->id, null, [['number_4d' => '2468', 'amount' => 1]])) + expect(fn () => app(RiskPoolService::class)->acquire($draw->id, 'SG', null, [['number_4d' => '2468', 'amount' => 1]])) ->toThrow(TicketOperationException::class, 'risk_sold_out'); }); @@ -215,13 +216,14 @@ test('admin can recover a manually closed risk pool number', function (): void { $this->assertDatabaseHas('risk_pool_lock_logs', [ 'draw_id' => $draw->id, + 'provider_code' => 'SG', 'normalized_number' => '2468', 'action_type' => 'recover', 'amount' => 0, 'source_reason' => 'admin_manual_recover', ]); - expect(app(RiskPoolService::class)->acquire($draw->id, null, [['number_4d' => '2468', 'amount' => 1]])) + expect(app(RiskPoolService::class)->acquire($draw->id, 'SG', null, [['number_4d' => '2468', 'amount' => 1]])) ->toBe(1); }); @@ -333,3 +335,69 @@ test('admin risk pool show 404 when pool missing', function (): void { ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/0000') ->assertStatus(404); }); + +test('admin risk pools keep same number separated by provider', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-008', + 'business_date' => '2026-05-12', + 'sequence_no' => 8, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => 'SG', + 'normalized_number' => '1234', + 'total_cap_amount' => 1_000, + 'locked_amount' => 900, + 'remaining_amount' => 100, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => 'MY', + 'normalized_number' => '1234', + 'total_cap_amount' => 2_000, + 'locked_amount' => 100, + 'remaining_amount' => 1_900, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + RiskPoolLockLog::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => 'MY', + 'normalized_number' => '1234', + 'ticket_item_id' => null, + 'action_type' => 'lock', + 'amount' => 100, + 'source_reason' => 'ticket_place', + 'created_at' => now(), + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools') + ->assertOk() + ->assertJsonPath('data.meta.total', 2) + ->assertJsonPath('data.items.0.provider_code', 'SG') + ->assertJsonPath('data.items.1.provider_code', 'MY'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/1234?provider_code=MY') + ->assertOk() + ->assertJsonPath('data.pool.provider_code', 'MY') + ->assertJsonPath('data.pool.locked_amount', 100) + ->assertJsonPath('data.logs.items.0.provider_code', 'MY'); +}); diff --git a/tests/Feature/PlayerRealtimeBroadcastTest.php b/tests/Feature/PlayerRealtimeBroadcastTest.php index f64d403..bf7a334 100644 --- a/tests/Feature/PlayerRealtimeBroadcastTest.php +++ b/tests/Feature/PlayerRealtimeBroadcastTest.php @@ -88,7 +88,7 @@ test('risk pool acquire dispatches warning and sold out broadcasts', function () 'version' => 0, ]); - app(RiskPoolService::class)->acquire($draw->id, null, [ + app(RiskPoolService::class)->acquire($draw->id, 'SG', null, [ ['number_4d' => '1234', 'amount' => 250], ]); diff --git a/tests/Feature/RiskPoolProviderIsolationTest.php b/tests/Feature/RiskPoolProviderIsolationTest.php new file mode 100644 index 0000000..84ba658 --- /dev/null +++ b/tests/Feature/RiskPoolProviderIsolationTest.php @@ -0,0 +1,123 @@ +create([ + 'draw_no' => '20260709-001', + 'business_date' => '2026-07-09', + 'sequence_no' => 1, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => 'SG', + 'normalized_number' => '1234', + 'total_cap_amount' => 1_000, + 'locked_amount' => 1_000, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + 'version' => 1, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => 'MY', + 'normalized_number' => '1234', + 'total_cap_amount' => 1_000, + 'locked_amount' => 100, + 'remaining_amount' => 900, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $service = app(RiskPoolService::class); + + expect(fn () => $service->preview($draw->id, 'SG', [['number_4d' => '1234', 'amount' => 1]])) + ->toThrow(\App\Exceptions\TicketOperationException::class, 'risk_sold_out'); + + expect($service->preview($draw->id, 'MY', [['number_4d' => '1234', 'amount' => 200]])) + ->toBe([ + ['number_4d' => '1234', 'amount' => 200, 'warning' => false], + ]); + + expect($service->acquire($draw->id, 'MY', null, [['number_4d' => '1234', 'amount' => 200]])) + ->toBe(200); + + $this->assertDatabaseHas('risk_pools', [ + 'draw_id' => $draw->id, + 'provider_code' => 'SG', + 'normalized_number' => '1234', + 'locked_amount' => 1_000, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + ]); + + $this->assertDatabaseHas('risk_pools', [ + 'draw_id' => $draw->id, + 'provider_code' => 'MY', + 'normalized_number' => '1234', + 'locked_amount' => 300, + 'remaining_amount' => 700, + 'sold_out_status' => 0, + ]); +}); + +test('risk cap resolver prefers provider-specific item and falls back to global', function (): void { + $version = RiskCapVersion::query()->create([ + 'version_no' => 1, + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + ]); + + RiskCapItem::query()->create([ + 'version_id' => $version->id, + 'provider_code' => 'GLOBAL', + 'draw_id' => null, + 'normalized_number' => '0000', + 'cap_amount' => 12_345, + 'cap_type' => 'default', + ]); + + RiskCapItem::query()->create([ + 'version_id' => $version->id, + 'provider_code' => 'GLOBAL', + 'draw_id' => null, + 'normalized_number' => '1234', + 'cap_amount' => 777, + 'cap_type' => 'per_number', + ]); + + RiskCapItem::query()->create([ + 'version_id' => $version->id, + 'provider_code' => 'MY', + 'draw_id' => null, + 'normalized_number' => '1234', + 'cap_amount' => 555, + 'cap_type' => 'per_number', + ]); + + $resolver = app(PlayCatalogResolver::class); + + expect($resolver->resolveCapAmount(9999, '1234', 'MY'))->toBe(555) + ->and($resolver->resolveCapAmount(9999, '1234', 'SG'))->toBe(777) + ->and($resolver->resolveCapAmount(9999, '5678', 'TH'))->toBe(12_345); +});