feat(admin,api,player): settlement stats, team crests, MS fields and list bet summary
This commit is contained in:
@@ -388,6 +388,10 @@ class CreateOutrightDto {
|
||||
@IsString()
|
||||
titleEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@@ -402,6 +406,18 @@ class UpdateOutrightDto {
|
||||
@IsString()
|
||||
matchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleZh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
isHot?: boolean;
|
||||
|
||||
@@ -1068,6 +1084,7 @@ export class AdminController {
|
||||
leagueId: BigInt(dto.leagueId),
|
||||
titleZh: dto.titleZh,
|
||||
titleEn: dto.titleEn,
|
||||
titleMs: dto.titleMs,
|
||||
status: dto.status,
|
||||
});
|
||||
return jsonResponse(data);
|
||||
@@ -1172,6 +1189,12 @@ export class AdminController {
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Get('matches/:id/settlement/stats')
|
||||
async getMatchSettlementStats(@Param('id') id: string) {
|
||||
const data = await this.settlement.getMatchBetStats(BigInt(id));
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('matches/:id/settlement/score')
|
||||
async recordScore(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
|
||||
@@ -2,7 +2,14 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
@@ -139,32 +146,39 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
|
||||
const code = teamCodeFromExport(team);
|
||||
const translations = translationsFromZhiboNames(team.names, team.name);
|
||||
|
||||
let record =
|
||||
team.id != null
|
||||
? await this.prisma.team.findFirst({ where: { externalId: team.id } })
|
||||
: await this.prisma.team.findUnique({ where: { code } });
|
||||
|
||||
if (!record) {
|
||||
record = await this.prisma.team.create({
|
||||
data: {
|
||||
code,
|
||||
externalId: team.id ?? undefined,
|
||||
logoUrl: team.image || undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
record = await this.prisma.team.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
logoUrl: team.image || record.logoUrl,
|
||||
externalId: team.id ?? record.externalId,
|
||||
},
|
||||
if (team.id != null) {
|
||||
const existing = await this.prisma.team.findFirst({
|
||||
where: { externalId: team.id },
|
||||
});
|
||||
if (existing) {
|
||||
const record = await this.prisma.team.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
logoUrl: team.image || existing.logoUrl,
|
||||
externalId: team.id,
|
||||
},
|
||||
});
|
||||
await this.upsertEntityTranslations('TEAM', record.id, translations);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
const code = teamCodeFromExport(team);
|
||||
const record = await this.prisma.team.upsert({
|
||||
where: { code },
|
||||
create: {
|
||||
code,
|
||||
externalId: team.id ?? undefined,
|
||||
logoUrl: team.image || undefined,
|
||||
},
|
||||
update: {
|
||||
logoUrl: team.image || undefined,
|
||||
externalId: team.id ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await this.upsertEntityTranslations('TEAM', record.id, translations);
|
||||
return record;
|
||||
}
|
||||
@@ -322,10 +336,51 @@ export class MatchesService {
|
||||
leagueZh,
|
||||
leagueMs,
|
||||
matchCount,
|
||||
betStats: { betCount: 0, totalStake: '0', pendingCount: 0 },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const leagueIds = leagues.map((l) => l.id);
|
||||
const leagueMatches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
leagueId: { in: leagueIds },
|
||||
deletedAt: null,
|
||||
isOutright: false,
|
||||
...(opts.status ? { status: opts.status } : {}),
|
||||
},
|
||||
select: { id: true, leagueId: true },
|
||||
});
|
||||
const matchStats = await this.betStatsForMatches(
|
||||
leagueMatches.map((m) => m.id),
|
||||
);
|
||||
const leagueBetRollup = new Map<string, MatchBetStatsSummary>();
|
||||
for (const lm of leagueMatches) {
|
||||
const lid = lm.leagueId.toString();
|
||||
const cur = leagueBetRollup.get(lid) ?? {
|
||||
betCount: 0,
|
||||
totalStake: new Decimal(0),
|
||||
pendingCount: 0,
|
||||
};
|
||||
const ms = matchStats.get(lm.id.toString());
|
||||
if (ms) {
|
||||
cur.betCount += ms.betCount;
|
||||
cur.totalStake = cur.totalStake.add(ms.totalStake);
|
||||
cur.pendingCount += ms.pendingCount;
|
||||
}
|
||||
leagueBetRollup.set(lid, cur);
|
||||
}
|
||||
for (const item of items) {
|
||||
const roll = leagueBetRollup.get(item.id);
|
||||
if (roll) {
|
||||
item.betStats = {
|
||||
betCount: roll.betCount,
|
||||
totalStake: roll.totalStake.toString(),
|
||||
pendingCount: roll.pendingCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { items, total, page: opts.page, pageSize: opts.pageSize };
|
||||
}
|
||||
|
||||
@@ -353,12 +408,17 @@ export class MatchesService {
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
});
|
||||
const locale = opts.locale ?? 'zh-CN';
|
||||
const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id));
|
||||
return Promise.all(
|
||||
items.map(async (m) => {
|
||||
const [homeTeamName, awayTeamName] = await Promise.all([
|
||||
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||
]);
|
||||
const raw = betStatsMap.get(m.id.toString());
|
||||
const betCount = raw?.betCount ?? 0;
|
||||
const totalStake = raw?.totalStake.toString() ?? '0';
|
||||
const pendingBets = raw?.pendingCount ?? 0;
|
||||
return {
|
||||
id: m.id.toString(),
|
||||
status: m.status,
|
||||
@@ -371,11 +431,76 @@ export class MatchesService {
|
||||
awayTeamName,
|
||||
homeTeam: { code: m.homeTeam.code },
|
||||
awayTeam: { code: m.awayTeam.code },
|
||||
betCount,
|
||||
totalStake,
|
||||
pendingBets,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary & { totalStake: Decimal }>> {
|
||||
const result = new Map<
|
||||
string,
|
||||
MatchBetStatsSummary & { totalStake: Decimal }
|
||||
>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: new Decimal(0),
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake,
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createPlatformMatch(data: {
|
||||
leagueId?: bigint;
|
||||
leagueEn?: string;
|
||||
@@ -438,34 +563,36 @@ export class MatchesService {
|
||||
});
|
||||
}
|
||||
}
|
||||
const [homeTeam, awayTeam] = await Promise.all([
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh || homeMs,
|
||||
names: {
|
||||
zh: homeZh || null,
|
||||
en: homeEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: homeMs || null,
|
||||
},
|
||||
image: data.homeTeamLogoUrl?.trim() || '',
|
||||
}),
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh || awayMs,
|
||||
names: {
|
||||
zh: awayZh || null,
|
||||
en: awayEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: awayMs || null,
|
||||
},
|
||||
image: data.awayTeamLogoUrl?.trim() || '',
|
||||
}),
|
||||
]);
|
||||
const homeTeam = await this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh || homeMs,
|
||||
names: {
|
||||
zh: homeZh || null,
|
||||
en: homeEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: homeMs || null,
|
||||
},
|
||||
image: data.homeTeamLogoUrl?.trim() || '',
|
||||
});
|
||||
const awayTeam = await this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh || awayMs,
|
||||
names: {
|
||||
zh: awayZh || null,
|
||||
en: awayEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: awayMs || null,
|
||||
},
|
||||
image: data.awayTeamLogoUrl?.trim() || '',
|
||||
});
|
||||
|
||||
if (homeTeam.id === awayTeam.id) {
|
||||
throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名');
|
||||
}
|
||||
|
||||
const matchName =
|
||||
data.matchName?.trim() ||
|
||||
@@ -499,6 +626,9 @@ export class MatchesService {
|
||||
|
||||
async getAdminMatchDetail(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
const scoreRow = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId },
|
||||
});
|
||||
const markets = await this.prisma.market.findMany({
|
||||
where: { matchId },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
@@ -542,6 +672,14 @@ export class MatchesService {
|
||||
matchName: match.matchName ?? '',
|
||||
stage: match.stage ?? '',
|
||||
groupName: match.groupName ?? '',
|
||||
score: scoreRow
|
||||
? {
|
||||
htHome: scoreRow.htHomeScore ?? 0,
|
||||
htAway: scoreRow.htAwayScore ?? 0,
|
||||
ftHome: scoreRow.ftHomeScore ?? 0,
|
||||
ftAway: scoreRow.ftAwayScore ?? 0,
|
||||
}
|
||||
: null,
|
||||
markets: markets.map((m) => ({
|
||||
id: m.id.toString(),
|
||||
marketType: m.marketType,
|
||||
@@ -1009,21 +1147,24 @@ export class MatchesService {
|
||||
return results;
|
||||
}
|
||||
|
||||
private marketLabelKey(marketType: string): string {
|
||||
const keys: Record<string, string> = {
|
||||
FT_1X2: '全场独赢',
|
||||
FT_HANDICAP: '全场让球',
|
||||
FT_OVER_UNDER: '全场大小',
|
||||
FT_ODD_EVEN: '全场单双',
|
||||
HT_1X2: '半场独赢',
|
||||
HT_HANDICAP: '半场让球',
|
||||
HT_OVER_UNDER: '半场大小',
|
||||
OUTRIGHT_WINNER: '冠军',
|
||||
FT_CORRECT_SCORE: '波胆',
|
||||
HT_CORRECT_SCORE: '上半场波胆',
|
||||
SH_CORRECT_SCORE: '下半场波胆',
|
||||
private marketLabelKey(marketType: string, locale = 'zh-CN'): string {
|
||||
type LangMap = Record<string, string>;
|
||||
const labels: Record<string, LangMap> = {
|
||||
FT_1X2: { 'zh-CN': '全场独赢', 'en-US': 'FT 1X2', 'ms-MY': '1X2 Penuh' },
|
||||
FT_HANDICAP: { 'zh-CN': '全场让球', 'en-US': 'FT Handicap', 'ms-MY': 'Handicap Penuh' },
|
||||
FT_OVER_UNDER: { 'zh-CN': '全场大小', 'en-US': 'FT O/U', 'ms-MY': 'Atas/Bawah Penuh' },
|
||||
FT_ODD_EVEN: { 'zh-CN': '全场单双', 'en-US': 'FT Odd/Even', 'ms-MY': 'Ganjil/Genap Penuh' },
|
||||
HT_1X2: { 'zh-CN': '半场独赢', 'en-US': 'HT 1X2', 'ms-MY': '1X2 Separuh' },
|
||||
HT_HANDICAP: { 'zh-CN': '半场让球', 'en-US': 'HT Handicap', 'ms-MY': 'Handicap Separuh' },
|
||||
HT_OVER_UNDER: { 'zh-CN': '半场大小', 'en-US': 'HT O/U', 'ms-MY': 'Atas/Bawah Separuh' },
|
||||
OUTRIGHT_WINNER: { 'zh-CN': '冠军', 'en-US': 'Outright', 'ms-MY': 'Juara' },
|
||||
FT_CORRECT_SCORE: { 'zh-CN': '波胆', 'en-US': 'Correct Score', 'ms-MY': 'Skor Tepat' },
|
||||
HT_CORRECT_SCORE: { 'zh-CN': '上半场波胆', 'en-US': '1H Correct Score', 'ms-MY': 'Skor Tepat PB1' },
|
||||
SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
|
||||
};
|
||||
return keys[marketType] ?? marketType;
|
||||
const entry = labels[marketType];
|
||||
if (!entry) return marketType;
|
||||
return entry[locale] ?? entry['en-US'] ?? marketType;
|
||||
}
|
||||
|
||||
async enrichBetsForHistory(
|
||||
@@ -1090,7 +1231,7 @@ export class MatchesService {
|
||||
const m = mid ? matchMeta.get(mid) : undefined;
|
||||
return {
|
||||
marketType: sel.marketType,
|
||||
marketLabel: this.marketLabelKey(sel.marketType),
|
||||
marketLabel: this.marketLabelKey(sel.marketType, locale),
|
||||
selectionName: sel.selectionNameSnapshot,
|
||||
odds: sel.odds,
|
||||
resultStatus: sel.resultStatus,
|
||||
|
||||
@@ -68,6 +68,11 @@ export class OutrightService {
|
||||
market,
|
||||
openCount,
|
||||
);
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(m.id, 'zh-CN'),
|
||||
this.getOutrightTitle(m.id, 'en-US'),
|
||||
this.getOutrightTitle(m.id, 'ms-MY'),
|
||||
]);
|
||||
return {
|
||||
id: m.id.toString(),
|
||||
leagueId: league.id.toString(),
|
||||
@@ -75,6 +80,9 @@ export class OutrightService {
|
||||
leagueZh,
|
||||
leagueEn,
|
||||
matchName: m.matchName ?? '',
|
||||
titleZh: titleZh || m.matchName || '',
|
||||
titleEn: titleEn || m.matchName || '',
|
||||
titleMs,
|
||||
status: m.status,
|
||||
selectionCount,
|
||||
canImportCanonical: leagueCode === WC2026_LEAGUE_CODE,
|
||||
@@ -154,6 +162,12 @@ export class OutrightService {
|
||||
),
|
||||
);
|
||||
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(match.id, 'zh-CN'),
|
||||
this.getOutrightTitle(match.id, 'en-US'),
|
||||
this.getOutrightTitle(match.id, 'ms-MY'),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: match.id.toString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -161,6 +175,9 @@ export class OutrightService {
|
||||
leagueZh,
|
||||
leagueEn,
|
||||
matchName: match.matchName ?? '',
|
||||
titleZh: titleZh || match.matchName || '',
|
||||
titleEn: titleEn || match.matchName || '',
|
||||
titleMs,
|
||||
status: match.status,
|
||||
marketId: fullMarket.id.toString(),
|
||||
marketStatus: fullMarket.status,
|
||||
@@ -177,6 +194,7 @@ export class OutrightService {
|
||||
leagueId: bigint;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
titleMs?: string;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
@@ -189,13 +207,14 @@ export class OutrightService {
|
||||
|
||||
const placeholder = await this.ensurePlaceholderTeam();
|
||||
const status = data.status ?? 'PUBLISHED';
|
||||
const matchName = this.resolveOutrightMatchName(data);
|
||||
const match = await this.prisma.match.create({
|
||||
data: {
|
||||
leagueId: data.leagueId,
|
||||
homeTeamId: placeholder.id,
|
||||
awayTeamId: placeholder.id,
|
||||
isOutright: true,
|
||||
matchName: data.titleEn || data.titleZh,
|
||||
matchName,
|
||||
startTime: data.startTime ?? new Date('2030-01-01T00:00:00Z'),
|
||||
status,
|
||||
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
||||
@@ -204,6 +223,11 @@ export class OutrightService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.upsertOutrightTitles(match.id, {
|
||||
zh: data.titleZh,
|
||||
en: data.titleEn,
|
||||
ms: data.titleMs,
|
||||
});
|
||||
await this.ensureOutrightMarket(match.id);
|
||||
return this.getForAdmin(match.id);
|
||||
}
|
||||
@@ -217,15 +241,35 @@ export class OutrightService {
|
||||
displayOrder?: number;
|
||||
titleZh?: string;
|
||||
titleEn?: string;
|
||||
titleMs?: string;
|
||||
},
|
||||
) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const status = data.status ?? match.status;
|
||||
|
||||
let matchName = data.matchName?.trim();
|
||||
const titlesTouched =
|
||||
data.titleZh !== undefined ||
|
||||
data.titleEn !== undefined ||
|
||||
data.titleMs !== undefined;
|
||||
if (titlesTouched) {
|
||||
const [curZh, curEn, curMs] = await Promise.all([
|
||||
this.getOutrightTitle(matchId, 'zh-CN'),
|
||||
this.getOutrightTitle(matchId, 'en-US'),
|
||||
this.getOutrightTitle(matchId, 'ms-MY'),
|
||||
]);
|
||||
const zh = data.titleZh !== undefined ? data.titleZh : curZh;
|
||||
const en = data.titleEn !== undefined ? data.titleEn : curEn;
|
||||
const ms = data.titleMs !== undefined ? data.titleMs : curMs;
|
||||
await this.upsertOutrightTitles(matchId, { zh, en, ms });
|
||||
matchName = this.resolveOutrightMatchName({ titleZh: zh, titleEn: en, titleMs: ms });
|
||||
}
|
||||
|
||||
await this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: {
|
||||
status,
|
||||
matchName: data.matchName,
|
||||
matchName: matchName !== undefined ? matchName : undefined,
|
||||
isHot: data.isHot,
|
||||
displayOrder: data.displayOrder,
|
||||
publishTime:
|
||||
@@ -490,7 +534,18 @@ export class OutrightService {
|
||||
|
||||
if (!selections.length) continue;
|
||||
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(match.id, 'zh-CN'),
|
||||
this.getOutrightTitle(match.id, 'en-US'),
|
||||
this.getOutrightTitle(match.id, 'ms-MY'),
|
||||
]);
|
||||
const localizedTitle = this.pickOutrightTitleForLocale(locale, {
|
||||
zh: titleZh,
|
||||
en: titleEn,
|
||||
ms: titleMs,
|
||||
});
|
||||
const title =
|
||||
localizedTitle ||
|
||||
match.matchName?.trim() ||
|
||||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
|
||||
|
||||
@@ -625,4 +680,76 @@ export class OutrightService {
|
||||
});
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
private async getOutrightTitle(matchId: bigint, locale: string): Promise<string> {
|
||||
const row = await this.prisma.entityTranslation.findFirst({
|
||||
where: {
|
||||
entityType: 'MATCH',
|
||||
entityId: matchId,
|
||||
locale,
|
||||
fieldName: 'title',
|
||||
},
|
||||
});
|
||||
return row?.value?.trim() ?? '';
|
||||
}
|
||||
|
||||
private resolveOutrightMatchName(data: {
|
||||
titleZh?: string;
|
||||
titleEn?: string;
|
||||
titleMs?: string;
|
||||
}): string {
|
||||
return (
|
||||
data.titleEn?.trim() ||
|
||||
data.titleZh?.trim() ||
|
||||
data.titleMs?.trim() ||
|
||||
'Outright'
|
||||
);
|
||||
}
|
||||
|
||||
private pickOutrightTitleForLocale(
|
||||
locale: string,
|
||||
titles: { zh: string; en: string; ms: string },
|
||||
): string {
|
||||
if (locale === 'ms-MY' || locale.startsWith('ms')) {
|
||||
return titles.ms || titles.en || titles.zh;
|
||||
}
|
||||
if (locale.startsWith('zh')) {
|
||||
return titles.zh || titles.en || titles.ms;
|
||||
}
|
||||
return titles.en || titles.zh || titles.ms;
|
||||
}
|
||||
|
||||
private async upsertOutrightTitles(
|
||||
matchId: bigint,
|
||||
titles: { zh?: string; en?: string; ms?: string },
|
||||
) {
|
||||
const entries: Array<[string, string | undefined]> = [
|
||||
['zh-CN', titles.zh],
|
||||
['en-US', titles.en],
|
||||
['ms-MY', titles.ms],
|
||||
];
|
||||
for (const [locale, raw] of entries) {
|
||||
if (raw === undefined) continue;
|
||||
const value = raw.trim();
|
||||
if (!value) continue;
|
||||
await this.prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'MATCH',
|
||||
entityId: matchId,
|
||||
locale,
|
||||
fieldName: 'title',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
entityType: 'MATCH',
|
||||
entityId: matchId,
|
||||
locale,
|
||||
fieldName: 'title',
|
||||
value,
|
||||
},
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,141 @@ export class SettlementService {
|
||||
return { success: true, batchId: batchId.toString() };
|
||||
}
|
||||
|
||||
async getMatchBetStats(matchId: bigint) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId },
|
||||
include: {
|
||||
bet: {
|
||||
select: {
|
||||
id: true,
|
||||
betNo: true,
|
||||
betType: true,
|
||||
stake: true,
|
||||
status: true,
|
||||
settlementStatus: true,
|
||||
potentialReturn: true,
|
||||
actualReturn: true,
|
||||
placedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
const betById = new Map<string, (typeof legs)[0]['bet']>();
|
||||
for (const leg of legs) {
|
||||
betById.set(leg.betId.toString(), leg.bet);
|
||||
}
|
||||
|
||||
let totalStake = new Decimal(0);
|
||||
let totalPotential = new Decimal(0);
|
||||
let singleBets = 0;
|
||||
let parlayBets = 0;
|
||||
const statusCounts: Record<string, number> = {};
|
||||
|
||||
for (const bet of betById.values()) {
|
||||
totalStake = totalStake.add(bet.stake);
|
||||
if (bet.potentialReturn) {
|
||||
totalPotential = totalPotential.add(bet.potentialReturn);
|
||||
}
|
||||
if (bet.betType === 'SINGLE') singleBets += 1;
|
||||
else if (bet.betType === 'PARLAY') parlayBets += 1;
|
||||
statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
type SelAgg = {
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionName: string;
|
||||
selectionId: string;
|
||||
legCount: number;
|
||||
singleStake: Decimal;
|
||||
parlayLegCount: number;
|
||||
};
|
||||
const selMap = new Map<string, SelAgg>();
|
||||
|
||||
for (const leg of legs) {
|
||||
const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`;
|
||||
let row = selMap.get(key);
|
||||
if (!row) {
|
||||
row = {
|
||||
marketType: leg.marketType,
|
||||
period: leg.period,
|
||||
selectionName: leg.selectionNameSnapshot,
|
||||
selectionId: leg.selectionId.toString(),
|
||||
legCount: 0,
|
||||
singleStake: new Decimal(0),
|
||||
parlayLegCount: 0,
|
||||
};
|
||||
selMap.set(key, row);
|
||||
}
|
||||
row.legCount += 1;
|
||||
if (leg.bet.betType === 'SINGLE') {
|
||||
row.singleStake = row.singleStake.add(leg.bet.stake);
|
||||
} else if (leg.bet.betType === 'PARLAY') {
|
||||
row.parlayLegCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const bySelection = Array.from(selMap.values())
|
||||
.map((r) => ({
|
||||
marketType: r.marketType,
|
||||
period: r.period,
|
||||
selectionName: r.selectionName,
|
||||
selectionId: r.selectionId,
|
||||
legCount: r.legCount,
|
||||
singleStake: r.singleStake.toString(),
|
||||
parlayLegCount: r.parlayLegCount,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const mk = a.marketType.localeCompare(b.marketType);
|
||||
if (mk !== 0) return mk;
|
||||
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(),
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalBets: betById.size,
|
||||
singleBets,
|
||||
parlayBets,
|
||||
totalStake: totalStake.toString(),
|
||||
totalPotentialReturn: totalPotential.toString(),
|
||||
statusCounts,
|
||||
legCount: legs.length,
|
||||
},
|
||||
bySelection,
|
||||
bets,
|
||||
};
|
||||
}
|
||||
|
||||
async voidMatchBets(matchId: bigint) {
|
||||
const bets = await this.prisma.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
|
||||
Reference in New Issue
Block a user