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) { 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) { 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, ) { 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(); 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>(); 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(); 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> { 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 = { '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[] = []; 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, 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>; }; 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>) ?? []).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; const labels: Record = { 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 }, }); } }