feat(risk): 支持按开注商隔离风险池并新增经营报表
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Reports;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AdminReportQueryRequest;
|
||||
use App\Services\Admin\AdminReportQueryService;
|
||||
use App\Support\AdminApiList;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/** GET /api/v1/admin/reports/provider-profit */
|
||||
final class AdminReportProviderProfitController extends Controller
|
||||
{
|
||||
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
|
||||
{
|
||||
$admin = $request->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,6 +14,7 @@ final class RiskCapItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'version_id',
|
||||
'provider_code',
|
||||
'draw_id',
|
||||
'normalized_number',
|
||||
'cap_amount',
|
||||
|
||||
@@ -10,6 +10,7 @@ final class RiskPool extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'draw_id',
|
||||
'provider_code',
|
||||
'normalized_number',
|
||||
'total_cap_amount',
|
||||
'locked_amount',
|
||||
|
||||
@@ -12,6 +12,7 @@ final class RiskPoolLockLog extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'draw_id',
|
||||
'provider_code',
|
||||
'normalized_number',
|
||||
'ticket_item_id',
|
||||
'action_type',
|
||||
|
||||
@@ -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<array<int, string|int|float|null>>
|
||||
*/
|
||||
@@ -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<array<int, string|int|float|null>> */
|
||||
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<array<int, string|int|float|null>>
|
||||
*/
|
||||
|
||||
@@ -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 = <<<SQL
|
||||
SELECT
|
||||
COALESCE(NULLIF(ti.provider_code, ''), l.provider_code) AS provider_code,
|
||||
l.ticket_item_id AS ticket_item_id,
|
||||
ti.ticket_no AS ticket_no,
|
||||
ti.play_code AS play_code,
|
||||
@@ -101,7 +113,7 @@ SELECT
|
||||
FROM risk_pool_lock_logs l
|
||||
INNER JOIN ticket_items ti ON ti.id = l.ticket_item_id
|
||||
WHERE {$filters}
|
||||
GROUP BY l.ticket_item_id, ti.ticket_no, ti.play_code, ti.original_number, ti.combination_count, ti.player_id
|
||||
GROUP BY COALESCE(NULLIF(ti.provider_code, ''), l.provider_code), l.ticket_item_id, ti.ticket_no, ti.play_code, ti.original_number, ti.combination_count, ti.player_id
|
||||
SQL;
|
||||
|
||||
$countSql = "SELECT COUNT(*) AS aggregate_count FROM ({$aggregateSql}) AS grouped";
|
||||
|
||||
@@ -54,6 +54,7 @@ final class EffectivePlayCatalogService
|
||||
|
||||
$riskItems = RiskCapItem::query()
|
||||
->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<int, RiskCapItem> $items
|
||||
* @return Collection<int, RiskCapItem>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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"][] = '默认封顶不能绑定具体期号';
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<array{number_4d:string, amount:int}> $locks
|
||||
* @return list<array{number_4d:string, amount:int, warning:bool}>
|
||||
*/
|
||||
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<array{number_4d:string, amount:int}> $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<array{number_4d:string, amount:int}> $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<array{number_4d:string, amount:int}> $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<array{number_4d:string, amount:int}> $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<array{number_4d:string, amount:int}> $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<array{number_4d:string, amount:int}> $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<array{number_4d:string, amount:int}> $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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% 以上,可能即将售罄',
|
||||
];
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BetProvider;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('risk_pools', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
45
tests/Feature/AdminProviderProfitReportTest.php
Normal file
45
tests/Feature/AdminProviderProfitReportTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Services\Admin\AdminReportQueryService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('provider profit report aggregates split ticket items by provider', function (): void {
|
||||
$player = Player::query()->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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
|
||||
123
tests/Feature/RiskPoolProviderIsolationTest.php
Normal file
123
tests/Feature/RiskPoolProviderIsolationTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\RiskCapItem;
|
||||
use App\Models\RiskCapVersion;
|
||||
use App\Models\RiskPool;
|
||||
use App\Services\Ticket\PlayCatalogResolver;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('risk pool service isolates same number by provider', function (): void {
|
||||
$draw = Draw::query()->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);
|
||||
});
|
||||
Reference in New Issue
Block a user