feat(admin,api,player): settlement stats, team crests, MS fields and list bet summary
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user