import { Injectable } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error'; import { MatchBetStatsService } from './match-bet-stats.service'; const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']); export type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH'; export type MatchArchivePreview = { matchId: string; matchStatus: string; isOutright: boolean; title: string; pendingBetCount: number; pendingStake: string; hasPreviewSettlementBatch: boolean; requiresForce: boolean; warnings: MatchArchiveWarning[]; }; export type LeagueBlockingMatch = { id: string; status: string; isOutright: boolean; title: string; pendingCount: number; }; export type LeagueArchivePreview = { leagueId: string; canArchive: boolean; blockingMatches: LeagueBlockingMatch[]; totalPendingBets: number; }; @Injectable() export class CatalogArchiveService { constructor( private prisma: PrismaService, private matchBetStats: MatchBetStatsService, ) {} async getMatchArchivePreview(matchId: bigint): Promise { const match = await this.requireActiveMatch(matchId); const [pending, hasPreviewBatch] = await Promise.all([ this.pendingBetSummary(matchId), this.hasPreviewBatch(matchId), ]); const warnings = this.buildMatchWarnings(match.status, pending.pendingBetCount, hasPreviewBatch); const requiresForce = warnings.length > 0; const title = await this.matchTitle(match); return { matchId: match.id.toString(), matchStatus: match.status, isOutright: match.isOutright, title, pendingBetCount: pending.pendingBetCount, pendingStake: pending.pendingStake, hasPreviewSettlementBatch: hasPreviewBatch, requiresForce, warnings, }; } async archiveMatch(matchId: bigint, opts: { force: boolean }) { const match = await this.requireActiveMatch(matchId); if (match.status === 'DRAFT') { throw appBadRequest('MATCH_DELETE_DRAFT_ONLY'); } if (match.status === 'SETTLED') { throw appBadRequest('ARCHIVE_BLOCKED'); } const preview = await this.getMatchArchivePreview(matchId); if (preview.requiresForce && !opts.force) { throw appConflict('ARCHIVE_BLOCKED', preview); } const now = new Date(); await this.prisma.$transaction(async (tx) => { await tx.marketSelection.updateMany({ where: { market: { matchId } }, data: { status: 'CLOSED' }, }); await tx.market.updateMany({ where: { matchId }, data: { status: 'CLOSED' }, }); await tx.match.update({ where: { id: matchId }, data: { deletedAt: now, status: match.status === 'CANCELLED' || match.status === 'VOID' ? match.status : 'CANCELLED', }, }); }); return { matchId: matchId.toString(), archivedAt: now.toISOString() }; } async getLeagueArchivePreview(leagueId: bigint): Promise { const league = await this.prisma.league.findFirst({ where: { id: leagueId, deletedAt: null }, }); if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const matches = await this.prisma.match.findMany({ where: { leagueId, deletedAt: null }, include: { homeTeam: true, awayTeam: true }, orderBy: [{ isOutright: 'desc' }, { id: 'asc' }], }); const matchIds = matches.map((m) => m.id); const stats = await this.matchBetStats.betStatsForMatches(matchIds); const previewBatches = matchIds.length ? await this.prisma.settlementBatch.findMany({ where: { matchId: { in: matchIds }, status: 'PREVIEW' }, select: { matchId: true }, }) : []; const previewBatchMatchIds = new Set(previewBatches.map((b) => b.matchId?.toString())); const blockingMatches: LeagueBlockingMatch[] = []; let totalPendingBets = 0; for (const match of matches) { const mid = match.id.toString(); const stat = stats.get(mid) ?? { betCount: 0, totalStake: '0', pendingCount: 0 }; totalPendingBets += stat.pendingCount; const hasPreview = previewBatchMatchIds.has(mid); const blocks = this.isLeagueMatchBlocking(match.status, stat.betCount, stat.pendingCount, hasPreview); if (blocks) { blockingMatches.push({ id: mid, status: match.status, isOutright: match.isOutright, title: await this.matchTitle(match), pendingCount: stat.pendingCount, }); } } if (totalPendingBets > 0 && !blockingMatches.length) { // Pending bets exist but each match might be terminal — still block league archive for (const match of matches) { const mid = match.id.toString(); const stat = stats.get(mid)!; if (stat.pendingCount > 0) { blockingMatches.push({ id: mid, status: match.status, isOutright: match.isOutright, title: await this.matchTitle(match), pendingCount: stat.pendingCount, }); } } } const canArchive = blockingMatches.length === 0 && totalPendingBets === 0; return { leagueId: leagueId.toString(), canArchive, blockingMatches, totalPendingBets, }; } async archiveLeague(leagueId: bigint) { const league = await this.prisma.league.findFirst({ where: { id: leagueId, deletedAt: null }, }); if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const preview = await this.getLeagueArchivePreview(leagueId); if (!preview.canArchive) { throw appConflict('LEAGUE_ARCHIVE_NOT_READY', preview); } const now = new Date(); await this.prisma.$transaction(async (tx) => { const matches = await tx.match.findMany({ where: { leagueId, deletedAt: null }, select: { id: true, status: true }, }); const matchIds = matches.map((m) => m.id); if (matchIds.length) { await tx.marketSelection.updateMany({ where: { market: { matchId: { in: matchIds } } }, data: { status: 'CLOSED' }, }); await tx.market.updateMany({ where: { matchId: { in: matchIds } }, data: { status: 'CLOSED' }, }); await tx.match.updateMany({ where: { id: { in: matchIds } }, data: { deletedAt: now }, }); } await tx.league.update({ where: { id: leagueId }, data: { deletedAt: now, isActive: false }, }); }); return { leagueId: leagueId.toString(), archivedAt: now.toISOString() }; } private async requireActiveMatch(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; } private async pendingBetSummary(matchId: bigint) { const bets = await this.prisma.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId } } }, select: { stake: true }, }); let pendingStake = new Decimal(0); for (const bet of bets) { pendingStake = pendingStake.add(bet.stake); } return { pendingBetCount: bets.length, pendingStake: pendingStake.toString(), }; } private async hasPreviewBatch(matchId: bigint) { const batch = await this.prisma.settlementBatch.findFirst({ where: { matchId, status: 'PREVIEW' }, select: { id: true }, }); return batch != null; } private buildMatchWarnings( status: string, pendingBetCount: number, hasPreviewBatch: boolean, ): MatchArchiveWarning[] { const warnings: MatchArchiveWarning[] = []; if (pendingBetCount > 0) warnings.push('PENDING_BETS'); if (!TERMINAL_MATCH_STATUSES.has(status) && status !== 'DRAFT') { warnings.push('UNSETTLED_MATCH'); } if (hasPreviewBatch) warnings.push('PREVIEW_BATCH'); return warnings; } private isLeagueMatchBlocking( status: string, betCount: number, pendingCount: number, hasPreviewBatch: boolean, ): boolean { if (pendingCount > 0) return true; if (hasPreviewBatch) return true; if (TERMINAL_MATCH_STATUSES.has(status)) return false; if (status === 'DRAFT' && betCount === 0) return false; return true; } private async matchTitle(match: { id: bigint; isOutright: boolean; matchName: string | null; homeTeamId: bigint; awayTeamId: bigint; homeTeam?: { code: string } | null; awayTeam?: { code: string } | null; }) { if (match.isOutright) { const name = match.matchName?.trim(); if (name) return name; return `Outright #${match.id}`; } const [home, away] = await Promise.all([ this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'), this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'), ]); if (home && away) return `${home} vs ${away}`; return `${match.homeTeam?.code ?? '?'} vs ${match.awayTeam?.code ?? '?'}`; } 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 ?? ''; } }