feat(admin,api,player): settlement stats, team crests, MS fields and list bet summary

This commit is contained in:
2026-06-04 17:30:48 +08:00
parent cc737e2924
commit 9fcee31a9a
27 changed files with 2296 additions and 427 deletions

View File

@@ -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,

View File

@@ -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,

View File

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

View File

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