1831 lines
60 KiB
TypeScript
1831 lines
60 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import {
|
||
defaultMarketName,
|
||
defaultSelectionName,
|
||
isPreMatchKickoff,
|
||
isSettlementSupportedMarketType,
|
||
PARLAY_MARKET_TYPES,
|
||
resolveMarketText,
|
||
resolveTranslationFallback,
|
||
sanitizeLocalizedText,
|
||
} 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';
|
||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
|
||
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';
|
||
import { OutrightService } from './outright.service';
|
||
|
||
export type { MatchBetStatsSummary } from './match-bet-stats.service';
|
||
|
||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||
|
||
const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;
|
||
|
||
export type ListPublishedOptions = {
|
||
/** 响应中是否包含 markets(列表页默认 false,仅摘要) */
|
||
includeMarkets?: boolean;
|
||
/** 仅返回串关玩法 markets(需 includeMarkets=true) */
|
||
parlayMarketsOnly?: boolean;
|
||
};
|
||
|
||
@Injectable()
|
||
export class MatchesService {
|
||
constructor(
|
||
private prisma: PrismaService,
|
||
private outright: OutrightService,
|
||
private matchBetStats: MatchBetStatsService,
|
||
) {}
|
||
|
||
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;
|
||
isActive?: boolean;
|
||
}) {
|
||
const leagueEn = data.leagueEn.trim();
|
||
const leagueZh = data.leagueZh.trim();
|
||
if (!leagueEn && !leagueZh) {
|
||
throw appBadRequest('LEAGUE_NAME_REQUIRED');
|
||
}
|
||
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; isActive: boolean } = {
|
||
isActive: data.isActive ?? false,
|
||
};
|
||
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
|
||
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
||
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,
|
||
isPublished: fresh.isActive,
|
||
leagueEn: en,
|
||
leagueZh: zh,
|
||
leagueMs: ms,
|
||
};
|
||
}
|
||
|
||
async updatePlatformLeague(
|
||
leagueId: bigint,
|
||
data: {
|
||
leagueEn: string;
|
||
leagueZh: string;
|
||
leagueMs?: string;
|
||
logoUrl?: string;
|
||
displayOrder?: number;
|
||
isActive?: boolean;
|
||
},
|
||
) {
|
||
const league = await this.prisma.league.findFirst({
|
||
where: { id: leagueId, deletedAt: null },
|
||
});
|
||
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
|
||
|
||
const leagueEn = data.leagueEn.trim();
|
||
const leagueZh = data.leagueZh.trim();
|
||
if (!leagueEn && !leagueZh) {
|
||
throw appBadRequest('LEAGUE_NAME_REQUIRED');
|
||
}
|
||
|
||
await this.upsertEntityTranslations('LEAGUE', leagueId, {
|
||
'zh-CN': leagueZh,
|
||
'en-US': leagueEn,
|
||
'ms-MY': (data.leagueMs ?? '').trim(),
|
||
});
|
||
|
||
const updates: { logoUrl?: string | null; displayOrder?: number; isActive?: boolean } = {};
|
||
if (data.logoUrl !== undefined) {
|
||
updates.logoUrl = data.logoUrl.trim() || null;
|
||
}
|
||
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
||
if (data.isActive !== undefined) {
|
||
if (league.isActive && data.isActive === false) {
|
||
const outright = await this.prisma.match.findFirst({
|
||
where: { leagueId, isOutright: true, deletedAt: null },
|
||
select: { status: true },
|
||
});
|
||
if (outright?.status === 'SETTLED') {
|
||
throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED');
|
||
}
|
||
}
|
||
updates.isActive = data.isActive;
|
||
}
|
||
if (Object.keys(updates).length) {
|
||
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
|
||
}
|
||
|
||
if (data.isActive === true) {
|
||
await this.outright.syncWithLeaguePublished(leagueId);
|
||
}
|
||
|
||
const [en, zh, ms] = await Promise.all([
|
||
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
|
||
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
|
||
this.getTranslationExact('LEAGUE', leagueId, 'ms-MY'),
|
||
]);
|
||
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: leagueId } });
|
||
return {
|
||
id: fresh.id.toString(),
|
||
code: fresh.code,
|
||
logoUrl: fresh.logoUrl,
|
||
displayOrder: fresh.displayOrder,
|
||
isPublished: fresh.isActive,
|
||
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) {
|
||
const ids = new Set<bigint>();
|
||
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,
|
||
OR: [
|
||
{ matchName: { contains: kw, mode: 'insensitive' } },
|
||
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||
],
|
||
};
|
||
if (opts.status) matchWhere.status = opts.status;
|
||
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,
|
||
isPublished: league.isActive,
|
||
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 fixtureTeamRows = await this.prisma.match.findMany({
|
||
where: {
|
||
leagueId: { in: leagueIds },
|
||
deletedAt: null,
|
||
isOutright: false,
|
||
},
|
||
select: {
|
||
leagueId: true,
|
||
homeTeam: { select: { id: true, code: true } },
|
||
awayTeam: { select: { id: true, code: true } },
|
||
},
|
||
});
|
||
const fixtureTeamSets = new Map<string, Set<string>>();
|
||
for (const row of fixtureTeamRows) {
|
||
const lid = row.leagueId.toString();
|
||
if (!fixtureTeamSets.has(lid)) fixtureTeamSets.set(lid, new Set());
|
||
const set = fixtureTeamSets.get(lid)!;
|
||
for (const team of [row.homeTeam, row.awayTeam]) {
|
||
if (team.code !== 'OUT') set.add(team.id.toString());
|
||
}
|
||
}
|
||
const matchStats = await this.betStatsForMatches(
|
||
leagueMatches.map((m) => m.id),
|
||
);
|
||
const leagueBetRollup = new Map<
|
||
string,
|
||
{ betCount: number; totalStake: Decimal; pendingCount: number }
|
||
>();
|
||
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(new Decimal(ms.totalStake));
|
||
cur.pendingCount += ms.pendingCount;
|
||
}
|
||
leagueBetRollup.set(lid, cur);
|
||
}
|
||
const outrightMatches = await this.prisma.match.findMany({
|
||
where: {
|
||
leagueId: { in: leagueIds },
|
||
isOutright: true,
|
||
deletedAt: null,
|
||
},
|
||
select: { id: true, leagueId: true },
|
||
});
|
||
const outrightTeamCounts = new Map<string, number>();
|
||
if (outrightMatches.length > 0) {
|
||
const matchIdToLeagueId = new Map(
|
||
outrightMatches.map((m) => [m.id.toString(), m.leagueId.toString()]),
|
||
);
|
||
const outrightMatchIds = outrightMatches.map((m) => m.id);
|
||
const markets = await this.prisma.market.findMany({
|
||
where: {
|
||
matchId: { in: outrightMatchIds },
|
||
marketType: 'OUTRIGHT_WINNER',
|
||
},
|
||
select: { id: true, matchId: true },
|
||
});
|
||
if (markets.length > 0) {
|
||
const marketIdToMatchId = new Map(
|
||
markets.map((m) => [m.id.toString(), m.matchId.toString()]),
|
||
);
|
||
const selectionCounts = await this.prisma.marketSelection.groupBy({
|
||
by: ['marketId'],
|
||
where: {
|
||
marketId: { in: markets.map((m) => m.id) },
|
||
status: 'OPEN',
|
||
selectionCode: { not: 'OUT' },
|
||
},
|
||
_count: { id: true },
|
||
});
|
||
for (const row of selectionCounts) {
|
||
const matchId = marketIdToMatchId.get(row.marketId.toString());
|
||
const leagueId = matchId ? matchIdToLeagueId.get(matchId) : undefined;
|
||
if (leagueId) {
|
||
outrightTeamCounts.set(leagueId, row._count.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
(item as { fixtureTeamCount?: number }).fixtureTeamCount =
|
||
fixtureTeamSets.get(item.id)?.size ?? 0;
|
||
(item as { outrightTeamCount?: number }).outrightTeamCount =
|
||
outrightTeamCounts.get(item.id) ?? 0;
|
||
}
|
||
|
||
return { items, total, page: opts.page, pageSize: opts.pageSize };
|
||
}
|
||
|
||
async listAdminLeagueMatches(
|
||
leagueId: bigint,
|
||
opts: {
|
||
status?: string;
|
||
keyword?: string;
|
||
locale?: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
hasBets?: string;
|
||
orderBy?: 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 page = Math.max(1, opts.page ?? 1);
|
||
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
|
||
|
||
if (opts.hasBets === 'true' || opts.orderBy === 'betCount' || opts.orderBy === 'totalStake') {
|
||
const allRows = await this.prisma.match.findMany({
|
||
where,
|
||
include: { homeTeam: true, awayTeam: true },
|
||
});
|
||
const locale = opts.locale ?? 'zh-CN';
|
||
const betStatsMap = await this.betStatsForMatches(allRows.map((m) => m.id));
|
||
const itemsWithStats = await Promise.all(
|
||
allRows.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 ?? '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,
|
||
};
|
||
}),
|
||
);
|
||
|
||
let filteredItems = itemsWithStats;
|
||
if (opts.hasBets === 'true') {
|
||
filteredItems = itemsWithStats.filter((item) => item.betCount > 0);
|
||
}
|
||
|
||
if (opts.orderBy === 'betCount') {
|
||
filteredItems.sort((a, b) => b.betCount - a.betCount);
|
||
} else if (opts.orderBy === 'totalStake') {
|
||
filteredItems.sort((a, b) => {
|
||
const stakeA = parseFloat(a.totalStake);
|
||
const stakeB = parseFloat(b.totalStake);
|
||
return stakeB - stakeA;
|
||
});
|
||
} else {
|
||
filteredItems.sort((a, b) => {
|
||
if (a.displayOrder !== b.displayOrder) {
|
||
return a.displayOrder - b.displayOrder;
|
||
}
|
||
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
|
||
});
|
||
}
|
||
|
||
const total = filteredItems.length;
|
||
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
|
||
return { items: paginatedItems, total, page, pageSize };
|
||
}
|
||
|
||
const [total, rows] = await Promise.all([
|
||
this.prisma.match.count({ where }),
|
||
this.prisma.match.findMany({
|
||
where,
|
||
include: { homeTeam: true, awayTeam: true },
|
||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
}),
|
||
]);
|
||
const locale = opts.locale ?? 'zh-CN';
|
||
const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id));
|
||
const items = await Promise.all(
|
||
rows.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 ?? '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,
|
||
};
|
||
}),
|
||
);
|
||
return { items, total, page, pageSize };
|
||
}
|
||
|
||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||
async betStatsForMatches(
|
||
matchIds: bigint[],
|
||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||
return this.matchBetStats.betStatsForMatches(matchIds);
|
||
}
|
||
|
||
private async upsertTeamByCode(data: {
|
||
code: string;
|
||
teamZh: string;
|
||
teamEn: string;
|
||
teamMs?: string;
|
||
logoUrl?: string;
|
||
}) {
|
||
const code = data.code.trim().toUpperCase();
|
||
if (!code) throw appBadRequest('TEAM_CODE_REQUIRED');
|
||
const logoUrl = data.logoUrl?.trim() || undefined;
|
||
const team = await this.prisma.team.upsert({
|
||
where: { code },
|
||
create: { code, logoUrl },
|
||
update: logoUrl ? { logoUrl } : {},
|
||
});
|
||
const translations: Record<string, string> = {
|
||
'zh-CN': data.teamZh.trim() || data.teamEn.trim(),
|
||
'en-US': data.teamEn.trim() || data.teamZh.trim(),
|
||
};
|
||
if (data.teamMs?.trim()) translations['ms-MY'] = data.teamMs.trim();
|
||
await this.upsertEntityTranslations('TEAM', team.id, translations);
|
||
return team;
|
||
}
|
||
|
||
async createPlatformMatch(data: {
|
||
leagueId?: bigint;
|
||
leagueEn?: string;
|
||
leagueZh?: string;
|
||
leagueMs?: string;
|
||
homeTeamCode?: string;
|
||
awayTeamCode?: 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 appBadRequest('TEAMS_NAME_REQUIRED');
|
||
}
|
||
|
||
let league;
|
||
if (data.leagueId) {
|
||
league = await this.prisma.league.findFirst({
|
||
where: { id: data.leagueId, deletedAt: null },
|
||
});
|
||
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
|
||
} else {
|
||
const leagueEn = data.leagueEn?.trim() ?? '';
|
||
const leagueZh = data.leagueZh?.trim() ?? '';
|
||
const leagueMs = data.leagueMs?.trim() ?? '';
|
||
if (!leagueEn && !leagueZh && !leagueMs) {
|
||
throw appBadRequest('LEAGUE_NAME_REQUIRED');
|
||
}
|
||
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 homeCode = data.homeTeamCode?.trim().toUpperCase();
|
||
const awayCode = data.awayTeamCode?.trim().toUpperCase();
|
||
let homeTeam;
|
||
let awayTeam;
|
||
if (homeCode && awayCode) {
|
||
if (homeCode === awayCode) {
|
||
throw appBadRequest('TEAMS_SAME');
|
||
}
|
||
homeTeam = await this.upsertTeamByCode({
|
||
code: homeCode,
|
||
teamZh: homeZh,
|
||
teamEn: homeEn,
|
||
teamMs: homeMs,
|
||
logoUrl: data.homeTeamLogoUrl,
|
||
});
|
||
awayTeam = await this.upsertTeamByCode({
|
||
code: awayCode,
|
||
teamZh: awayZh,
|
||
teamEn: awayEn,
|
||
teamMs: awayMs,
|
||
logoUrl: data.awayTeamLogoUrl,
|
||
});
|
||
} else {
|
||
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() || '',
|
||
});
|
||
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 appBadRequest('TEAMS_SAME');
|
||
}
|
||
|
||
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 appNotFound('MATCH_NOT_FOUND');
|
||
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,
|
||
homeCorners: scoreRow.homeCorners ?? null,
|
||
awayCorners: scoreRow.awayCorners ?? null,
|
||
homeYellowCards: scoreRow.homeYellowCards ?? null,
|
||
awayYellowCards: scoreRow.awayYellowCards ?? null,
|
||
homeRedCards: scoreRow.homeRedCards ?? null,
|
||
awayRedCards: scoreRow.awayRedCards ?? null,
|
||
homeCards: scoreRow.homeCards ?? null,
|
||
awayCards: scoreRow.awayCards ?? null,
|
||
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
|
||
}
|
||
: null,
|
||
markets: markets.map((m) => ({
|
||
id: m.id.toString(),
|
||
marketType: m.marketType,
|
||
marketKey: m.marketKey ?? m.marketType,
|
||
lineKey: m.lineKey ?? null,
|
||
period: m.period,
|
||
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||
paramsJson: m.paramsJson ?? null,
|
||
status: m.status,
|
||
allowSingle: m.allowSingle,
|
||
allowParlay: m.allowParlay,
|
||
showOnPlayer: m.showOnPlayer,
|
||
promoLabel: m.promoLabel ?? '',
|
||
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
|
||
nameI18n: sanitizeLocalizedText(m.nameI18n),
|
||
sortOrder: m.sortOrder,
|
||
selections: m.selections.map((s) => ({
|
||
id: s.id.toString(),
|
||
selectionCode: s.selectionCode,
|
||
selectionName: s.selectionName,
|
||
nameI18n: sanitizeLocalizedText(s.nameI18n),
|
||
odds: Number(s.odds),
|
||
status: s.status,
|
||
sortOrder: s.sortOrder,
|
||
})),
|
||
})),
|
||
};
|
||
}
|
||
|
||
async updatePlatformMatch(
|
||
matchId: bigint,
|
||
data: {
|
||
homeTeamZh: string;
|
||
homeTeamEn: string;
|
||
homeTeamMs?: string;
|
||
awayTeamZh: string;
|
||
awayTeamEn: string;
|
||
awayTeamMs?: string;
|
||
startTime: Date;
|
||
isHot?: boolean;
|
||
displayOrder?: number;
|
||
matchName?: string;
|
||
stage?: string;
|
||
groupName?: string;
|
||
homeTeamLogoUrl?: string;
|
||
awayTeamLogoUrl?: string;
|
||
updatedBy?: bigint;
|
||
},
|
||
) {
|
||
const match = await this.requireAdminMatch(matchId);
|
||
if (match.isOutright) {
|
||
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
|
||
}
|
||
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
|
||
throw appBadRequest('MATCH_NOT_EDITABLE');
|
||
}
|
||
|
||
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('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.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 appBadRequest('OUTRIGHT_DELETE_FORBIDDEN');
|
||
}
|
||
if (match.status !== 'DRAFT') {
|
||
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
|
||
}
|
||
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
|
||
if (betCount > 0) {
|
||
throw appBadRequest('MATCH_HAS_BETS');
|
||
}
|
||
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 appBadRequest('MATCHES_ARRAY_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 unpublishMatch(matchId: bigint) {
|
||
const match = await this.requireAdminMatch(matchId);
|
||
if (match.isOutright) {
|
||
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
|
||
}
|
||
const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'];
|
||
if (!allowed.includes(match.status)) {
|
||
throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN');
|
||
}
|
||
if (match.status === 'PENDING_SETTLEMENT') {
|
||
await this.prisma.settlementBatch.deleteMany({
|
||
where: { matchId, status: 'PREVIEW' },
|
||
});
|
||
}
|
||
return this.prisma.match.update({
|
||
where: { id: matchId },
|
||
data: { status: 'DRAFT', closeTime: null },
|
||
});
|
||
}
|
||
|
||
async closeMatch(matchId: bigint) {
|
||
return this.prisma.match.update({
|
||
where: { id: matchId },
|
||
data: { status: 'CLOSED', closeTime: new Date() },
|
||
});
|
||
}
|
||
|
||
async reopenMatch(matchId: bigint, startTime?: Date) {
|
||
const match = await this.requireAdminMatch(matchId);
|
||
|
||
if (match.isOutright) {
|
||
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||
if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||
if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||
const reopenable =
|
||
match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT';
|
||
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||
if (match.status === 'PENDING_SETTLEMENT') {
|
||
await this.prisma.settlementBatch.deleteMany({
|
||
where: { matchId, status: 'PREVIEW' },
|
||
});
|
||
}
|
||
return this.prisma.match.update({
|
||
where: { id: matchId },
|
||
data: { status: 'PUBLISHED', closeTime: null },
|
||
});
|
||
}
|
||
|
||
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||
|
||
const reopenable =
|
||
match.status === 'CLOSED' ||
|
||
(match.status === 'PENDING_SETTLEMENT' && !scoreRow);
|
||
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||
|
||
if (match.status === 'PENDING_SETTLEMENT') {
|
||
await this.prisma.settlementBatch.deleteMany({
|
||
where: { matchId, status: 'PREVIEW' },
|
||
});
|
||
}
|
||
|
||
const effectiveStart = startTime ?? match.startTime;
|
||
if (!isPreMatchKickoff(effectiveStart)) {
|
||
throw appBadRequest('MATCH_REOPEN_KICKOFF_REQUIRED');
|
||
}
|
||
|
||
return this.prisma.match.update({
|
||
where: { id: matchId },
|
||
data: {
|
||
status: 'PUBLISHED',
|
||
closeTime: null,
|
||
...(startTime ? { startTime } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
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,
|
||
options?: { omitMarkets?: boolean },
|
||
) {
|
||
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 };
|
||
score?: {
|
||
htHomeScore: number | null;
|
||
htAwayScore: number | null;
|
||
ftHomeScore: number | null;
|
||
ftAwayScore: number | null;
|
||
homeCorners?: number | null;
|
||
awayCorners?: number | null;
|
||
homeYellowCards?: number | null;
|
||
awayYellowCards?: number | null;
|
||
homeRedCards?: number | null;
|
||
awayRedCards?: number | null;
|
||
homeCards?: number | null;
|
||
awayCards?: number | null;
|
||
} | 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.status === 'SETTLED' ? false : (m.isHot ?? false),
|
||
displayOrder: m.displayOrder ?? 0,
|
||
matchName: m.matchName ?? null,
|
||
stage: m.stage ?? null,
|
||
groupName: m.groupName ?? null,
|
||
status: m.status ?? 'PUBLISHED',
|
||
score: m.score
|
||
? {
|
||
htHome: m.score.htHomeScore ?? null,
|
||
htAway: m.score.htAwayScore ?? null,
|
||
ftHome: m.score.ftHomeScore ?? null,
|
||
ftAway: m.score.ftAwayScore ?? null,
|
||
homeCorners: m.score.homeCorners ?? null,
|
||
awayCorners: m.score.awayCorners ?? null,
|
||
homeYellowCards: m.score.homeYellowCards ?? null,
|
||
awayYellowCards: m.score.awayYellowCards ?? null,
|
||
homeRedCards: m.score.homeRedCards ?? null,
|
||
awayRedCards: m.score.awayRedCards ?? null,
|
||
homeCards: m.score.homeCards ?? null,
|
||
awayCards: m.score.awayCards ?? null,
|
||
}
|
||
: null,
|
||
bettingOpen: this.isMatchBettingOpen({
|
||
status: m.status,
|
||
startTime: m.startTime,
|
||
markets: (m.markets ?? []) as Array<{
|
||
status: string;
|
||
selections: Array<{ status: string }>;
|
||
}>,
|
||
}),
|
||
matchPhase: this.resolvePlayerMatchPhase(m.status ?? 'PUBLISHED', {
|
||
status: m.status,
|
||
startTime: m.startTime,
|
||
markets: (m.markets ?? []) as Array<{
|
||
status: string;
|
||
selections: Array<{ status: string }>;
|
||
}>,
|
||
}),
|
||
};
|
||
if (m.markets && !options?.omitMarkets) {
|
||
return {
|
||
...base,
|
||
markets: m.markets
|
||
.filter(
|
||
(market) =>
|
||
((market.showOnPlayer as boolean | undefined) ?? true) &&
|
||
isSettlementSupportedMarketType(market.marketType as string),
|
||
)
|
||
.map((market) => ({
|
||
id: (market.id as bigint).toString(),
|
||
marketType: market.marketType as string,
|
||
marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
|
||
lineKey: (market.lineKey as string | null | undefined) ?? null,
|
||
period: market.period as string,
|
||
status: (market.status as string) ?? 'OPEN',
|
||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||
allowSingle: (market.allowSingle as boolean | undefined) ?? true,
|
||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||
marketDisplayName:
|
||
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
|
||
(market.marketType as string),
|
||
promoLabel:
|
||
resolveMarketText(
|
||
market.promoLabelI18n,
|
||
locale,
|
||
(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,
|
||
selectionDisplayName:
|
||
resolveMarketText(
|
||
s.nameI18n,
|
||
locale,
|
||
defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
|
||
(s.selectionName as string),
|
||
) || (s.selectionName as string),
|
||
status: (s.status as string) ?? 'OPEN',
|
||
odds: Number(s.odds),
|
||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||
})),
|
||
})),
|
||
};
|
||
}
|
||
return base;
|
||
}
|
||
|
||
private resolvePlayerMatchPhaseFromStatus(
|
||
status: string,
|
||
startTime: Date,
|
||
): 'open' | 'closed_pending' | 'settled' {
|
||
if (status === 'SETTLED') return 'settled';
|
||
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
|
||
if (status === 'PUBLISHED' && !isPreMatchKickoff(startTime)) return 'closed_pending';
|
||
return 'open';
|
||
}
|
||
|
||
private resolvePlayerMatchPhase(
|
||
status: string,
|
||
m: {
|
||
status?: string;
|
||
startTime: Date;
|
||
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
|
||
},
|
||
): 'open' | 'closed_pending' | 'settled' {
|
||
if (status === 'SETTLED') return 'settled';
|
||
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
|
||
if (status === 'PUBLISHED' && !this.isMatchBettingOpen(m)) return 'closed_pending';
|
||
return 'open';
|
||
}
|
||
|
||
private isMatchBettingOpen(m: {
|
||
status?: string;
|
||
startTime: Date;
|
||
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
|
||
}): boolean {
|
||
if (m.status !== 'PUBLISHED') return false;
|
||
if (!isPreMatchKickoff(m.startTime)) return false;
|
||
return (m.markets ?? []).some(
|
||
(mk) => mk.status === 'OPEN' && mk.selections.some((s) => s.status === 'OPEN'),
|
||
);
|
||
}
|
||
|
||
private playerMarketInclude = {
|
||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||
include: {
|
||
selections: {
|
||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||
orderBy: { sortOrder: 'asc' as const },
|
||
},
|
||
},
|
||
orderBy: { sortOrder: 'asc' as const },
|
||
};
|
||
|
||
/** 仅 status 字段,用于列表页计算 bettingOpen,不返回给客户端 */
|
||
private playerMarketStatusInclude = {
|
||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||
select: {
|
||
status: true,
|
||
selections: { select: { status: true } },
|
||
},
|
||
};
|
||
|
||
async listPublished(
|
||
locale = 'en-US',
|
||
leagueId?: bigint,
|
||
options?: ListPublishedOptions,
|
||
) {
|
||
const includeMarkets = options?.includeMarkets ?? true;
|
||
const parlayMarketsOnly = options?.parlayMarketsOnly ?? false;
|
||
|
||
const marketWhere = {
|
||
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
|
||
showOnPlayer: true,
|
||
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
|
||
};
|
||
|
||
const marketsRelation = includeMarkets
|
||
? {
|
||
where: marketWhere,
|
||
include: {
|
||
selections: {
|
||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||
orderBy: { sortOrder: 'asc' as const },
|
||
},
|
||
},
|
||
orderBy: { sortOrder: 'asc' as const },
|
||
}
|
||
: this.playerMarketStatusInclude;
|
||
|
||
const matches = await this.prisma.match.findMany({
|
||
where: {
|
||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
|
||
isOutright: false,
|
||
sportType: 'FOOTBALL',
|
||
deletedAt: null,
|
||
league: { isActive: true, deletedAt: null },
|
||
...(leagueId ? { leagueId } : {}),
|
||
},
|
||
include: {
|
||
league: true,
|
||
homeTeam: true,
|
||
awayTeam: true,
|
||
score: true,
|
||
markets: marketsRelation,
|
||
},
|
||
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
||
});
|
||
|
||
const enriched = await Promise.all(
|
||
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
|
||
);
|
||
|
||
if (!includeMarkets || !parlayMarketsOnly) return enriched;
|
||
|
||
return enriched.filter(
|
||
(m) =>
|
||
Array.isArray((m as { markets?: unknown[] }).markets) &&
|
||
(m as { markets: unknown[] }).markets.length > 0,
|
||
);
|
||
}
|
||
|
||
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot) */
|
||
async listUpcomingPublished(
|
||
locale = 'en-US',
|
||
options?: { limit?: number; days?: number },
|
||
) {
|
||
const limit = options?.limit ?? 50;
|
||
const days = options?.days ?? 3;
|
||
const now = new Date();
|
||
const end = new Date(now);
|
||
end.setDate(end.getDate() + days);
|
||
|
||
const matches = await this.prisma.match.findMany({
|
||
where: {
|
||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
|
||
isOutright: false,
|
||
sportType: 'FOOTBALL',
|
||
deletedAt: null,
|
||
startTime: { gte: now, lte: end },
|
||
league: { isActive: true, deletedAt: null },
|
||
},
|
||
include: {
|
||
league: true,
|
||
homeTeam: true,
|
||
awayTeam: true,
|
||
score: true,
|
||
markets: this.playerMarketStatusInclude,
|
||
},
|
||
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
|
||
take: limit,
|
||
});
|
||
|
||
return Promise.all(
|
||
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
|
||
);
|
||
}
|
||
|
||
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', 'PENDING_SETTLEMENT', 'SETTLED'] },
|
||
league: { isActive: true, deletedAt: null },
|
||
},
|
||
include: {
|
||
league: true,
|
||
homeTeam: true,
|
||
awayTeam: true,
|
||
markets: this.playerMarketInclude,
|
||
score: true,
|
||
},
|
||
});
|
||
if (!match) throw appNotFound('MATCH_NOT_FOUND');
|
||
return this.enrichMatch(match, locale);
|
||
}
|
||
|
||
async getSelectionsOdds(ids: bigint[]) {
|
||
const selections = await this.prisma.marketSelection.findMany({
|
||
where: { id: { in: ids } },
|
||
include: { market: { include: { match: true } } },
|
||
});
|
||
|
||
return selections.map((sel) => ({
|
||
id: sel.id.toString(),
|
||
odds: sel.odds.toString(),
|
||
oddsVersion: sel.oddsVersion.toString(),
|
||
status: sel.status,
|
||
marketStatus: sel.market.status,
|
||
marketShowOnPlayer: sel.market.showOnPlayer,
|
||
matchStatus: sel.market.match.status,
|
||
matchId: sel.market.match.id.toString(),
|
||
}));
|
||
}
|
||
|
||
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 defaultMarketName(marketType, locale);
|
||
return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
|
||
}
|
||
|
||
async enrichBetsForHistory(
|
||
bets: Array<{
|
||
betNo: string;
|
||
betType: string;
|
||
stake: unknown;
|
||
totalOdds: unknown;
|
||
potentialReturn: unknown;
|
||
actualReturn: unknown;
|
||
status: string;
|
||
placedAt: Date;
|
||
isCashbacked?: boolean;
|
||
selections: Array<{
|
||
matchId: bigint | null;
|
||
marketType: string;
|
||
marketNameSnapshot?: string | null;
|
||
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, score: true },
|
||
})
|
||
: [];
|
||
|
||
const matchMeta = new Map<
|
||
string,
|
||
{
|
||
leagueName: string;
|
||
matchTitle: string;
|
||
isOutright: boolean;
|
||
matchPhase: 'open' | 'closed_pending' | 'settled';
|
||
score: { ht: string | null; ft: string | null } | null;
|
||
}
|
||
>();
|
||
|
||
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),
|
||
]);
|
||
const s = m.score;
|
||
const ftScore =
|
||
s?.ftHomeScore != null && s?.ftAwayScore != null
|
||
? `${s.ftHomeScore}-${s.ftAwayScore}`
|
||
: null;
|
||
const htScore =
|
||
s?.htHomeScore != null && s?.htAwayScore != null
|
||
? `${s.htHomeScore}-${s.htAwayScore}`
|
||
: null;
|
||
matchMeta.set(m.id.toString(), {
|
||
leagueName,
|
||
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
|
||
isOutright: m.isOutright,
|
||
matchPhase: this.resolvePlayerMatchPhaseFromStatus(m.status, m.startTime),
|
||
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
|
||
});
|
||
}
|
||
|
||
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: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
|
||
selectionName: sel.selectionNameSnapshot,
|
||
odds: sel.odds,
|
||
resultStatus: sel.resultStatus,
|
||
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
|
||
leagueName: m?.leagueName ?? '',
|
||
score: m?.score ?? null,
|
||
};
|
||
});
|
||
|
||
const firstScore = meta?.score ?? legs[0]?.score ?? null;
|
||
|
||
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,
|
||
isCashbacked: bet.isCashbacked ?? false,
|
||
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,
|
||
matchScore: isParlay ? null : firstScore,
|
||
matchPhase: isParlay ? null : meta?.matchPhase ?? null,
|
||
};
|
||
});
|
||
}
|
||
|
||
@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 },
|
||
});
|
||
}
|
||
}
|