303 lines
9.4 KiB
TypeScript
303 lines
9.4 KiB
TypeScript
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<MatchArchivePreview> {
|
|
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<LeagueArchivePreview> {
|
|
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 ?? '';
|
|
}
|
|
}
|