1281 lines
40 KiB
TypeScript
1281 lines
40 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
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,
|
|
resolveInternalStatus,
|
|
resolveIsHot,
|
|
resolveStartTime,
|
|
teamCodeFromExport,
|
|
toKickoffJson,
|
|
toVenueJson,
|
|
translationsFromZhiboNames,
|
|
} from './zhibo-match.mapper';
|
|
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
|
|
|
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
|
|
|
@Injectable()
|
|
export class MatchesService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
async createLeague(code: string, translations: Record<string, string>) {
|
|
const league = await this.prisma.league.create({ data: { code } });
|
|
for (const [locale, value] of Object.entries(translations)) {
|
|
await this.prisma.entityTranslation.create({
|
|
data: {
|
|
entityType: 'LEAGUE',
|
|
entityId: league.id,
|
|
locale,
|
|
fieldName: 'name',
|
|
value,
|
|
},
|
|
});
|
|
}
|
|
return league;
|
|
}
|
|
|
|
async createTeam(code: string, translations: Record<string, string>) {
|
|
const team = await this.prisma.team.create({ data: { code } });
|
|
for (const [locale, value] of Object.entries(translations)) {
|
|
await this.prisma.entityTranslation.create({
|
|
data: {
|
|
entityType: 'TEAM',
|
|
entityId: team.id,
|
|
locale,
|
|
fieldName: 'name',
|
|
value,
|
|
},
|
|
});
|
|
}
|
|
return team;
|
|
}
|
|
|
|
async createMatch(data: {
|
|
leagueId: bigint;
|
|
homeTeamId: bigint;
|
|
awayTeamId: bigint;
|
|
startTime: Date;
|
|
isHot?: boolean;
|
|
displayOrder?: number;
|
|
createdBy?: bigint;
|
|
status?: string;
|
|
publishTime?: Date;
|
|
zhibo?: Partial<{
|
|
officialMatchNo: number;
|
|
stage: string;
|
|
groupName: string;
|
|
liveMatchId?: bigint;
|
|
additionMatchId: bigint | null;
|
|
channelId: string | null;
|
|
matchName: string;
|
|
venueJson: Prisma.InputJsonValue;
|
|
kickoffJson: Prisma.InputJsonValue;
|
|
externalStatus: string;
|
|
}>;
|
|
}) {
|
|
const status = data.status ?? 'DRAFT';
|
|
return this.prisma.match.create({
|
|
data: {
|
|
leagueId: data.leagueId,
|
|
homeTeamId: data.homeTeamId,
|
|
awayTeamId: data.awayTeamId,
|
|
startTime: data.startTime,
|
|
isHot: data.isHot ?? false,
|
|
displayOrder: data.displayOrder ?? 0,
|
|
createdBy: data.createdBy,
|
|
status,
|
|
publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined),
|
|
officialMatchNo: data.zhibo?.officialMatchNo,
|
|
stage: data.zhibo?.stage,
|
|
groupName: data.zhibo?.groupName,
|
|
liveMatchId: data.zhibo?.liveMatchId,
|
|
additionMatchId: data.zhibo?.additionMatchId ?? undefined,
|
|
channelId: data.zhibo?.channelId ?? undefined,
|
|
matchName: data.zhibo?.matchName,
|
|
venueJson: data.zhibo?.venueJson,
|
|
kickoffJson: data.zhibo?.kickoffJson,
|
|
externalStatus: data.zhibo?.externalStatus,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async upsertEntityTranslations(
|
|
entityType: 'LEAGUE' | 'TEAM',
|
|
entityId: bigint,
|
|
translations: Record<string, string>,
|
|
) {
|
|
for (const [locale, value] of Object.entries(translations)) {
|
|
await this.prisma.entityTranslation.upsert({
|
|
where: {
|
|
entityType_entityId_locale_fieldName: {
|
|
entityType,
|
|
entityId,
|
|
locale,
|
|
fieldName: 'name',
|
|
},
|
|
},
|
|
create: { entityType, entityId, locale, fieldName: 'name', value },
|
|
update: { value },
|
|
});
|
|
}
|
|
}
|
|
|
|
async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) {
|
|
const code = leagueCodeFromExport(league);
|
|
const record = await this.prisma.league.upsert({
|
|
where: { code },
|
|
create: { code, sportType: league.type || 'FOOTBALL' },
|
|
update: { sportType: league.type || 'FOOTBALL' },
|
|
});
|
|
await this.upsertEntityTranslations('LEAGUE', record.id, {
|
|
'zh-CN': league.zh,
|
|
'en-US': league.en,
|
|
});
|
|
return record;
|
|
}
|
|
|
|
async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
|
|
const translations = translationsFromZhiboNames(team.names, team.name);
|
|
|
|
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;
|
|
}
|
|
|
|
private async findExistingZhiboMatch(
|
|
leagueId: bigint,
|
|
homeTeamId: bigint,
|
|
awayTeamId: bigint,
|
|
item: ZhiboMatchExport,
|
|
) {
|
|
if (item.liveMatchId != null) {
|
|
return this.prisma.match.findUnique({
|
|
where: { liveMatchId: BigInt(item.liveMatchId) },
|
|
});
|
|
}
|
|
if (item.officialMatchNo != null) {
|
|
return this.prisma.match.findFirst({
|
|
where: {
|
|
leagueId,
|
|
homeTeamId,
|
|
awayTeamId,
|
|
officialMatchNo: item.officialMatchNo,
|
|
},
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async createPlatformLeague(data: {
|
|
leagueEn: string;
|
|
leagueZh: string;
|
|
leagueMs?: string;
|
|
logoUrl?: string;
|
|
displayOrder?: number;
|
|
}) {
|
|
const leagueEn = data.leagueEn.trim();
|
|
const leagueZh = data.leagueZh.trim();
|
|
if (!leagueEn && !leagueZh) {
|
|
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
|
|
}
|
|
const league = await this.upsertLeagueFromZhiboExport({
|
|
type: 'FOOTBALL',
|
|
en: leagueEn || leagueZh,
|
|
zh: leagueZh || leagueEn,
|
|
});
|
|
if (data.leagueMs?.trim()) {
|
|
await this.upsertEntityTranslations('LEAGUE', league.id, {
|
|
'ms-MY': data.leagueMs.trim(),
|
|
});
|
|
}
|
|
const updates: { logoUrl?: string; displayOrder?: number } = {};
|
|
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
|
|
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
|
if (Object.keys(updates).length) {
|
|
await this.prisma.league.update({ where: { id: league.id }, data: updates });
|
|
}
|
|
const [en, zh, ms] = await Promise.all([
|
|
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
|
|
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
|
|
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
|
|
]);
|
|
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } });
|
|
return {
|
|
id: fresh.id.toString(),
|
|
code: fresh.code,
|
|
logoUrl: fresh.logoUrl,
|
|
displayOrder: fresh.displayOrder,
|
|
leagueEn: en,
|
|
leagueZh: zh,
|
|
leagueMs: ms,
|
|
};
|
|
}
|
|
|
|
async listAdminLeagues(opts: {
|
|
page: number;
|
|
pageSize: number;
|
|
keyword?: string;
|
|
status?: string;
|
|
}) {
|
|
const skip = (opts.page - 1) * opts.pageSize;
|
|
const kw = opts.keyword?.trim();
|
|
let idFilter: bigint[] | undefined;
|
|
|
|
if (kw || opts.status) {
|
|
const ids = new Set<bigint>();
|
|
if (kw) {
|
|
const trRows = await this.prisma.entityTranslation.findMany({
|
|
where: {
|
|
entityType: 'LEAGUE',
|
|
fieldName: 'name',
|
|
value: { contains: kw, mode: 'insensitive' },
|
|
},
|
|
select: { entityId: true },
|
|
});
|
|
for (const r of trRows) ids.add(r.entityId);
|
|
}
|
|
const matchWhere: Prisma.MatchWhereInput = {
|
|
deletedAt: null,
|
|
isOutright: false,
|
|
};
|
|
if (opts.status) matchWhere.status = opts.status;
|
|
if (kw) {
|
|
matchWhere.OR = [
|
|
{ matchName: { contains: kw, mode: 'insensitive' } },
|
|
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
|
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
|
];
|
|
}
|
|
const matchLeagues = await this.prisma.match.findMany({
|
|
where: matchWhere,
|
|
select: { leagueId: true },
|
|
distinct: ['leagueId'],
|
|
});
|
|
for (const m of matchLeagues) ids.add(m.leagueId);
|
|
idFilter = [...ids];
|
|
if (!idFilter.length) {
|
|
return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize };
|
|
}
|
|
}
|
|
|
|
const where: Prisma.LeagueWhereInput = { deletedAt: null };
|
|
if (idFilter) where.id = { in: idFilter };
|
|
|
|
const [leagues, total] = await Promise.all([
|
|
this.prisma.league.findMany({
|
|
where,
|
|
orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }],
|
|
skip,
|
|
take: opts.pageSize,
|
|
}),
|
|
this.prisma.league.count({ where }),
|
|
]);
|
|
|
|
const items = await Promise.all(
|
|
leagues.map(async (league) => {
|
|
const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([
|
|
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
|
|
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
|
|
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
|
|
this.prisma.match.count({
|
|
where: {
|
|
leagueId: league.id,
|
|
deletedAt: null,
|
|
isOutright: false,
|
|
...(opts.status ? { status: opts.status } : {}),
|
|
},
|
|
}),
|
|
]);
|
|
return {
|
|
id: league.id.toString(),
|
|
code: league.code,
|
|
logoUrl: league.logoUrl,
|
|
displayOrder: league.displayOrder,
|
|
leagueEn,
|
|
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 };
|
|
}
|
|
|
|
async listAdminLeagueMatches(
|
|
leagueId: bigint,
|
|
opts: { status?: string; keyword?: string; locale?: string },
|
|
) {
|
|
const where: Prisma.MatchWhereInput = {
|
|
leagueId,
|
|
deletedAt: null,
|
|
isOutright: false,
|
|
};
|
|
if (opts.status) where.status = opts.status;
|
|
const kw = opts.keyword?.trim();
|
|
if (kw) {
|
|
where.OR = [
|
|
{ matchName: { contains: kw, mode: 'insensitive' } },
|
|
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
|
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
|
];
|
|
}
|
|
const items = await this.prisma.match.findMany({
|
|
where,
|
|
include: { homeTeam: true, awayTeam: true },
|
|
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,
|
|
isOutright: m.isOutright,
|
|
isHot: m.isHot,
|
|
displayOrder: m.displayOrder,
|
|
startTime: m.startTime,
|
|
matchName: m.matchName,
|
|
homeTeamName,
|
|
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;
|
|
leagueZh?: string;
|
|
leagueMs?: string;
|
|
homeTeamZh: string;
|
|
homeTeamEn: string;
|
|
homeTeamMs?: string;
|
|
awayTeamZh: string;
|
|
awayTeamEn: string;
|
|
awayTeamMs?: string;
|
|
startTime: Date;
|
|
isHot?: boolean;
|
|
displayOrder?: number;
|
|
matchName?: string;
|
|
stage?: string;
|
|
groupName?: string;
|
|
leagueLogoUrl?: string;
|
|
homeTeamLogoUrl?: string;
|
|
awayTeamLogoUrl?: string;
|
|
createdBy?: bigint;
|
|
}) {
|
|
const homeEn = data.homeTeamEn.trim();
|
|
const homeZh = data.homeTeamZh.trim();
|
|
const homeMs = data.homeTeamMs?.trim() ?? '';
|
|
const awayEn = data.awayTeamEn.trim();
|
|
const awayZh = data.awayTeamZh.trim();
|
|
const awayMs = data.awayTeamMs?.trim() ?? '';
|
|
if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) {
|
|
throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)');
|
|
}
|
|
|
|
let league;
|
|
if (data.leagueId) {
|
|
league = await this.prisma.league.findFirst({
|
|
where: { id: data.leagueId, deletedAt: null },
|
|
});
|
|
if (!league) throw new NotFoundException('赛事不存在');
|
|
} else {
|
|
const leagueEn = data.leagueEn?.trim() ?? '';
|
|
const leagueZh = data.leagueZh?.trim() ?? '';
|
|
const leagueMs = data.leagueMs?.trim() ?? '';
|
|
if (!leagueEn && !leagueZh && !leagueMs) {
|
|
throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)');
|
|
}
|
|
league = await this.upsertLeagueFromZhiboExport({
|
|
type: 'FOOTBALL',
|
|
en: leagueEn || leagueZh || leagueMs,
|
|
zh: leagueZh || leagueEn || leagueMs,
|
|
});
|
|
if (leagueMs) {
|
|
await this.upsertEntityTranslations('LEAGUE', league.id, {
|
|
'ms-MY': leagueMs,
|
|
});
|
|
}
|
|
if (data.leagueLogoUrl?.trim()) {
|
|
await this.prisma.league.update({
|
|
where: { id: league.id },
|
|
data: { logoUrl: data.leagueLogoUrl.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() ||
|
|
`${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`;
|
|
|
|
return this.createMatch({
|
|
leagueId: league.id,
|
|
homeTeamId: homeTeam.id,
|
|
awayTeamId: awayTeam.id,
|
|
startTime: data.startTime,
|
|
isHot: data.isHot ?? false,
|
|
displayOrder: data.displayOrder ?? 0,
|
|
createdBy: data.createdBy,
|
|
status: 'DRAFT',
|
|
zhibo: {
|
|
matchName,
|
|
stage: data.stage?.trim() || undefined,
|
|
groupName: data.groupName?.trim() || undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async requireAdminMatch(matchId: bigint) {
|
|
const match = await this.prisma.match.findFirst({
|
|
where: { id: matchId, deletedAt: null },
|
|
include: { homeTeam: true, awayTeam: true, league: true },
|
|
});
|
|
if (!match) throw new NotFoundException('赛事不存在');
|
|
return match;
|
|
}
|
|
|
|
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' } } },
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] =
|
|
await Promise.all([
|
|
this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'),
|
|
this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'),
|
|
this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'),
|
|
this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'),
|
|
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
|
|
this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'),
|
|
this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'),
|
|
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
|
|
this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'),
|
|
]);
|
|
return {
|
|
id: match.id.toString(),
|
|
status: match.status,
|
|
isOutright: match.isOutright,
|
|
isHot: match.isHot,
|
|
displayOrder: match.displayOrder,
|
|
startTime: match.startTime.toISOString(),
|
|
leagueId: match.leagueId.toString(),
|
|
leagueCode: match.league.code,
|
|
leagueEn,
|
|
leagueZh,
|
|
leagueMs,
|
|
leagueLogoUrl: match.league.logoUrl ?? '',
|
|
homeTeamEn: homeEn,
|
|
homeTeamZh: homeZh,
|
|
homeTeamMs: homeMs,
|
|
homeTeamCode: match.homeTeam.code,
|
|
homeTeamLogoUrl: match.homeTeam.logoUrl ?? '',
|
|
awayTeamEn: awayEn,
|
|
awayTeamZh: awayZh,
|
|
awayTeamMs: awayMs,
|
|
awayTeamCode: match.awayTeam.code,
|
|
awayTeamLogoUrl: match.awayTeam.logoUrl ?? '',
|
|
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,
|
|
period: m.period,
|
|
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
|
status: m.status,
|
|
promoLabel: m.promoLabel ?? '',
|
|
sortOrder: m.sortOrder,
|
|
selections: m.selections.map((s) => ({
|
|
id: s.id.toString(),
|
|
selectionCode: s.selectionCode,
|
|
selectionName: s.selectionName,
|
|
odds: Number(s.odds),
|
|
status: s.status,
|
|
sortOrder: s.sortOrder,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async updatePlatformMatch(
|
|
matchId: bigint,
|
|
data: {
|
|
leagueEn: string;
|
|
leagueZh: string;
|
|
leagueMs?: string;
|
|
homeTeamZh: string;
|
|
homeTeamEn: string;
|
|
homeTeamMs?: string;
|
|
awayTeamZh: string;
|
|
awayTeamEn: string;
|
|
awayTeamMs?: string;
|
|
startTime: Date;
|
|
isHot?: boolean;
|
|
displayOrder?: number;
|
|
matchName?: string;
|
|
stage?: string;
|
|
groupName?: string;
|
|
leagueLogoUrl?: string;
|
|
homeTeamLogoUrl?: string;
|
|
awayTeamLogoUrl?: string;
|
|
updatedBy?: bigint;
|
|
},
|
|
) {
|
|
const match = await this.requireAdminMatch(matchId);
|
|
if (match.isOutright) {
|
|
throw new BadRequestException('冠军盘请通过盘口管理维护');
|
|
}
|
|
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
|
|
throw new BadRequestException('当前状态不可编辑');
|
|
}
|
|
|
|
const matchName =
|
|
data.matchName?.trim() ||
|
|
`${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`;
|
|
|
|
await Promise.all([
|
|
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
|
|
'zh-CN': data.leagueZh.trim(),
|
|
'en-US': data.leagueEn.trim(),
|
|
'ms-MY': (data.leagueMs ?? '').trim(),
|
|
}),
|
|
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
|
|
'zh-CN': data.homeTeamZh.trim(),
|
|
'en-US': data.homeTeamEn.trim(),
|
|
'ms-MY': (data.homeTeamMs ?? '').trim(),
|
|
}),
|
|
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
|
|
'zh-CN': data.awayTeamZh.trim(),
|
|
'en-US': data.awayTeamEn.trim(),
|
|
'ms-MY': (data.awayTeamMs ?? '').trim(),
|
|
}),
|
|
]);
|
|
|
|
const logoUpdates: Promise<unknown>[] = [];
|
|
if (data.leagueLogoUrl !== undefined) {
|
|
logoUpdates.push(
|
|
this.prisma.league.update({
|
|
where: { id: match.leagueId },
|
|
data: { logoUrl: data.leagueLogoUrl.trim() || null },
|
|
}),
|
|
);
|
|
}
|
|
if (data.homeTeamLogoUrl !== undefined) {
|
|
logoUpdates.push(
|
|
this.prisma.team.update({
|
|
where: { id: match.homeTeamId },
|
|
data: { logoUrl: data.homeTeamLogoUrl.trim() || null },
|
|
}),
|
|
);
|
|
}
|
|
if (data.awayTeamLogoUrl !== undefined) {
|
|
logoUpdates.push(
|
|
this.prisma.team.update({
|
|
where: { id: match.awayTeamId },
|
|
data: { logoUrl: data.awayTeamLogoUrl.trim() || null },
|
|
}),
|
|
);
|
|
}
|
|
if (logoUpdates.length) await Promise.all(logoUpdates);
|
|
|
|
return this.prisma.match.update({
|
|
where: { id: matchId },
|
|
data: {
|
|
startTime: data.startTime,
|
|
isHot: data.isHot ?? match.isHot,
|
|
displayOrder: data.displayOrder ?? match.displayOrder,
|
|
matchName,
|
|
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
|
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
|
updatedBy: data.updatedBy,
|
|
},
|
|
});
|
|
}
|
|
|
|
async deleteMatch(matchId: bigint) {
|
|
const match = await this.requireAdminMatch(matchId);
|
|
if (match.isOutright) {
|
|
throw new BadRequestException('冠军盘不可删除');
|
|
}
|
|
if (match.status !== 'DRAFT') {
|
|
throw new BadRequestException('仅草稿状态可删除');
|
|
}
|
|
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
|
|
if (betCount > 0) {
|
|
throw new BadRequestException('该赛事已有注单关联,无法删除');
|
|
}
|
|
return this.prisma.match.update({
|
|
where: { id: matchId },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async createMatchFromZhiboExport(
|
|
item: ZhiboMatchExport,
|
|
createdBy?: bigint,
|
|
opts?: { asDraft?: boolean },
|
|
) {
|
|
const league = await this.upsertLeagueFromZhiboExport(item.league);
|
|
const [homeTeam, awayTeam] = await Promise.all([
|
|
this.upsertTeamFromZhiboExport(item.homeTeam),
|
|
this.upsertTeamFromZhiboExport(item.awayTeam),
|
|
]);
|
|
|
|
const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item);
|
|
const startTime = resolveStartTime(item.kickoff);
|
|
const liveMatchId =
|
|
item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined;
|
|
const payload = {
|
|
leagueId: league.id,
|
|
homeTeamId: homeTeam.id,
|
|
awayTeamId: awayTeam.id,
|
|
startTime,
|
|
isHot: resolveIsHot(item),
|
|
displayOrder: item.sortOrder,
|
|
createdBy,
|
|
status,
|
|
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
|
zhibo: {
|
|
officialMatchNo: item.officialMatchNo,
|
|
stage: item.stage,
|
|
groupName: item.groupName,
|
|
liveMatchId,
|
|
additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null,
|
|
channelId: item.channelId,
|
|
matchName: item.matchName,
|
|
venueJson: toVenueJson(item.venue),
|
|
kickoffJson: toKickoffJson(item.kickoff),
|
|
externalStatus: item.status.state,
|
|
},
|
|
};
|
|
|
|
const existing = await this.findExistingZhiboMatch(
|
|
league.id,
|
|
homeTeam.id,
|
|
awayTeam.id,
|
|
item,
|
|
);
|
|
if (existing) {
|
|
return this.prisma.match.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
leagueId: payload.leagueId,
|
|
homeTeamId: payload.homeTeamId,
|
|
awayTeamId: payload.awayTeamId,
|
|
startTime: payload.startTime,
|
|
isHot: payload.isHot,
|
|
displayOrder: payload.displayOrder,
|
|
status: payload.status,
|
|
publishTime: existing.publishTime ?? payload.publishTime,
|
|
officialMatchNo: payload.zhibo.officialMatchNo,
|
|
stage: payload.zhibo.stage,
|
|
groupName: payload.zhibo.groupName,
|
|
liveMatchId: payload.zhibo.liveMatchId ?? undefined,
|
|
additionMatchId: payload.zhibo.additionMatchId ?? undefined,
|
|
channelId: payload.zhibo.channelId ?? undefined,
|
|
matchName: payload.zhibo.matchName,
|
|
venueJson: payload.zhibo.venueJson,
|
|
kickoffJson: payload.zhibo.kickoffJson,
|
|
externalStatus: payload.zhibo.externalStatus,
|
|
updatedBy: createdBy,
|
|
},
|
|
});
|
|
}
|
|
|
|
return this.createMatch(payload);
|
|
}
|
|
|
|
async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) {
|
|
if (!bundle.matches?.length) {
|
|
throw new BadRequestException('matches array is required');
|
|
}
|
|
|
|
const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = [];
|
|
|
|
for (const item of bundle.matches) {
|
|
try {
|
|
const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true });
|
|
results.push({
|
|
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
|
|
id: match.id.toString(),
|
|
status: match.status,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'import failed';
|
|
results.push({
|
|
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
|
|
id: '',
|
|
status: 'error',
|
|
reason: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: bundle.matches.length,
|
|
imported: results.filter((r) => !r.skipped && r.status !== 'error').length,
|
|
skipped: results.filter((r) => r.skipped).length,
|
|
failed: results.filter((r) => r.status === 'error').length,
|
|
results,
|
|
};
|
|
}
|
|
|
|
async publishMatch(matchId: bigint) {
|
|
return this.prisma.match.update({
|
|
where: { id: matchId },
|
|
data: { status: 'PUBLISHED', publishTime: new Date() },
|
|
});
|
|
}
|
|
|
|
async closeMatch(matchId: bigint) {
|
|
return this.prisma.match.update({
|
|
where: { id: matchId },
|
|
data: { status: 'CLOSED', closeTime: new Date() },
|
|
});
|
|
}
|
|
|
|
async cancelMatch(matchId: bigint) {
|
|
return this.prisma.match.update({
|
|
where: { id: matchId },
|
|
data: { status: 'CANCELLED' },
|
|
});
|
|
}
|
|
|
|
private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
|
|
const row = await this.prisma.entityTranslation.findFirst({
|
|
where: { entityType, entityId, locale, fieldName: 'name' },
|
|
});
|
|
return row?.value ?? '';
|
|
}
|
|
|
|
async getTranslation(entityType: string, entityId: bigint, locale: string) {
|
|
const translations = await this.prisma.entityTranslation.findMany({
|
|
where: { entityType, entityId },
|
|
});
|
|
const map = Object.fromEntries(
|
|
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
|
|
);
|
|
return resolveTranslationFallback(map, locale);
|
|
}
|
|
|
|
async enrichMatch(match: Record<string, unknown>, locale: string) {
|
|
const m = match as {
|
|
id: bigint;
|
|
leagueId: bigint;
|
|
homeTeamId: bigint;
|
|
awayTeamId: bigint;
|
|
startTime: Date;
|
|
status?: string;
|
|
isHot?: boolean;
|
|
displayOrder?: number;
|
|
matchName?: string | null;
|
|
stage?: string | null;
|
|
groupName?: string | null;
|
|
homeTeam?: { code: string; logoUrl?: string | null };
|
|
awayTeam?: { code: string; logoUrl?: string | null };
|
|
league?: { logoUrl?: string | null };
|
|
markets?: Array<Record<string, unknown>>;
|
|
};
|
|
const [leagueName, homeName, awayName] = await Promise.all([
|
|
this.getTranslation('LEAGUE', m.leagueId, locale),
|
|
this.getTranslation('TEAM', m.homeTeamId, locale),
|
|
this.getTranslation('TEAM', m.awayTeamId, locale),
|
|
]);
|
|
const base = {
|
|
id: m.id.toString(),
|
|
leagueId: m.leagueId.toString(),
|
|
leagueName,
|
|
leagueLogoUrl: m.league?.logoUrl ?? null,
|
|
homeTeamName: homeName,
|
|
awayTeamName: awayName,
|
|
homeTeamCode: m.homeTeam?.code ?? '',
|
|
awayTeamCode: m.awayTeam?.code ?? '',
|
|
homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null,
|
|
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
|
startTime: m.startTime.toISOString(),
|
|
isHot: m.isHot ?? false,
|
|
displayOrder: m.displayOrder ?? 0,
|
|
matchName: m.matchName ?? null,
|
|
stage: m.stage ?? null,
|
|
groupName: m.groupName ?? null,
|
|
status: m.status ?? 'PUBLISHED',
|
|
};
|
|
if (m.markets) {
|
|
return {
|
|
...base,
|
|
markets: m.markets.map((market) => ({
|
|
id: (market.id as bigint).toString(),
|
|
marketType: market.marketType as string,
|
|
period: market.period as string,
|
|
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
|
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
|
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
|
|
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
|
id: (s.id as bigint).toString(),
|
|
selectionCode: s.selectionCode as string,
|
|
selectionName: s.selectionName as string,
|
|
odds: Number(s.odds),
|
|
oddsVersion: (s.oddsVersion as bigint).toString(),
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
return base;
|
|
}
|
|
|
|
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
|
const now = new Date();
|
|
const matches = await this.prisma.match.findMany({
|
|
where: {
|
|
status: 'PUBLISHED',
|
|
isOutright: false,
|
|
sportType: 'FOOTBALL',
|
|
deletedAt: null,
|
|
startTime: { gt: now },
|
|
...(leagueId ? { leagueId } : {}),
|
|
},
|
|
include: {
|
|
league: true,
|
|
homeTeam: true,
|
|
awayTeam: true,
|
|
markets: {
|
|
where: { status: 'OPEN' },
|
|
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
},
|
|
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
|
});
|
|
|
|
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
|
}
|
|
|
|
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
|
const match = await this.prisma.match.findFirst({
|
|
where: {
|
|
id: matchId,
|
|
deletedAt: null,
|
|
sportType: 'FOOTBALL',
|
|
isOutright: false,
|
|
status: { in: ['PUBLISHED', 'CLOSED'] },
|
|
},
|
|
include: {
|
|
league: true,
|
|
homeTeam: true,
|
|
awayTeam: true,
|
|
markets: {
|
|
where: { status: 'OPEN' },
|
|
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
score: true,
|
|
},
|
|
});
|
|
if (!match) throw new NotFoundException('Match not found');
|
|
return this.enrichMatch(match, locale);
|
|
}
|
|
|
|
async listOutrights(locale = 'en-US') {
|
|
try {
|
|
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
|
|
} catch {
|
|
/* 联赛未 seed 时忽略,仍返回已有数据 */
|
|
}
|
|
|
|
const matches = await this.prisma.match.findMany({
|
|
where: {
|
|
status: 'PUBLISHED',
|
|
isOutright: true,
|
|
sportType: 'FOOTBALL',
|
|
deletedAt: null,
|
|
},
|
|
include: {
|
|
markets: {
|
|
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
|
|
include: {
|
|
selections: {
|
|
where: { status: 'OPEN' },
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
|
|
});
|
|
|
|
const results = [];
|
|
for (const match of matches) {
|
|
const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale);
|
|
const market = match.markets[0];
|
|
if (!market) continue;
|
|
|
|
const selections = await Promise.all(
|
|
market.selections
|
|
.filter((sel) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE)
|
|
.map(async (sel) => {
|
|
const team = await this.prisma.team.findUnique({
|
|
where: { code: sel.selectionCode },
|
|
});
|
|
const teamName = team
|
|
? await this.getTranslation('TEAM', team.id, locale)
|
|
: sel.selectionName;
|
|
return {
|
|
id: sel.id.toString(),
|
|
teamCode: sel.selectionCode,
|
|
teamName,
|
|
rank: sel.sortOrder + 1,
|
|
odds: sel.odds.toString(),
|
|
oddsVersion: sel.oddsVersion.toString(),
|
|
};
|
|
}),
|
|
);
|
|
|
|
if (selections.length === 0) continue;
|
|
|
|
results.push({
|
|
id: match.id.toString(),
|
|
leagueId: match.leagueId.toString(),
|
|
leagueName,
|
|
title: `*${leagueName} 冠军`,
|
|
marketId: market.id.toString(),
|
|
selections,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
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' },
|
|
};
|
|
const entry = labels[marketType];
|
|
if (!entry) return marketType;
|
|
return entry[locale] ?? entry['en-US'] ?? marketType;
|
|
}
|
|
|
|
async enrichBetsForHistory(
|
|
bets: Array<{
|
|
betNo: string;
|
|
betType: string;
|
|
stake: unknown;
|
|
totalOdds: unknown;
|
|
potentialReturn: unknown;
|
|
actualReturn: unknown;
|
|
status: string;
|
|
placedAt: Date;
|
|
selections: Array<{
|
|
matchId: bigint | null;
|
|
marketType: string;
|
|
selectionNameSnapshot: string;
|
|
odds: unknown;
|
|
resultStatus?: string | null;
|
|
}>;
|
|
}>,
|
|
locale: string,
|
|
) {
|
|
const matchIds = [
|
|
...new Set(
|
|
bets.flatMap((b) =>
|
|
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
|
|
),
|
|
),
|
|
];
|
|
|
|
const matches =
|
|
matchIds.length > 0
|
|
? await this.prisma.match.findMany({
|
|
where: { id: { in: matchIds } },
|
|
include: { homeTeam: true, awayTeam: true },
|
|
})
|
|
: [];
|
|
|
|
const matchMeta = new Map<
|
|
string,
|
|
{ leagueName: string; matchTitle: string; isOutright: boolean }
|
|
>();
|
|
|
|
for (const m of matches) {
|
|
const [leagueName, homeName, awayName] = await Promise.all([
|
|
this.getTranslation('LEAGUE', m.leagueId, locale),
|
|
this.getTranslation('TEAM', m.homeTeamId, locale),
|
|
this.getTranslation('TEAM', m.awayTeamId, locale),
|
|
]);
|
|
matchMeta.set(m.id.toString(), {
|
|
leagueName,
|
|
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
|
|
isOutright: m.isOutright,
|
|
});
|
|
}
|
|
|
|
return bets.map((bet) => {
|
|
const firstMatchId = bet.selections.find((s) => s.matchId)?.matchId?.toString();
|
|
const meta = firstMatchId ? matchMeta.get(firstMatchId) : undefined;
|
|
const isParlay = bet.betType === 'PARLAY' || bet.selections.length > 1;
|
|
|
|
const legs = bet.selections.map((sel) => {
|
|
const mid = sel.matchId?.toString();
|
|
const m = mid ? matchMeta.get(mid) : undefined;
|
|
return {
|
|
marketType: sel.marketType,
|
|
marketLabel: this.marketLabelKey(sel.marketType, locale),
|
|
selectionName: sel.selectionNameSnapshot,
|
|
odds: sel.odds,
|
|
resultStatus: sel.resultStatus,
|
|
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
|
|
leagueName: m?.leagueName ?? '',
|
|
};
|
|
});
|
|
|
|
return {
|
|
betNo: bet.betNo,
|
|
betType: bet.betType,
|
|
stake: bet.stake,
|
|
totalOdds: bet.totalOdds,
|
|
potentialReturn: bet.potentialReturn,
|
|
actualReturn: bet.actualReturn,
|
|
status: bet.status,
|
|
placedAt: bet.placedAt,
|
|
leagueName: isParlay
|
|
? 'Parlay'
|
|
: meta?.leagueName ?? legs[0]?.leagueName ?? '',
|
|
legCount: bet.selections.length,
|
|
matchTitle: isParlay
|
|
? ''
|
|
: meta?.matchTitle ?? legs[0]?.matchTitle ?? bet.betNo,
|
|
pickLabel: isParlay
|
|
? ''
|
|
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
|
|
legs,
|
|
isParlay,
|
|
};
|
|
});
|
|
}
|
|
|
|
@Cron(CronExpression.EVERY_MINUTE)
|
|
async autoCloseMatches() {
|
|
const now = new Date();
|
|
await this.prisma.match.updateMany({
|
|
where: {
|
|
status: 'PUBLISHED',
|
|
isOutright: false,
|
|
startTime: { lte: now },
|
|
},
|
|
data: { status: 'CLOSED', closeTime: now },
|
|
});
|
|
}
|
|
}
|