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

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