feat(admin,api,player): 结算预览分页、统计图表与返水限额
完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": false,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,17 +507,20 @@ async function main() {
|
||||
});
|
||||
|
||||
const permCodes = [
|
||||
'users.create', 'users.view', 'agents.create', 'agents.view',
|
||||
'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
|
||||
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
||||
'cashback.confirm', 'content.manage', 'reports.view',
|
||||
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
|
||||
'bets.view', 'settings.manage', 'audit.view',
|
||||
];
|
||||
|
||||
const permIds = new Map<string, bigint>();
|
||||
for (const code of permCodes) {
|
||||
const perm = await prisma.permission.upsert({
|
||||
where: { code },
|
||||
create: { code, name: code, module: code.split('.')[0] },
|
||||
update: {},
|
||||
});
|
||||
permIds.set(code, perm.id);
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
|
||||
create: { roleId: superAdminRole.id, permissionId: perm.id },
|
||||
@@ -525,6 +528,49 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRole(code: string, name: string, permissions: string[]) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { code },
|
||||
create: { code, name, description: name },
|
||||
update: { name },
|
||||
});
|
||||
for (const p of permissions) {
|
||||
const pid = permIds.get(p);
|
||||
if (!pid) continue;
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: role.id, permissionId: pid } },
|
||||
create: { roleId: role.id, permissionId: pid },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
await ensureRole('MATCH_ADMIN', 'Match Admin', [
|
||||
'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
|
||||
'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
|
||||
'reports.view', 'bets.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
|
||||
|
||||
const defaultBettingLimits = [
|
||||
['bet.min_stake', '1', '最小单注金额'],
|
||||
['bet.max_stake_single', '50000', '单关最大投注额'],
|
||||
['bet.max_stake_parlay', '20000', '串关最大投注额'],
|
||||
['bet.max_payout_single', '500000', '单关最高派彩'],
|
||||
['bet.max_payout_parlay', '1000000', '串关最高派彩'],
|
||||
['bet.daily_stake_limit', '200000', '玩家每日投注上限'],
|
||||
] as const;
|
||||
for (const [key, value, desc] of defaultBettingLimits) {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, configValue: value, description: desc },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash('Admin@123', 10);
|
||||
const agentHash = await bcrypt.hash('Agent@123', 10);
|
||||
const playerHash = await bcrypt.hash('Player@123', 10);
|
||||
|
||||
19
apps/api/src/applications/admin/admin-permissions.ts
Normal file
19
apps/api/src/applications/admin/admin-permissions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** 后台权限码 — 与 seed 中 permissions 表一致 */
|
||||
export const P = {
|
||||
reports: 'reports.view',
|
||||
usersView: 'users.view',
|
||||
usersCreate: 'users.create',
|
||||
settings: 'settings.manage',
|
||||
agentsView: 'agents.view',
|
||||
agentsCreate: 'agents.create',
|
||||
agentsCredit: 'agents.credit',
|
||||
walletDeposit: 'wallet.deposit',
|
||||
walletWithdraw: 'wallet.withdraw',
|
||||
matches: 'matches.manage',
|
||||
settlement: 'settlement.confirm',
|
||||
resettle: 'settlement.resettle',
|
||||
bets: 'bets.view',
|
||||
cashback: 'cashback.confirm',
|
||||
content: 'content.manage',
|
||||
audit: 'audit.view',
|
||||
} as const;
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, AdminGuard } from '../../domains/identity/guards';
|
||||
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
||||
import { ContentService } from '../../domains/operations/content/content.service';
|
||||
import { CurrentUser } from '../../shared/common/decorators';
|
||||
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
|
||||
import { jsonResponse } from '../../shared/common/filters';
|
||||
import { UsersService } from '../../domains/identity/users.service';
|
||||
import { AgentsService } from '../../domains/agent/agents.service';
|
||||
@@ -27,9 +27,11 @@ import { CashbackService } from '../../domains/operations/cashback/cashback.serv
|
||||
import { I18nService } from '../../domains/operations/i18n/i18n.service';
|
||||
import { AuditService } from '../../domains/operations/audit/audit.service';
|
||||
import { BetsService } from '../../domains/betting/bets.service';
|
||||
import { BettingLimitsService } from '../../domains/betting/betting-limits.service';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { P } from './admin-permissions';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -341,17 +343,26 @@ function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
|
||||
}
|
||||
|
||||
class ScoreDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
htHome!: number;
|
||||
htHome?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
htAway!: number;
|
||||
htAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
ftHome!: number;
|
||||
ftHome?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
ftAway!: number;
|
||||
ftAway?: number;
|
||||
|
||||
/** 冠军盘结算:获胜球队 ID */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
winnerTeamId?: number;
|
||||
}
|
||||
|
||||
/* 智能比分推荐已关闭
|
||||
@@ -571,9 +582,67 @@ class CashbackPreviewDto {
|
||||
periodEnd!: string;
|
||||
}
|
||||
|
||||
class ResettlePreviewDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
htHome?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
htAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
ftHome?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
ftAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
winnerTeamId?: number;
|
||||
}
|
||||
|
||||
class BettingLimitsDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minStake?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxStakeSingle?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxStakeParlay?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPayoutSingle?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPayoutParlay?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
dailyStakeLimit?: number;
|
||||
}
|
||||
|
||||
@ApiTags('Admin')
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@UseGuards(JwtAuthGuard, AdminGuard, PermissionsGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AdminController {
|
||||
constructor(
|
||||
@@ -592,21 +661,25 @@ export class AdminController {
|
||||
private prisma: PrismaService,
|
||||
private readonly dashboardService: AdminDashboardService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
@RequirePermissions(P.reports)
|
||||
async getDashboard() {
|
||||
const overview = await this.dashboardService.getOverview();
|
||||
return jsonResponse(overview);
|
||||
}
|
||||
|
||||
@Get('users/settings/account')
|
||||
@RequirePermissions(P.settings)
|
||||
async getPlayerAccountSettings() {
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Put('users/settings/account')
|
||||
@RequirePermissions(P.settings)
|
||||
async updatePlayerAccountSettings(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: PlayerAccountSettingsDto,
|
||||
@@ -622,7 +695,32 @@ export class AdminController {
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Get('settings/betting-limits')
|
||||
@RequirePermissions(P.settings)
|
||||
async getBettingLimits() {
|
||||
const limits = await this.bettingLimits.getLimits();
|
||||
return jsonResponse(limits);
|
||||
}
|
||||
|
||||
@Put('settings/betting-limits')
|
||||
@RequirePermissions(P.settings)
|
||||
async updateBettingLimits(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: BettingLimitsDto,
|
||||
) {
|
||||
const limits = await this.bettingLimits.updateLimits(dto);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'UPDATE_BETTING_LIMITS',
|
||||
module: 'SETTINGS',
|
||||
afterData: limits,
|
||||
});
|
||||
return jsonResponse(limits);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@RequirePermissions(P.usersView)
|
||||
async listUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -643,12 +741,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
@RequirePermissions(P.usersView)
|
||||
async getUserDetail(@Param('id') id: string) {
|
||||
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
@RequirePermissions(P.usersCreate)
|
||||
async updateUser(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -666,6 +766,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@RequirePermissions(P.usersCreate)
|
||||
async createPlayer(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: CreatePlayerAdminDto,
|
||||
@@ -700,6 +801,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('users/promotable-for-agent')
|
||||
@RequirePermissions(P.usersView)
|
||||
async listPromotableForAgent(@Query('keyword') keyword?: string) {
|
||||
const rows = await this.agents.listPromotablePlayers(keyword);
|
||||
return jsonResponse(
|
||||
@@ -716,6 +818,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('agents/options')
|
||||
@RequirePermissions(P.agentsView)
|
||||
async listAgentOptions() {
|
||||
const agents = await this.prisma.user.findMany({
|
||||
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
|
||||
@@ -728,6 +831,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('agents')
|
||||
@RequirePermissions(P.agentsView)
|
||||
async listAgents(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -742,12 +846,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('agents/:id')
|
||||
@RequirePermissions(P.agentsView)
|
||||
async getAgentDetail(@Param('id') id: string) {
|
||||
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Put('agents/:id')
|
||||
@RequirePermissions(P.agentsCreate)
|
||||
async updateAgent(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -765,6 +871,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('agents')
|
||||
@RequirePermissions(P.agentsCreate)
|
||||
async createAgent(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: CreateAgentAdminDto,
|
||||
@@ -787,6 +894,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('agents/:id/credit')
|
||||
@RequirePermissions(P.agentsCredit)
|
||||
async adjustCredit(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -803,6 +911,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('wallet/deposit')
|
||||
@RequirePermissions(P.walletDeposit)
|
||||
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||
const result = await this.wallet.deposit(
|
||||
BigInt(dto.userId),
|
||||
@@ -815,6 +924,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('wallet/withdraw')
|
||||
@RequirePermissions(P.walletWithdraw)
|
||||
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||
const result = await this.wallet.withdraw(
|
||||
BigInt(dto.userId),
|
||||
@@ -827,12 +937,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
|
||||
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
|
||||
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('leagues')
|
||||
@RequirePermissions(P.matches)
|
||||
async createLeague(
|
||||
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
|
||||
) {
|
||||
@@ -853,6 +965,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('leagues')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listLeagues(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -871,6 +984,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('leagues/:leagueId/matches')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listLeagueMatches(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -886,12 +1000,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('teams')
|
||||
@RequirePermissions(P.matches)
|
||||
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||
const team = await this.matches.createTeam(dto.code, dto.translations);
|
||||
return jsonResponse(team);
|
||||
}
|
||||
|
||||
@Get('matches')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listMatches(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -928,12 +1044,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('matches/:id')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async getMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.getAdminMatchDetail(BigInt(id));
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Put('matches/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateMatch(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -964,12 +1082,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('matches/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async deleteMatch(@Param('id') id: string) {
|
||||
await this.matches.deleteMatch(BigInt(id));
|
||||
return jsonResponse({ deleted: true });
|
||||
}
|
||||
|
||||
@Post('matches')
|
||||
@RequirePermissions(P.matches)
|
||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
||||
const match = await this.matches.createPlatformMatch({
|
||||
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
|
||||
@@ -997,6 +1117,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('matches/import')
|
||||
@RequirePermissions(P.matches)
|
||||
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
|
||||
if (!isZhiboBundlePayload(dto)) {
|
||||
throw new BadRequestException('Invalid import payload: matches[] required');
|
||||
@@ -1006,18 +1127,21 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('matches/:id/publish')
|
||||
@RequirePermissions(P.matches)
|
||||
async publishMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.publishMatch(BigInt(id));
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('matches/:id/close')
|
||||
@RequirePermissions(P.matches)
|
||||
async closeMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.closeMatch(BigInt(id));
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('matches/:id/cancel')
|
||||
@RequirePermissions(P.matches)
|
||||
async cancelMatch(@Param('id') id: string) {
|
||||
await this.matches.cancelMatch(BigInt(id));
|
||||
const voided = await this.settlement.voidMatchBets(BigInt(id));
|
||||
@@ -1025,12 +1149,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('matches/:id/markets/templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
|
||||
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
|
||||
return jsonResponse(markets);
|
||||
}
|
||||
|
||||
@Put('matches/:id/odds')
|
||||
@RequirePermissions(P.matches)
|
||||
async batchUpdateMatchOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -1045,6 +1171,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Patch('markets/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
||||
const market = await this.markets.updateMarket(BigInt(id), {
|
||||
promoLabel: dto.promoLabel,
|
||||
@@ -1055,6 +1182,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Patch('selections/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateSelection(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -1073,6 +1201,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Put('selections/:id/odds')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -1083,18 +1212,21 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('outrights')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listOutrights() {
|
||||
const data = await this.outright.listForAdmin();
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Get('outrights/leagues')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listOutrightLeagues() {
|
||||
const data = await this.outright.listLeagueOptions();
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('outrights')
|
||||
@RequirePermissions(P.matches)
|
||||
async createOutright(@Body() dto: CreateOutrightDto) {
|
||||
const data = await this.outright.createForAdmin({
|
||||
leagueId: BigInt(dto.leagueId),
|
||||
@@ -1107,6 +1239,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('outrights/import/wc2026')
|
||||
@RequirePermissions(P.matches)
|
||||
async importWc2026Outright() {
|
||||
const data = await this.outright.importWc2026Canonical();
|
||||
return jsonResponse(data);
|
||||
@@ -1114,6 +1247,7 @@ export class AdminController {
|
||||
|
||||
/** @deprecated */
|
||||
@Get('outrights/wc2026')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async getWc2026OutrightLegacy() {
|
||||
const list = await this.outright.listForAdmin();
|
||||
const wc = list.find((e) => e.leagueCode === 'WC2026');
|
||||
@@ -1123,6 +1257,7 @@ export class AdminController {
|
||||
|
||||
/** @deprecated */
|
||||
@Put('outrights/wc2026/odds')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateWc2026OutrightOddsLegacy(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: BatchOutrightOddsDto,
|
||||
@@ -1137,17 +1272,20 @@ export class AdminController {
|
||||
|
||||
/** @deprecated */
|
||||
@Post('outrights/wc2026/apply-canonical')
|
||||
@RequirePermissions(P.matches)
|
||||
async applyWc2026CanonicalLegacy() {
|
||||
return jsonResponse(await this.outright.importWc2026Canonical());
|
||||
}
|
||||
|
||||
@Get('outrights/:matchId')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async getOutright(@Param('matchId') matchId: string) {
|
||||
const data = await this.outright.getForAdmin(BigInt(matchId));
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Put('outrights/:matchId')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateOutright(
|
||||
@Param('matchId') matchId: string,
|
||||
@Body() dto: UpdateOutrightDto,
|
||||
@@ -1157,6 +1295,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Put('outrights/:matchId/odds')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateOutrightOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('matchId') matchId: string,
|
||||
@@ -1171,6 +1310,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('outrights/:matchId/selections')
|
||||
@RequirePermissions(P.matches)
|
||||
async addOutrightSelection(
|
||||
@Param('matchId') matchId: string,
|
||||
@Body() dto: AddOutrightSelectionDto,
|
||||
@@ -1180,6 +1320,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Patch('outrights/:matchId/selections/:selectionId')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateOutrightSelectionTeam(
|
||||
@Param('matchId') matchId: string,
|
||||
@Param('selectionId') selectionId: string,
|
||||
@@ -1194,6 +1335,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('outrights/:matchId/selections/:selectionId')
|
||||
@RequirePermissions(P.matches)
|
||||
async removeOutrightSelection(
|
||||
@Param('matchId') matchId: string,
|
||||
@Param('selectionId') selectionId: string,
|
||||
@@ -1206,8 +1348,16 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('matches/:id/settlement/stats')
|
||||
async getMatchSettlementStats(@Param('id') id: string) {
|
||||
const data = await this.settlement.getMatchBetStats(BigInt(id));
|
||||
@RequirePermissions(P.settlement, P.reports)
|
||||
async getMatchSettlementStats(
|
||||
@Param('id') id: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const data = await this.settlement.getMatchBetStats(BigInt(id), {
|
||||
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
|
||||
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
|
||||
});
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@@ -1216,6 +1366,7 @@ export class AdminController {
|
||||
// async suggestSmartScore(...) { ... }
|
||||
|
||||
@Post('matches/:id/settlement/score')
|
||||
@RequirePermissions(P.settlement)
|
||||
async recordScore(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@@ -1223,28 +1374,99 @@ export class AdminController {
|
||||
) {
|
||||
const result = await this.settlement.recordScore(
|
||||
BigInt(id),
|
||||
dto.htHome,
|
||||
dto.htAway,
|
||||
dto.ftHome,
|
||||
dto.ftAway,
|
||||
dto.htHome ?? 0,
|
||||
dto.htAway ?? 0,
|
||||
dto.ftHome ?? 0,
|
||||
dto.ftAway ?? 0,
|
||||
operatorId,
|
||||
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('matches/:id/settlement/preview')
|
||||
async settlementPreview(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
|
||||
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId);
|
||||
@RequirePermissions(P.settlement)
|
||||
async settlementPreview(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId, {
|
||||
page: dto?.page ? Math.max(1, dto.page) : 1,
|
||||
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
|
||||
});
|
||||
return jsonResponse(preview);
|
||||
}
|
||||
|
||||
@Get('settlement/:batchId/preview-items')
|
||||
@RequirePermissions(P.settlement)
|
||||
async getSettlementPreviewItems(
|
||||
@Param('batchId') batchId: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const data = await this.settlement.getPreviewSettlementItems(BigInt(batchId), {
|
||||
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
|
||||
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
|
||||
});
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('settlement/:batchId/confirm')
|
||||
@RequirePermissions(P.settlement)
|
||||
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'CONFIRM_SETTLEMENT',
|
||||
module: 'SETTLEMENT',
|
||||
targetId: batchId,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('matches/:id/resettle/preview')
|
||||
@RequirePermissions(P.resettle)
|
||||
async resettlePreview(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ResettlePreviewDto,
|
||||
) {
|
||||
const preview = await this.settlement.previewResettlement(
|
||||
BigInt(id),
|
||||
{
|
||||
htHome: dto.htHome ?? 0,
|
||||
htAway: dto.htAway ?? 0,
|
||||
ftHome: dto.ftHome ?? 0,
|
||||
ftAway: dto.ftAway ?? 0,
|
||||
},
|
||||
operatorId,
|
||||
dto.reason,
|
||||
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||
);
|
||||
return jsonResponse(preview);
|
||||
}
|
||||
|
||||
@Post('resettle/:batchId/confirm')
|
||||
@RequirePermissions(P.resettle)
|
||||
async confirmResettlement(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('batchId') batchId: string,
|
||||
) {
|
||||
const result = await this.settlement.confirmResettlement(BigInt(batchId), operatorId);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'CONFIRM_RESETTLE',
|
||||
module: 'SETTLEMENT',
|
||||
targetId: batchId,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('bets')
|
||||
@RequirePermissions(P.bets)
|
||||
async listBets(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -1267,12 +1489,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('bets/:id')
|
||||
@RequirePermissions(P.bets)
|
||||
async getBet(@Param('id') id: string) {
|
||||
const detail = await this.bets.getBetAdminDetail(BigInt(id));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Post('cashbacks/preview')
|
||||
@RequirePermissions(P.cashback, P.reports)
|
||||
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
|
||||
const preview = await this.cashback.previewBatch(
|
||||
new Date(dto.periodStart),
|
||||
@@ -1282,12 +1506,21 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('cashbacks/:batchId/confirm')
|
||||
@RequirePermissions(P.cashback)
|
||||
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'CONFIRM_CASHBACK',
|
||||
module: 'CASHBACK',
|
||||
targetId: batchId,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async listContents(
|
||||
@Query('type') type?: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -1297,24 +1530,28 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('contents/:id')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async getContent(@Param('id') id: string) {
|
||||
const item = await this.content.getForAdmin(BigInt(id));
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Post('contents')
|
||||
@RequirePermissions(P.content)
|
||||
async createContent(@Body() dto: CreateContentDto) {
|
||||
const item = await this.content.create(dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Put('contents/:id')
|
||||
@RequirePermissions(P.content)
|
||||
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
|
||||
const item = await this.content.update(BigInt(id), dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Patch('contents/:id/status')
|
||||
@RequirePermissions(P.content)
|
||||
async updateContentStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ContentStatusDto,
|
||||
@@ -1324,18 +1561,21 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('contents/:id')
|
||||
@RequirePermissions(P.content)
|
||||
async deleteContent(@Param('id') id: string) {
|
||||
const result = await this.content.remove(BigInt(id));
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('i18n/messages')
|
||||
@RequirePermissions(P.settings, P.reports)
|
||||
async getMessages(@Query('locale') locale = 'en-US') {
|
||||
const messages = await this.i18n.getMessages(locale);
|
||||
return jsonResponse(messages);
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
@RequirePermissions(P.audit)
|
||||
async auditLogs(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { PermissionsGuard } from '../../domains/identity/guards';
|
||||
import { UsersModule } from '../../domains/identity/users.module';
|
||||
import { AgentsModule } from '../../domains/agent/agents.module';
|
||||
import { WalletModule } from '../../domains/ledger/wallet.module';
|
||||
@@ -26,6 +27,6 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
||||
BetsModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminDashboardService],
|
||||
providers: [AdminDashboardService, PermissionsGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BetsService } from './bets.service';
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
providers: [BetsService],
|
||||
exports: [BetsService],
|
||||
providers: [BetsService, BettingLimitsService],
|
||||
exports: [BetsService, BettingLimitsService],
|
||||
})
|
||||
export class BetsModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, BadRequestException, ConflictException, NotFoundException }
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../../shared/common/decorators';
|
||||
import {
|
||||
@@ -10,8 +11,25 @@ import {
|
||||
canSelectForParlay,
|
||||
isPreMatchKickoff,
|
||||
isSupportedSport,
|
||||
resolveTranslationFallback,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
type MatchContext = {
|
||||
matchLabel: string;
|
||||
leagueName: string;
|
||||
};
|
||||
|
||||
type BetSelectionRow = {
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
handicapLine: Decimal | null;
|
||||
totalLine: Decimal | null;
|
||||
odds: Decimal;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
oddsVersion: bigint;
|
||||
@@ -23,6 +41,7 @@ export class BetsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(
|
||||
@@ -92,6 +111,12 @@ export class BetsService {
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -148,16 +173,9 @@ export class BetsService {
|
||||
if (existing) return existing;
|
||||
|
||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||
const matchIds = new Set<string>();
|
||||
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
||||
|
||||
const matchKey = sel.market.matchId.toString();
|
||||
if (matchIds.has(matchKey)) {
|
||||
throw new BadRequestException('Same match cannot be in parlay');
|
||||
}
|
||||
matchIds.add(matchKey);
|
||||
selections.push(sel);
|
||||
}
|
||||
|
||||
@@ -168,6 +186,12 @@ export class BetsService {
|
||||
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'PARLAY',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -234,6 +258,105 @@ export class BetsService {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
private resolveEntityName(
|
||||
translations: Array<{ entityType: string; entityId: bigint; locale: string; value: string }>,
|
||||
entityType: string,
|
||||
entityId: bigint,
|
||||
locale: string,
|
||||
) {
|
||||
const map = Object.fromEntries(
|
||||
translations
|
||||
.filter(
|
||||
(t) =>
|
||||
t.entityType === entityType && t.entityId.toString() === entityId.toString(),
|
||||
)
|
||||
.map((t) => [t.locale, t.value]),
|
||||
);
|
||||
return resolveTranslationFallback(map, locale);
|
||||
}
|
||||
|
||||
private async loadMatchContext(
|
||||
matchIds: bigint[],
|
||||
locale = 'zh-CN',
|
||||
): Promise<Map<string, MatchContext>> {
|
||||
const result = new Map<string, MatchContext>();
|
||||
if (matchIds.length === 0) return result;
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: { id: { in: matchIds } },
|
||||
select: {
|
||||
id: true,
|
||||
matchName: true,
|
||||
leagueId: true,
|
||||
homeTeamId: true,
|
||||
awayTeamId: true,
|
||||
homeTeam: { select: { code: true } },
|
||||
awayTeam: { select: { code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const entityIds = new Set<bigint>();
|
||||
for (const m of matches) {
|
||||
entityIds.add(m.leagueId);
|
||||
entityIds.add(m.homeTeamId);
|
||||
entityIds.add(m.awayTeamId);
|
||||
}
|
||||
|
||||
const translations =
|
||||
entityIds.size > 0
|
||||
? await this.prisma.entityTranslation.findMany({
|
||||
where: {
|
||||
entityId: { in: Array.from(entityIds) },
|
||||
entityType: { in: ['TEAM', 'LEAGUE'] },
|
||||
fieldName: 'name',
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
for (const m of matches) {
|
||||
const leagueName =
|
||||
this.resolveEntityName(translations, 'LEAGUE', m.leagueId, locale) || m.leagueId.toString();
|
||||
const homeName =
|
||||
this.resolveEntityName(translations, 'TEAM', m.homeTeamId, locale) || m.homeTeam.code;
|
||||
const awayName =
|
||||
this.resolveEntityName(translations, 'TEAM', m.awayTeamId, locale) || m.awayTeam.code;
|
||||
const matchLabel = m.matchName?.trim() || `${homeName} vs ${awayName}`;
|
||||
result.set(m.id.toString(), { matchLabel, leagueName });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private formatSelectionPreviews(
|
||||
selections: BetSelectionRow[],
|
||||
matchContext: Map<string, MatchContext>,
|
||||
) {
|
||||
return selections.map((s) => {
|
||||
const ctx = s.matchId ? matchContext.get(s.matchId.toString()) : undefined;
|
||||
return {
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
matchLabel: ctx?.matchLabel ?? '—',
|
||||
leagueName: ctx?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
odds: this.dec(s.odds),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private attachSelectionPreviews<T extends Record<string, unknown>>(
|
||||
row: T,
|
||||
selections: BetSelectionRow[],
|
||||
matchContext: Map<string, MatchContext>,
|
||||
) {
|
||||
const selectionPreviews = this.formatSelectionPreviews(selections, matchContext);
|
||||
const selectionSummary = selectionPreviews
|
||||
.map((p) => `${p.matchLabel} · ${p.selectionName}`)
|
||||
.join(';');
|
||||
return { ...row, selectionPreviews, selectionSummary };
|
||||
}
|
||||
|
||||
private formatBetListRow(
|
||||
b: {
|
||||
id: bigint;
|
||||
@@ -326,7 +449,7 @@ export class BetsService {
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { selections: true } },
|
||||
selections: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
@@ -335,8 +458,26 @@ export class BetsService {
|
||||
this.prisma.bet.count({ where }),
|
||||
]);
|
||||
|
||||
const matchIds = [
|
||||
...new Set(
|
||||
items.flatMap((b) =>
|
||||
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
|
||||
),
|
||||
),
|
||||
];
|
||||
const matchContext = await this.loadMatchContext(matchIds);
|
||||
|
||||
return {
|
||||
items: items.map((b) => this.formatBetListRow(b)),
|
||||
items: items.map((b) =>
|
||||
this.attachSelectionPreviews(
|
||||
this.formatBetListRow({
|
||||
...b,
|
||||
_count: { selections: b.selections.length },
|
||||
}),
|
||||
b.selections,
|
||||
matchContext,
|
||||
),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -359,27 +500,44 @@ export class BetsService {
|
||||
});
|
||||
if (!bet) throw new NotFoundException('注单不存在');
|
||||
|
||||
return {
|
||||
...this.formatBetListRow({
|
||||
const matchIds = bet.selections
|
||||
.map((s) => s.matchId)
|
||||
.filter((id): id is bigint => id != null);
|
||||
const matchContext = await this.loadMatchContext(matchIds);
|
||||
const selectionPreviews = this.formatSelectionPreviews(bet.selections, matchContext);
|
||||
|
||||
const base = this.attachSelectionPreviews(
|
||||
this.formatBetListRow({
|
||||
...bet,
|
||||
_count: { selections: bet.selections.length },
|
||||
}),
|
||||
bet.selections,
|
||||
matchContext,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
requestId: bet.requestId,
|
||||
createdAt: bet.createdAt,
|
||||
updatedAt: bet.updatedAt,
|
||||
selections: bet.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
||||
odds: this.dec(s.odds),
|
||||
resultStatus: s.resultStatus,
|
||||
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
selections: bet.selections.map((s, index) => {
|
||||
const preview = selectionPreviews[index];
|
||||
return {
|
||||
id: s.id.toString(),
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
matchLabel: preview?.matchLabel ?? '—',
|
||||
leagueName: preview?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
||||
odds: this.dec(s.odds),
|
||||
resultStatus: s.resultStatus,
|
||||
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
||||
sortOrder: s.sortOrder,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
54
apps/api/src/domains/betting/betting-limits.service.spec.ts
Normal file
54
apps/api/src/domains/betting/betting-limits.service.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
describe('BettingLimitsService', () => {
|
||||
const prisma = {
|
||||
systemConfig: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
bet: {
|
||||
aggregate: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const service = new BettingLimitsService(prisma as never);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.systemConfig.findUnique.mockResolvedValue(null);
|
||||
prisma.bet.aggregate.mockResolvedValue({ _sum: { stake: new Decimal(0) } });
|
||||
});
|
||||
|
||||
it('rejects stake below minimum', async () => {
|
||||
await expect(
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 0.5,
|
||||
potentialReturn: new Decimal(1),
|
||||
}),
|
||||
).rejects.toThrow('Minimum stake is 1');
|
||||
});
|
||||
|
||||
it('rejects stake above single max', async () => {
|
||||
await expect(
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 60000,
|
||||
potentialReturn: new Decimal(70000),
|
||||
}),
|
||||
).rejects.toThrow('Maximum stake is 50000');
|
||||
});
|
||||
|
||||
it('rejects potential return above payout cap', async () => {
|
||||
await expect(
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 100,
|
||||
potentialReturn: new Decimal(600000),
|
||||
}),
|
||||
).rejects.toThrow('Potential return exceeds limit');
|
||||
});
|
||||
});
|
||||
126
apps/api/src/domains/betting/betting-limits.service.ts
Normal file
126
apps/api/src/domains/betting/betting-limits.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type BettingLimits = {
|
||||
minStake: number;
|
||||
maxStakeSingle: number;
|
||||
maxStakeParlay: number;
|
||||
maxPayoutSingle: number;
|
||||
maxPayoutParlay: number;
|
||||
dailyStakeLimit: number;
|
||||
};
|
||||
|
||||
export const BET_LIMIT_KEYS = {
|
||||
minStake: 'bet.min_stake',
|
||||
maxStakeSingle: 'bet.max_stake_single',
|
||||
maxStakeParlay: 'bet.max_stake_parlay',
|
||||
maxPayoutSingle: 'bet.max_payout_single',
|
||||
maxPayoutParlay: 'bet.max_payout_parlay',
|
||||
dailyStakeLimit: 'bet.daily_stake_limit',
|
||||
} as const;
|
||||
|
||||
const DEFAULTS: BettingLimits = {
|
||||
minStake: 1,
|
||||
maxStakeSingle: 50000,
|
||||
maxStakeParlay: 20000,
|
||||
maxPayoutSingle: 500000,
|
||||
maxPayoutParlay: 1000000,
|
||||
dailyStakeLimit: 200000,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BettingLimitsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private async getNumber(key: string, fallback: number): Promise<number> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
if (!row) return fallback;
|
||||
const n = Number(row.configValue);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
private async setNumber(key: string, value: number, description: string) {
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, configValue: String(value), description },
|
||||
update: { configValue: String(value) },
|
||||
});
|
||||
}
|
||||
|
||||
async getLimits(): Promise<BettingLimits> {
|
||||
return {
|
||||
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
|
||||
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
|
||||
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
|
||||
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
|
||||
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
|
||||
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
|
||||
};
|
||||
}
|
||||
|
||||
async updateLimits(data: Partial<BettingLimits>): Promise<BettingLimits> {
|
||||
const desc: Record<keyof BettingLimits, string> = {
|
||||
minStake: '最小单注金额',
|
||||
maxStakeSingle: '单关最大投注额',
|
||||
maxStakeParlay: '串关最大投注额',
|
||||
maxPayoutSingle: '单关最高派彩',
|
||||
maxPayoutParlay: '串关最高派彩',
|
||||
dailyStakeLimit: '玩家每日投注上限',
|
||||
};
|
||||
for (const [field, key] of Object.entries(BET_LIMIT_KEYS) as Array<
|
||||
[keyof BettingLimits, string]
|
||||
>) {
|
||||
const val = data[field];
|
||||
if (val !== undefined) {
|
||||
await this.setNumber(key, val, desc[field]);
|
||||
}
|
||||
}
|
||||
return this.getLimits();
|
||||
}
|
||||
|
||||
async validateBet(params: {
|
||||
userId: bigint;
|
||||
betType: 'SINGLE' | 'PARLAY';
|
||||
stake: number;
|
||||
potentialReturn: Decimal;
|
||||
}) {
|
||||
const limits = await this.getLimits();
|
||||
const stake = params.stake;
|
||||
|
||||
if (stake < limits.minStake) {
|
||||
throw new BadRequestException(`Minimum stake is ${limits.minStake}`);
|
||||
}
|
||||
|
||||
const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle;
|
||||
if (stake > maxStake) {
|
||||
throw new BadRequestException(`Maximum stake is ${maxStake}`);
|
||||
}
|
||||
|
||||
const maxPayout =
|
||||
params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle;
|
||||
if (params.potentialReturn.gt(maxPayout)) {
|
||||
throw new BadRequestException(`Potential return exceeds limit of ${maxPayout}`);
|
||||
}
|
||||
|
||||
if (limits.dailyStakeLimit > 0) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const agg = await this.prisma.bet.aggregate({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
placedAt: { gte: startOfDay, lte: endOfDay },
|
||||
status: { notIn: ['VOID', 'CANCELLED'] },
|
||||
},
|
||||
_sum: { stake: true },
|
||||
});
|
||||
const todayStake = new Decimal(agg._sum.stake ?? 0);
|
||||
if (todayStake.add(stake).gt(limits.dailyStakeLimit)) {
|
||||
throw new BadRequestException(`Daily stake limit of ${limits.dailyStakeLimit} exceeded`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,20 @@ export class PermissionsGuard implements CanActivate {
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required?.length) return true;
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
const userPerms: string[] = user?.permissions ?? [];
|
||||
if (user?.role === 'SUPER_ADMIN') return true;
|
||||
if (!user || user.userType !== 'ADMIN') {
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
if (user.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
const hasAll = required.every((p) => userPerms.includes(p));
|
||||
if (!hasAll) throw new ForbiddenException('Insufficient permissions');
|
||||
if (!required?.length) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
|
||||
const userPerms: string[] = user.permissions ?? [];
|
||||
const hasAccess = required.some((p) => userPerms.includes(p));
|
||||
if (!hasAccess) throw new ForbiddenException('Insufficient permissions');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
const permissions =
|
||||
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
|
||||
const roleCode = user.adminRole?.role?.code ?? payload.role;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -47,7 +48,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
parentId: user.parentId,
|
||||
agentLevel: user.agentLevel,
|
||||
locale: user.locale,
|
||||
role: payload.role,
|
||||
role: roleCode,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateTransactionId } from '../../shared/common/decorators';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
@Injectable()
|
||||
export class WalletService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -19,7 +22,7 @@ export class WalletService {
|
||||
});
|
||||
}
|
||||
|
||||
private async lockWallet(tx: Parameters<Parameters<PrismaService['$transaction']>[0]>[0], userId: bigint) {
|
||||
private async lockWallet(tx: TxClient, userId: bigint) {
|
||||
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
|
||||
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
|
||||
`;
|
||||
@@ -163,24 +166,25 @@ export class WalletService {
|
||||
payout: Decimal,
|
||||
betId: string,
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
tx?: TxClient,
|
||||
) {
|
||||
const txTypeMap: Record<string, string> = {
|
||||
WIN: 'BET_SETTLE_WIN',
|
||||
LOSE: 'BET_SETTLE_LOSE',
|
||||
PUSH: 'BET_SETTLE_PUSH',
|
||||
VOID: 'BET_VOID_REFUND',
|
||||
HALF_WIN: 'BET_SETTLE_WIN',
|
||||
HALF_LOSE: 'BET_SETTLE_LOSE',
|
||||
};
|
||||
const run = async (client: TxClient) => {
|
||||
const txTypeMap: Record<string, string> = {
|
||||
WIN: 'BET_SETTLE_WIN',
|
||||
LOSE: 'BET_SETTLE_LOSE',
|
||||
PUSH: 'BET_SETTLE_PUSH',
|
||||
VOID: 'BET_VOID_REFUND',
|
||||
HALF_WIN: 'BET_SETTLE_WIN',
|
||||
HALF_LOSE: 'BET_SETTLE_LOSE',
|
||||
};
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const frozen = new Decimal(w.frozen_balance);
|
||||
const frozenAfter = frozen.sub(stake);
|
||||
const balanceAfter = avail.add(payout);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -189,7 +193,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -204,7 +208,55 @@ export class WalletService {
|
||||
referenceId: betId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
/** 重结算差额调整:delta = 新派彩 - 原派彩,允许扣回导致负余额 */
|
||||
async applyResettleDelta(
|
||||
userId: bigint,
|
||||
delta: Decimal,
|
||||
betNo: string,
|
||||
tx?: TxClient,
|
||||
) {
|
||||
if (delta.eq(0)) return;
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const balanceAfter = avail.add(delta);
|
||||
const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE';
|
||||
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
walletId: w.id,
|
||||
transactionType: txType,
|
||||
amount: delta,
|
||||
balanceBefore: avail,
|
||||
balanceAfter,
|
||||
frozenBefore: w.frozen_balance,
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'BET',
|
||||
referenceId: betNo,
|
||||
remark: 'Resettlement adjustment',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { resolveCashbackRateForBet } from './cashback-rate.resolver';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
describe('resolveCashbackRateForBet', () => {
|
||||
const userId = BigInt(100);
|
||||
const agentId = BigInt(200);
|
||||
|
||||
it('uses agent default when no rules', () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId,
|
||||
agentId,
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.02'),
|
||||
rules: [],
|
||||
});
|
||||
expect(rate.toString()).toBe('0.02');
|
||||
});
|
||||
|
||||
it('prefers USER rule over agent default', () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId,
|
||||
agentId,
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'USER',
|
||||
targetId: userId,
|
||||
rate: new Decimal('0.03'),
|
||||
marketType: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(rate.toString()).toBe('0.03');
|
||||
});
|
||||
|
||||
it('applies market-specific rule when market matches', () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId,
|
||||
agentId,
|
||||
marketTypes: ['FT_HANDICAP'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'GLOBAL',
|
||||
targetId: null,
|
||||
rate: new Decimal('0.005'),
|
||||
marketType: 'FT_HANDICAP',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(rate.toString()).toBe('0.005');
|
||||
});
|
||||
|
||||
it('skips market-specific rule when market does not match', () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId,
|
||||
agentId,
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'GLOBAL',
|
||||
targetId: null,
|
||||
rate: new Decimal('0.005'),
|
||||
marketType: 'FT_HANDICAP',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(rate.toString()).toBe('0.01');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
export type CashbackRuleRow = {
|
||||
targetType: string;
|
||||
targetId: bigint | null;
|
||||
rate: Decimal;
|
||||
marketType: string | null;
|
||||
};
|
||||
|
||||
const TARGET_PRIORITY: Record<string, number> = {
|
||||
USER: 3,
|
||||
AGENT: 2,
|
||||
GLOBAL: 1,
|
||||
};
|
||||
|
||||
export function resolveCashbackRateForBet(params: {
|
||||
userId: bigint;
|
||||
agentId: bigint | null;
|
||||
marketTypes: string[];
|
||||
agentDefaultRate: Decimal;
|
||||
rules: CashbackRuleRow[];
|
||||
}): Decimal {
|
||||
const { userId, agentId, marketTypes, agentDefaultRate, rules } = params;
|
||||
let best: { priority: number; rate: Decimal } | null = null;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.marketType && !marketTypes.includes(rule.marketType)) continue;
|
||||
|
||||
let priority = 0;
|
||||
if (rule.targetType === 'USER' && rule.targetId?.toString() === userId.toString()) {
|
||||
priority = TARGET_PRIORITY.USER;
|
||||
} else if (
|
||||
rule.targetType === 'AGENT' &&
|
||||
agentId != null &&
|
||||
rule.targetId?.toString() === agentId.toString()
|
||||
) {
|
||||
priority = TARGET_PRIORITY.AGENT;
|
||||
} else if (rule.targetType === 'GLOBAL') {
|
||||
priority = TARGET_PRIORITY.GLOBAL;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!best || priority > best.priority) {
|
||||
best = { priority, rate: rule.rate };
|
||||
}
|
||||
}
|
||||
|
||||
return best?.rate ?? agentDefaultRate;
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../../shared/common/decorators';
|
||||
import {
|
||||
resolveCashbackRateForBet,
|
||||
type CashbackRuleRow,
|
||||
} from './cashback-rate.resolver';
|
||||
|
||||
@Injectable()
|
||||
export class CashbackService {
|
||||
@@ -12,37 +16,98 @@ export class CashbackService {
|
||||
) {}
|
||||
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'SETTLED'] },
|
||||
settledAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
include: { user: { include: { agentProfile: true } } },
|
||||
});
|
||||
const [settledBets, rules, agentProfiles] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST'] },
|
||||
settledAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, parentId: true } },
|
||||
selections: { select: { marketType: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.cashbackRule.findMany({ where: { isActive: true } }),
|
||||
this.prisma.agentProfile.findMany({
|
||||
select: { userId: true, cashbackRate: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const playerStakes = new Map<string, { userId: bigint; stake: Decimal; rate: Decimal }>();
|
||||
|
||||
for (const bet of settledBets) {
|
||||
if (bet.status === 'PUSH' || bet.status === 'VOID') continue;
|
||||
|
||||
const key = bet.userId.toString();
|
||||
const existing = playerStakes.get(key) ?? {
|
||||
userId: bet.userId,
|
||||
stake: new Decimal(0),
|
||||
rate: new Decimal(0.01),
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
playerStakes.set(key, existing);
|
||||
}
|
||||
|
||||
const items = Array.from(playerStakes.values()).map((p) => ({
|
||||
userId: p.userId,
|
||||
effectiveStake: p.stake,
|
||||
rate: p.rate,
|
||||
amount: p.stake.mul(p.rate),
|
||||
const agentRateById = new Map(
|
||||
agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]),
|
||||
);
|
||||
const ruleRows: CashbackRuleRow[] = rules.map((r) => ({
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
rate: new Decimal(r.rate),
|
||||
marketType: r.marketType,
|
||||
}));
|
||||
|
||||
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
const playerAgg = new Map<
|
||||
string,
|
||||
{ userId: bigint; stake: Decimal; amount: Decimal }
|
||||
>();
|
||||
|
||||
for (const bet of settledBets) {
|
||||
const agentId = bet.user.parentId;
|
||||
const agentDefaultRate = agentId
|
||||
? agentRateById.get(agentId.toString()) ?? new Decimal(0)
|
||||
: new Decimal(0);
|
||||
const marketTypes = bet.selections.map((s) => s.marketType);
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: bet.userId,
|
||||
agentId,
|
||||
marketTypes,
|
||||
agentDefaultRate,
|
||||
rules: ruleRows,
|
||||
});
|
||||
|
||||
if (rate.lte(0)) continue;
|
||||
|
||||
const key = bet.userId.toString();
|
||||
const existing = playerAgg.get(key) ?? {
|
||||
userId: bet.userId,
|
||||
stake: new Decimal(0),
|
||||
amount: new Decimal(0),
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
existing.amount = existing.amount.add(bet.stake.mul(rate));
|
||||
playerAgg.set(key, existing);
|
||||
}
|
||||
|
||||
const items = Array.from(playerAgg.values())
|
||||
.map((p) => ({
|
||||
userId: p.userId,
|
||||
effectiveStake: p.stake,
|
||||
rate: p.stake.gt(0) ? p.amount.div(p.stake) : new Decimal(0),
|
||||
amount: p.amount,
|
||||
}))
|
||||
.sort((a, b) => (b.amount.gt(a.amount) ? 1 : a.amount.gt(b.amount) ? -1 : 0));
|
||||
|
||||
const userIds = items.map((i) => i.userId);
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
const enrichedItems = items.map((item) => {
|
||||
const user = userById.get(item.userId.toString());
|
||||
return {
|
||||
...item,
|
||||
username: user?.username ?? '',
|
||||
agentUsername: user?.parent?.username ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
|
||||
const batch = await this.prisma.cashbackBatch.create({
|
||||
data: {
|
||||
@@ -55,7 +120,7 @@ export class CashbackService {
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
for (const item of enrichedItems) {
|
||||
await this.prisma.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
@@ -67,7 +132,7 @@ export class CashbackService {
|
||||
});
|
||||
}
|
||||
|
||||
return { batch, items, totalAmount };
|
||||
return { batch, items: enrichedItems, totalAmount };
|
||||
}
|
||||
|
||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||
|
||||
@@ -110,9 +110,40 @@ describe('SettlementCalculator', () => {
|
||||
});
|
||||
expect(r).toBe('HALF_LOSE');
|
||||
});
|
||||
|
||||
it('0-0: home -0.5 loses', () => {
|
||||
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -0.5,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Over/Under', () => {
|
||||
it('0-0: under 2.5 wins, over 2.5 loses', () => {
|
||||
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||
const under = settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 2.5,
|
||||
score: s,
|
||||
});
|
||||
const over = settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2.5,
|
||||
score: s,
|
||||
});
|
||||
expect(under).toBe('WIN');
|
||||
expect(over).toBe('LOSE');
|
||||
expect(calculatePayout(100, 1.95, under).toNumber()).toBe(195);
|
||||
});
|
||||
|
||||
it('S013: over 2.5 wins with 3 goals', () => {
|
||||
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
|
||||
expect(
|
||||
@@ -177,6 +208,27 @@ describe('SettlementCalculator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OUTRIGHT_WINNER', () => {
|
||||
it('wins when selection matches winner team code', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
selectionCode: 'BRA',
|
||||
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
|
||||
winnerTeamCode: 'BRA',
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
selectionCode: 'ARG',
|
||||
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
|
||||
winnerTeamCode: 'BRA',
|
||||
}),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quarter line detection', () => {
|
||||
it('detects quarter lines', () => {
|
||||
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SettlementInput {
|
||||
totalLine?: number | null;
|
||||
score: ScoreInput;
|
||||
templateScores?: string[];
|
||||
/** 冠军盘:获胜球队 code,如 FRA、BRA */
|
||||
winnerTeamCode?: string | null;
|
||||
}
|
||||
|
||||
export function getShScore(score: ScoreInput): { home: number; away: number } {
|
||||
@@ -196,7 +198,8 @@ export function settleSelection(input: SettlementInput): SelectionResult {
|
||||
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
|
||||
}
|
||||
case 'OUTRIGHT_WINNER':
|
||||
return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE';
|
||||
if (!input.winnerTeamCode) return 'LOSE';
|
||||
return selectionCode === input.winnerTeamCode ? 'WIN' : 'LOSE';
|
||||
}
|
||||
|
||||
return 'LOSE';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { resolveSelectionCode } from './settlement-helpers';
|
||||
|
||||
describe('resolveSelectionCode', () => {
|
||||
it('prefers database selection code', () => {
|
||||
expect(resolveSelectionCode('UNDER', '小 2.5')).toBe('UNDER');
|
||||
});
|
||||
|
||||
it('maps Chinese snapshot names when code is missing', () => {
|
||||
expect(resolveSelectionCode(null, '主胜')).toBe('HOME');
|
||||
expect(resolveSelectionCode('', '小 2.5')).toBe('UNDER');
|
||||
expect(resolveSelectionCode(undefined, '大 2.5')).toBe('OVER');
|
||||
expect(resolveSelectionCode(null, '主 -0.5')).toBe('HOME');
|
||||
});
|
||||
});
|
||||
37
apps/api/src/domains/settlement/domain/settlement-helpers.ts
Normal file
37
apps/api/src/domains/settlement/domain/settlement-helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from './settlement-calculator';
|
||||
|
||||
const SNAPSHOT_1X2: Record<string, string> = {
|
||||
主胜: 'HOME',
|
||||
客胜: 'AWAY',
|
||||
和局: 'DRAW',
|
||||
平局: 'DRAW',
|
||||
};
|
||||
|
||||
/** 盘口 code 缺失时,从下单快照名推断标准 selectionCode */
|
||||
export function resolveSelectionCode(
|
||||
selectionCode: string | null | undefined,
|
||||
nameSnapshot: string,
|
||||
): string {
|
||||
if (selectionCode?.trim()) return selectionCode.trim();
|
||||
|
||||
const name = nameSnapshot.trim();
|
||||
if (SNAPSHOT_1X2[name]) return SNAPSHOT_1X2[name];
|
||||
if (name.startsWith('大')) return 'OVER';
|
||||
if (name.startsWith('小')) return 'UNDER';
|
||||
if (name.startsWith('主')) return 'HOME';
|
||||
if (name.startsWith('客')) return 'AWAY';
|
||||
|
||||
if (name.includes('-')) {
|
||||
return `SCORE_${name.replace('-', '_')}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function templateScoresForMarket(marketType: string): string[] {
|
||||
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||
return [];
|
||||
}
|
||||
@@ -9,22 +9,25 @@ import {
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
ScoreInput,
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
SelectionResult,
|
||||
} from './domain/settlement-calculator';
|
||||
// 智能比分推荐已关闭
|
||||
// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver';
|
||||
import {
|
||||
resolveSelectionCode,
|
||||
templateScoresForMarket,
|
||||
} from './domain/settlement-helpers';
|
||||
|
||||
function resolveSelectionCodeFromLeg(
|
||||
selectionCode: string | null | undefined,
|
||||
nameSnapshot: string,
|
||||
): string {
|
||||
if (selectionCode?.trim()) return selectionCode.trim();
|
||||
if (nameSnapshot.includes('-')) {
|
||||
return `SCORE_${nameSnapshot.replace('-', '_')}`;
|
||||
}
|
||||
return nameSnapshot;
|
||||
}
|
||||
type BetSelectionLeg = {
|
||||
id: bigint;
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
selectionId: bigint;
|
||||
selectionNameSnapshot: string;
|
||||
handicapLine: Decimal | null;
|
||||
totalLine: Decimal | null;
|
||||
odds: Decimal;
|
||||
resultStatus: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
@@ -34,6 +37,52 @@ export class SettlementService {
|
||||
private agents: AgentsService,
|
||||
) {}
|
||||
|
||||
private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise<string | null> {
|
||||
if (!winnerTeamId) return null;
|
||||
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
|
||||
return team?.code ?? null;
|
||||
}
|
||||
|
||||
private buildSettleInput(
|
||||
sel: BetSelectionLeg,
|
||||
selectionCode: string,
|
||||
scoreInput: ScoreInput,
|
||||
winnerTeamCode: string | null,
|
||||
) {
|
||||
return {
|
||||
marketType: sel.marketType,
|
||||
selectionCode,
|
||||
handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine != null ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
templateScores: templateScoresForMarket(sel.marketType),
|
||||
winnerTeamCode,
|
||||
};
|
||||
}
|
||||
|
||||
private settleLegResult(
|
||||
sel: BetSelectionLeg,
|
||||
selectionCode: string,
|
||||
scoreInput: ScoreInput,
|
||||
winnerTeamCode: string | null,
|
||||
): SelectionResult {
|
||||
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode));
|
||||
}
|
||||
|
||||
private walletResultFromSelection(
|
||||
result: SelectionResult,
|
||||
): 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE' {
|
||||
if (result === 'HALF_WIN') return 'HALF_WIN';
|
||||
if (result === 'HALF_LOSE') return 'HALF_LOSE';
|
||||
return result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID';
|
||||
}
|
||||
|
||||
private betStatusFromSelection(result: SelectionResult): 'LOST' | 'PUSH' | 'WON' {
|
||||
if (result === 'LOSE') return 'LOST';
|
||||
if (result === 'PUSH' || result === 'VOID') return 'PUSH';
|
||||
return 'WON';
|
||||
}
|
||||
|
||||
async recordScore(
|
||||
matchId: bigint,
|
||||
htHome: number,
|
||||
@@ -41,11 +90,49 @@ export class SettlementService {
|
||||
ftHome: number,
|
||||
ftAway: number,
|
||||
operatorId: bigint,
|
||||
winnerTeamId?: bigint,
|
||||
) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
|
||||
if (match.isOutright) {
|
||||
if (!winnerTeamId) {
|
||||
throw new BadRequestException('冠军盘结算需指定获胜球队 winnerTeamId');
|
||||
}
|
||||
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
|
||||
if (!team) throw new BadRequestException('获胜球队不存在');
|
||||
const outrightSel = await this.prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
market: { matchId, marketType: 'OUTRIGHT_WINNER' },
|
||||
selectionCode: team.code,
|
||||
},
|
||||
});
|
||||
if (!outrightSel) {
|
||||
throw new BadRequestException('该球队不在本冠军盘选项中');
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.matchScore.upsert({
|
||||
where: { matchId },
|
||||
create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
|
||||
update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
|
||||
create: {
|
||||
matchId,
|
||||
htHomeScore: match.isOutright ? 0 : htHome,
|
||||
htAwayScore: match.isOutright ? 0 : htAway,
|
||||
ftHomeScore: match.isOutright ? 0 : ftHome,
|
||||
ftAwayScore: match.isOutright ? 0 : ftAway,
|
||||
winnerTeamId: match.isOutright ? winnerTeamId : null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
update: {
|
||||
htHomeScore: match.isOutright ? 0 : htHome,
|
||||
htAwayScore: match.isOutright ? 0 : htAway,
|
||||
ftHomeScore: match.isOutright ? 0 : ftHome,
|
||||
ftAwayScore: match.isOutright ? 0 : ftAway,
|
||||
winnerTeamId: match.isOutright ? winnerTeamId : null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.match.update({
|
||||
@@ -53,75 +140,18 @@ export class SettlementService {
|
||||
data: { status: 'PENDING_SETTLEMENT' },
|
||||
});
|
||||
|
||||
return { matchId, htHome, htAway, ftHome, ftAway };
|
||||
return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null };
|
||||
}
|
||||
|
||||
async previewSettlement(matchId: bigint, operatorId: bigint) {
|
||||
async previewSettlement(
|
||||
matchId: bigint,
|
||||
operatorId: bigint,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
if (!score) throw new BadRequestException('Score not recorded');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: score.htHomeScore ?? 0,
|
||||
htAway: score.htAwayScore ?? 0,
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId } },
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
const parlayBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
betType: 'PARLAY',
|
||||
selections: { some: { matchId } },
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
let totalPayout = new Decimal(0);
|
||||
let totalRefund = new Decimal(0);
|
||||
const items: Array<{ betId: bigint; betNo: string; result: string; payout: Decimal }> = [];
|
||||
|
||||
for (const bet of pendingBets) {
|
||||
if (bet.betType === 'SINGLE') {
|
||||
const sel = bet.selections[0];
|
||||
const template =
|
||||
sel.marketType === 'FT_CORRECT_SCORE'
|
||||
? FT_CORRECT_SCORE_TEMPLATE
|
||||
: sel.marketType.includes('CORRECT_SCORE')
|
||||
? HT_CORRECT_SCORE_TEMPLATE
|
||||
: [];
|
||||
|
||||
const result = settleSelection({
|
||||
marketType: sel.marketType,
|
||||
selectionCode: sel.selectionNameSnapshot.includes('-')
|
||||
? `SCORE_${sel.selectionNameSnapshot.replace('-', '_')}`
|
||||
: sel.selectionNameSnapshot,
|
||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
templateScores: template,
|
||||
});
|
||||
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
items.push({ betId: bet.id, betNo: bet.betNo, result, payout });
|
||||
|
||||
if (result === 'LOSE') {
|
||||
// no payout
|
||||
} else if (result === 'PUSH' || result === 'VOID') {
|
||||
totalRefund = totalRefund.add(bet.stake);
|
||||
} else {
|
||||
totalPayout = totalPayout.add(payout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const computation = await this.computePreviewComputation(matchId);
|
||||
const batch = await this.prisma.settlementBatch.create({
|
||||
data: {
|
||||
matchId,
|
||||
@@ -131,24 +161,324 @@ export class SettlementService {
|
||||
ftHomeScore: score.ftHomeScore,
|
||||
ftAwayScore: score.ftAwayScore,
|
||||
status: 'PREVIEW',
|
||||
totalBets: pendingBets.length,
|
||||
totalPayout,
|
||||
totalRefund,
|
||||
totalBets: computation.pendingBets.length,
|
||||
totalPayout: computation.totalPayout,
|
||||
totalRefund: computation.totalRefund,
|
||||
operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
return this.buildPreviewResponse(computation, batch, opts);
|
||||
}
|
||||
|
||||
async getPreviewSettlementItems(
|
||||
batchId: bigint,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') {
|
||||
throw new BadRequestException('Batch is not in preview');
|
||||
}
|
||||
|
||||
const computation = await this.computePreviewComputation(batch.matchId);
|
||||
const itemsPage = this.paginatePreviewItems(computation.items, opts);
|
||||
return {
|
||||
items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
|
||||
total: itemsPage.total,
|
||||
page: itemsPage.page,
|
||||
pageSize: itemsPage.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private buildPreviewResponse(
|
||||
computation: {
|
||||
scoreInput: ScoreInput;
|
||||
winnerTeamCode: string | null;
|
||||
pendingBets: Array<{ betType: string }>;
|
||||
items: Array<{
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
betType: string;
|
||||
result: string;
|
||||
payout: Decimal;
|
||||
note?: string;
|
||||
}>;
|
||||
totalPayout: Decimal;
|
||||
totalRefund: Decimal;
|
||||
lostOnThisMatch: number;
|
||||
pendingOtherMatches: number;
|
||||
wonLegsOnMatch: number;
|
||||
},
|
||||
batch: { id: bigint },
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const { pendingBets } = computation;
|
||||
const itemsPage = this.paginatePreviewItems(computation.items, opts);
|
||||
|
||||
return {
|
||||
batch,
|
||||
score: scoreInput,
|
||||
score: computation.scoreInput,
|
||||
winnerTeamCode: computation.winnerTeamCode,
|
||||
pendingBetCount: pendingBets.length,
|
||||
singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length,
|
||||
parlayBetCount: parlayBets.length,
|
||||
parlayBetCount: pendingBets.filter((b) => b.betType === 'PARLAY').length,
|
||||
lostOnThisMatch: computation.lostOnThisMatch,
|
||||
pendingOtherMatches: computation.pendingOtherMatches,
|
||||
wonLegsOnMatch: computation.wonLegsOnMatch,
|
||||
items: {
|
||||
items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
|
||||
total: itemsPage.total,
|
||||
page: itemsPage.page,
|
||||
pageSize: itemsPage.pageSize,
|
||||
},
|
||||
totalPayout: computation.totalPayout.toString(),
|
||||
totalRefund: computation.totalRefund.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
private serializePreviewItem(item: {
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
betType: string;
|
||||
result: string;
|
||||
payout: Decimal;
|
||||
note?: string;
|
||||
}) {
|
||||
return {
|
||||
betId: item.betId.toString(),
|
||||
betNo: item.betNo,
|
||||
betType: item.betType,
|
||||
result: item.result,
|
||||
payout: item.payout.toString(),
|
||||
note: item.note,
|
||||
};
|
||||
}
|
||||
|
||||
private paginatePreviewItems<T>(all: T[], opts?: { page?: number; pageSize?: number }) {
|
||||
const page = Math.max(1, opts?.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
|
||||
const total = all.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
items: all.slice(start, start + pageSize),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private async computePreviewComputation(matchId: bigint) {
|
||||
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
if (!score) throw new BadRequestException('Score not recorded');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: score.htHomeScore ?? 0,
|
||||
htAway: score.htAwayScore ?? 0,
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets);
|
||||
|
||||
let totalPayout = new Decimal(0);
|
||||
let totalRefund = new Decimal(0);
|
||||
let lostOnThisMatch = 0;
|
||||
let pendingOtherMatches = 0;
|
||||
let wonLegsOnMatch = 0;
|
||||
const items: Array<{
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
betType: string;
|
||||
result: string;
|
||||
payout: Decimal;
|
||||
note?: string;
|
||||
}> = [];
|
||||
|
||||
for (const bet of pendingBets) {
|
||||
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
|
||||
const sel = bet.selections[0];
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
|
||||
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
|
||||
if (result === 'PUSH' || result === 'VOID') {
|
||||
totalRefund = totalRefund.add(bet.stake);
|
||||
} else if (result !== 'LOSE') {
|
||||
totalPayout = totalPayout.add(payout);
|
||||
}
|
||||
} else if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
|
||||
const legResults = bet.selections.map((sel) => {
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
return {
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
|
||||
};
|
||||
});
|
||||
const parlay = calculateParlayPayout(bet.stake, legResults);
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
betType: 'SINGLE',
|
||||
result: parlay.betResult === 'LOST' ? 'LOSE' : parlay.betResult,
|
||||
payout: parlay.payout,
|
||||
note: '单关含多腿,按串关规则合并结算',
|
||||
});
|
||||
if (parlay.betResult === 'PUSH') {
|
||||
totalRefund = totalRefund.add(bet.stake);
|
||||
} else if (parlay.betResult === 'WON') {
|
||||
totalPayout = totalPayout.add(parlay.payout);
|
||||
}
|
||||
} else {
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() !== matchId.toString()) continue;
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
|
||||
}
|
||||
|
||||
const preview = this.previewParlayForMatch(
|
||||
bet,
|
||||
matchId,
|
||||
scoreInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
if (preview.kind === 'SETTLED') {
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
betType: 'PARLAY',
|
||||
result: preview.betResult,
|
||||
payout: preview.payout,
|
||||
});
|
||||
if (preview.betResult === 'PUSH') {
|
||||
totalRefund = totalRefund.add(bet.stake);
|
||||
} else if (preview.betResult === 'WON') {
|
||||
totalPayout = totalPayout.add(preview.payout);
|
||||
}
|
||||
} else if (preview.kind === 'LOST_ON_THIS_MATCH') {
|
||||
lostOnThisMatch += 1;
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
betType: 'PARLAY',
|
||||
result: 'LOST',
|
||||
payout: preview.payout,
|
||||
note: '本场已有输腿,串关整单作废',
|
||||
});
|
||||
} else {
|
||||
pendingOtherMatches += 1;
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
betType: 'PARLAY',
|
||||
result: 'PENDING_OTHER_MATCHES',
|
||||
payout: new Decimal(0),
|
||||
note: '本场腿已出结果,待其他场次结算后派彩',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scoreInput,
|
||||
winnerTeamCode,
|
||||
pendingBets,
|
||||
items,
|
||||
totalPayout,
|
||||
totalRefund,
|
||||
lostOnThisMatch,
|
||||
pendingOtherMatches,
|
||||
wonLegsOnMatch,
|
||||
};
|
||||
}
|
||||
|
||||
private previewParlayForMatch(
|
||||
bet: { stake: Decimal; selections: BetSelectionLeg[] },
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
):
|
||||
| { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
|
||||
| { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal }
|
||||
| { kind: 'PENDING_OTHER_MATCHES' } {
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() !== matchId.toString()) continue;
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
if (result === 'LOSE' || result === 'HALF_LOSE') {
|
||||
return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
|
||||
}
|
||||
}
|
||||
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() === matchId.toString()) {
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
legResults.push({
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
|
||||
});
|
||||
} else if (sel.resultStatus) {
|
||||
legResults.push({
|
||||
odds: sel.odds,
|
||||
result: sel.resultStatus as SelectionResult,
|
||||
});
|
||||
} else {
|
||||
return { kind: 'PENDING_OTHER_MATCHES' };
|
||||
}
|
||||
}
|
||||
|
||||
if (legResults.length !== bet.selections.length) {
|
||||
return { kind: 'PENDING_OTHER_MATCHES' };
|
||||
}
|
||||
const settled = calculateParlayPayout(bet.stake, legResults);
|
||||
return { kind: 'SETTLED', betResult: settled.betResult, payout: settled.payout };
|
||||
}
|
||||
|
||||
private async loadSelectionCodes(
|
||||
bets: Array<{ selections: Array<{ selectionId: bigint }> }>,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const ids = new Set<bigint>();
|
||||
for (const bet of bets) {
|
||||
for (const sel of bet.selections) ids.add(sel.selectionId);
|
||||
}
|
||||
if (!ids.size) return new Map();
|
||||
|
||||
const rows = await this.prisma.marketSelection.findMany({
|
||||
where: { id: { in: [...ids] } },
|
||||
select: { id: true, selectionCode: true },
|
||||
});
|
||||
return new Map(rows.map((r) => [r.id.toString(), r.selectionCode]));
|
||||
}
|
||||
|
||||
async confirmSettlement(batchId: bigint, operatorId: bigint) {
|
||||
const batch = await this.prisma.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
@@ -168,40 +498,30 @@ export class SettlementService {
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: true, user: true },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets);
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const bet of pendingBets) {
|
||||
if (bet.betType === 'SINGLE') {
|
||||
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
|
||||
const sel = bet.selections[0];
|
||||
const selection = await tx.marketSelection.findUnique({
|
||||
where: { id: sel.selectionId },
|
||||
});
|
||||
|
||||
const result = settleSelection({
|
||||
marketType: sel.marketType,
|
||||
selectionCode: selection?.selectionCode ?? sel.selectionNameSnapshot,
|
||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
templateScores:
|
||||
sel.marketType === 'FT_CORRECT_SCORE'
|
||||
? FT_CORRECT_SCORE_TEMPLATE
|
||||
: HT_CORRECT_SCORE_TEMPLATE,
|
||||
});
|
||||
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
const betStatus =
|
||||
result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON';
|
||||
const betStatus = this.betStatusFromSelection(result);
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
@@ -222,7 +542,8 @@ export class SettlementService {
|
||||
bet.stake,
|
||||
payout,
|
||||
bet.betNo,
|
||||
result === 'HALF_WIN' ? 'HALF_WIN' : result === 'HALF_LOSE' ? 'HALF_LOSE' : result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID',
|
||||
this.walletResultFromSelection(result),
|
||||
tx,
|
||||
);
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
@@ -236,20 +557,69 @@ export class SettlementService {
|
||||
payout,
|
||||
},
|
||||
});
|
||||
} else if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
for (const sel of bet.selections) {
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
legResults.push({ odds: sel.odds, result });
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result, effectiveOdds: sel.odds },
|
||||
});
|
||||
}
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus =
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOST'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: parlayResult.payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
tx,
|
||||
);
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
batchId,
|
||||
betId: bet.id,
|
||||
userId: bet.userId,
|
||||
result: betStatus,
|
||||
payout: parlayResult.payout,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Parlay: update this leg's result, check if all legs settled
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() === batch.matchId.toString()) {
|
||||
const selection = await tx.marketSelection.findUnique({
|
||||
where: { id: sel.selectionId },
|
||||
});
|
||||
const result = settleSelection({
|
||||
marketType: sel.marketType,
|
||||
selectionCode: selection?.selectionCode ?? '',
|
||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
});
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result },
|
||||
@@ -257,20 +627,29 @@ export class SettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx.betSelection.findMany({ where: { betId: bet.id } });
|
||||
const updated = await tx.betSelection.findMany({
|
||||
where: { betId: bet.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const allHaveResult = updated.every((s) => s.resultStatus != null);
|
||||
|
||||
if (allHaveResult) {
|
||||
const legResults = updated.map((s) => ({
|
||||
odds: s.odds,
|
||||
result: s.resultStatus as 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
result: s.resultStatus as SelectionResult,
|
||||
}));
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus =
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOST'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: {
|
||||
status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON',
|
||||
status: betStatus,
|
||||
actualReturn: parlayResult.payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
@@ -281,10 +660,25 @@ export class SettlementService {
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN',
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
tx,
|
||||
);
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
batchId,
|
||||
betId: bet.id,
|
||||
userId: bet.userId,
|
||||
result: betStatus,
|
||||
payout: parlayResult.payout,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +701,10 @@ export class SettlementService {
|
||||
return { success: true, batchId: batchId.toString() };
|
||||
}
|
||||
|
||||
async getMatchBetStats(matchId: bigint) {
|
||||
async getMatchBetStats(
|
||||
matchId: bigint,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
});
|
||||
@@ -405,28 +802,50 @@ export class SettlementService {
|
||||
return a.selectionName.localeCompare(b.selectionName);
|
||||
});
|
||||
|
||||
const bets = Array.from(legs)
|
||||
.map((leg) => ({
|
||||
id: leg.bet.id.toString(),
|
||||
betNo: leg.bet.betNo,
|
||||
username: leg.bet.user.username,
|
||||
betType: leg.bet.betType,
|
||||
status: leg.bet.status,
|
||||
settlementStatus: leg.bet.settlementStatus,
|
||||
stake: leg.bet.stake.toString(),
|
||||
potentialReturn: leg.bet.potentialReturn?.toString() ?? null,
|
||||
actualReturn: leg.bet.actualReturn.toString(),
|
||||
placedAt: leg.bet.placedAt.toISOString(),
|
||||
marketType: leg.marketType,
|
||||
period: leg.period,
|
||||
selectionName: leg.selectionNameSnapshot,
|
||||
odds: leg.odds.toString(),
|
||||
const betsById = new Map<
|
||||
string,
|
||||
{
|
||||
bet: (typeof legs)[0]['bet'];
|
||||
matchLegs: (typeof legs);
|
||||
}
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
const key = leg.betId.toString();
|
||||
const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
|
||||
row.matchLegs.push(leg);
|
||||
betsById.set(key, row);
|
||||
}
|
||||
|
||||
const allBets = Array.from(betsById.values())
|
||||
.map(({ bet, matchLegs }) => ({
|
||||
id: bet.id.toString(),
|
||||
betNo: bet.betNo,
|
||||
username: matchLegs[0].bet.user.username,
|
||||
betType: bet.betType,
|
||||
status: bet.status,
|
||||
settlementStatus: bet.settlementStatus,
|
||||
stake: bet.stake.toString(),
|
||||
potentialReturn: bet.potentialReturn?.toString() ?? null,
|
||||
actualReturn: bet.actualReturn.toString(),
|
||||
placedAt: bet.placedAt.toISOString(),
|
||||
legCountOnMatch: matchLegs.length,
|
||||
selections: matchLegs.map((leg) => ({
|
||||
marketType: leg.marketType,
|
||||
period: leg.period,
|
||||
selectionName: leg.selectionNameSnapshot,
|
||||
odds: leg.odds.toString(),
|
||||
})),
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
|
||||
);
|
||||
|
||||
const page = Math.max(1, opts?.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
|
||||
const total = allBets.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalBets: betById.size,
|
||||
@@ -438,13 +857,283 @@ export class SettlementService {
|
||||
legCount: legs.length,
|
||||
},
|
||||
bySelection,
|
||||
bets,
|
||||
bets: {
|
||||
items: allBets.slice(start, start + pageSize),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import
|
||||
async suggestSmartScores(...) { ... }
|
||||
*/
|
||||
private async computeBetOutcome(
|
||||
bet: {
|
||||
id: bigint;
|
||||
betType: string;
|
||||
stake: Decimal;
|
||||
actualReturn: Decimal;
|
||||
selections: BetSelectionLeg[];
|
||||
},
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
) {
|
||||
if (bet.betType === 'SINGLE') {
|
||||
const sel = bet.selections[0];
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
return {
|
||||
payout,
|
||||
betStatus: this.betStatusFromSelection(result),
|
||||
legUpdates: new Map([[sel.id.toString(), result]]),
|
||||
};
|
||||
}
|
||||
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
const legUpdates = new Map<string, SelectionResult>();
|
||||
|
||||
for (const sel of bet.selections) {
|
||||
let result: SelectionResult;
|
||||
if (sel.matchId?.toString() === matchId.toString()) {
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
legUpdates.set(sel.id.toString(), result);
|
||||
} else {
|
||||
if (!sel.resultStatus) {
|
||||
throw new BadRequestException(`Parlay bet ${bet.id} has unsettled legs`);
|
||||
}
|
||||
result = sel.resultStatus as SelectionResult;
|
||||
}
|
||||
legResults.push({ odds: sel.odds, result });
|
||||
}
|
||||
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus =
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOST'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
return { payout: parlayResult.payout, betStatus, legUpdates };
|
||||
}
|
||||
|
||||
async previewResettlement(
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
operatorId: bigint,
|
||||
reason?: string,
|
||||
winnerTeamId?: bigint,
|
||||
) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
if (match.status !== 'SETTLED') {
|
||||
throw new BadRequestException('Only settled matches can be resettled');
|
||||
}
|
||||
|
||||
const winnerTeamCode = winnerTeamId
|
||||
? await this.resolveWinnerTeamCode(winnerTeamId)
|
||||
: null;
|
||||
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
|
||||
selections: { some: { matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets);
|
||||
const items: Array<{
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
oldPayout: Decimal;
|
||||
newPayout: Decimal;
|
||||
delta: Decimal;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
}> = [];
|
||||
|
||||
let totalClawback = new Decimal(0);
|
||||
let totalTopup = new Decimal(0);
|
||||
|
||||
for (const bet of settledBets) {
|
||||
const oldPayout = new Decimal(bet.actualReturn);
|
||||
const outcome = await this.computeBetOutcome(
|
||||
bet,
|
||||
matchId,
|
||||
scoreInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
const delta = outcome.payout.sub(oldPayout);
|
||||
if (delta.eq(0) && outcome.betStatus === bet.status) continue;
|
||||
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
oldPayout,
|
||||
newPayout: outcome.payout,
|
||||
delta,
|
||||
oldStatus: bet.status,
|
||||
newStatus: outcome.betStatus,
|
||||
});
|
||||
|
||||
if (delta.gt(0)) totalTopup = totalTopup.add(delta);
|
||||
else if (delta.lt(0)) totalClawback = totalClawback.add(delta.abs());
|
||||
}
|
||||
|
||||
const batch = await this.prisma.settlementBatch.create({
|
||||
data: {
|
||||
matchId,
|
||||
batchNo: generateBatchNo('RST'),
|
||||
htHomeScore: scoreInput.htHome,
|
||||
htAwayScore: scoreInput.htAway,
|
||||
ftHomeScore: scoreInput.ftHome,
|
||||
ftAwayScore: scoreInput.ftAway,
|
||||
status: 'PREVIEW',
|
||||
totalBets: items.length,
|
||||
totalPayout: totalTopup,
|
||||
totalRefund: totalClawback,
|
||||
operatorId,
|
||||
isResettle: true,
|
||||
reason: reason?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
batch,
|
||||
score: scoreInput,
|
||||
winnerTeamCode,
|
||||
items,
|
||||
totalClawback,
|
||||
totalTopup,
|
||||
affectedCount: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmResettlement(batchId: bigint, operatorId: bigint) {
|
||||
const batch = await this.prisma.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { match: true },
|
||||
});
|
||||
if (!batch) throw new NotFoundException('Batch not found');
|
||||
if (!batch.isResettle) throw new BadRequestException('Not a resettle batch');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: batch.htHomeScore ?? 0,
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
};
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(
|
||||
(
|
||||
await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } })
|
||||
)?.winnerTeamId,
|
||||
);
|
||||
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets);
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.matchScore.upsert({
|
||||
where: { matchId: batch.matchId },
|
||||
create: {
|
||||
matchId: batch.matchId,
|
||||
htHomeScore: scoreInput.htHome,
|
||||
htAwayScore: scoreInput.htAway,
|
||||
ftHomeScore: scoreInput.ftHome,
|
||||
ftAwayScore: scoreInput.ftAway,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
update: {
|
||||
htHomeScore: scoreInput.htHome,
|
||||
htAwayScore: scoreInput.htAway,
|
||||
ftHomeScore: scoreInput.ftHome,
|
||||
ftAwayScore: scoreInput.ftAway,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
for (const bet of settledBets) {
|
||||
const oldPayout = new Decimal(bet.actualReturn);
|
||||
const outcome = await this.computeBetOutcome(
|
||||
bet,
|
||||
batch.matchId,
|
||||
scoreInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
const delta = outcome.payout.sub(oldPayout);
|
||||
|
||||
if (delta.eq(0) && outcome.betStatus === bet.status) continue;
|
||||
|
||||
for (const [legId, result] of outcome.legUpdates) {
|
||||
const leg = bet.selections.find((s) => s.id.toString() === legId);
|
||||
await tx.betSelection.update({
|
||||
where: { id: BigInt(legId) },
|
||||
data: {
|
||||
resultStatus: result,
|
||||
effectiveOdds: leg?.odds ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: {
|
||||
status: outcome.betStatus,
|
||||
actualReturn: outcome.payout,
|
||||
settlementStatus: 'RESETTLED',
|
||||
},
|
||||
});
|
||||
|
||||
await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
batchId,
|
||||
betId: bet.id,
|
||||
userId: bet.userId,
|
||||
result: outcome.betStatus,
|
||||
payout: outcome.payout,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.settlementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
});
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
await this.agents.recalculateUsedCredit(agentId);
|
||||
}
|
||||
|
||||
return { success: true, batchId: batchId.toString() };
|
||||
}
|
||||
|
||||
async voidMatchBets(matchId: bigint) {
|
||||
const bets = await this.prisma.bet.findMany({
|
||||
|
||||
@@ -72,10 +72,13 @@ describe('Bet Validation Rules (B001-B010)', () => {
|
||||
expect(submitted === current).toBe(false);
|
||||
});
|
||||
|
||||
it('B007: same match in parlay rejected', () => {
|
||||
const matchIds = ['1', '1', '2'];
|
||||
const unique = new Set(matchIds);
|
||||
expect(unique.size !== matchIds.length).toBe(true);
|
||||
it('B007: same match legs allowed in parlay (2–5 legs)', () => {
|
||||
const legs = [
|
||||
{ matchId: '1', selectionId: 'a' },
|
||||
{ matchId: '1', selectionId: 'b' },
|
||||
];
|
||||
expect(legs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(legs.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('B008: quarter line in parlay rejected', () => {
|
||||
|
||||
9
apps/api/tsconfig.build.json
Normal file
9
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -21,5 +22,5 @@
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "prisma/seed.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user