feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化
管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。 API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -247,6 +247,14 @@ class CreatePlatformMatchDto {
|
||||
@IsString()
|
||||
leagueMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
homeTeamCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
awayTeamCode?: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamEn!: string;
|
||||
|
||||
@@ -471,6 +479,11 @@ class AddOutrightSelectionDto {
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
class AddOutrightSelectionsBatchDto {
|
||||
@IsArray()
|
||||
items!: AddOutrightSelectionDto[];
|
||||
}
|
||||
|
||||
class UpdateOutrightSelectionTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -983,6 +996,15 @@ export class AdminController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('leagues/:leagueId/outright')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async getLeagueOutright(@Param('leagueId') leagueId: string) {
|
||||
const data = await this.outright.getOrCreateAndSyncForLeague(
|
||||
BigInt(leagueId),
|
||||
);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Get('leagues/:leagueId/matches')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listLeagueMatches(
|
||||
@@ -1096,6 +1118,8 @@ export class AdminController {
|
||||
leagueEn: dto.leagueEn ?? '',
|
||||
leagueZh: dto.leagueZh ?? '',
|
||||
leagueMs: dto.leagueMs,
|
||||
homeTeamCode: dto.homeTeamCode,
|
||||
awayTeamCode: dto.awayTeamCode,
|
||||
homeTeamEn: dto.homeTeamEn,
|
||||
homeTeamZh: dto.homeTeamZh,
|
||||
homeTeamMs: dto.homeTeamMs,
|
||||
@@ -1319,6 +1343,22 @@ export class AdminController {
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('outrights/:matchId/selections/batch')
|
||||
@RequirePermissions(P.matches)
|
||||
async addOutrightSelectionsBatch(
|
||||
@Param('matchId') matchId: string,
|
||||
@Body() dto: AddOutrightSelectionsBatchDto,
|
||||
) {
|
||||
if (!dto.items?.length) {
|
||||
throw new BadRequestException('At least one team required');
|
||||
}
|
||||
const data = await this.outright.addSelectionsBatch(
|
||||
BigInt(matchId),
|
||||
dto.items,
|
||||
);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Patch('outrights/:matchId/selections/:selectionId')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateOutrightSelectionTeam(
|
||||
|
||||
@@ -222,10 +222,28 @@ export class PlayerController {
|
||||
return jsonResponse({ ...result, items });
|
||||
}
|
||||
|
||||
@Get('bets/stats')
|
||||
async betStats(@CurrentUser('id') userId: bigint) {
|
||||
const stats = await this.bets.getUserBetStats(userId);
|
||||
return jsonResponse(stats);
|
||||
}
|
||||
|
||||
@Get('bets/:betNo')
|
||||
async betDetail(@CurrentUser('id') userId: bigint, @Param('betNo') betNo: string) {
|
||||
async betDetail(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('locale') locale: string,
|
||||
@Param('betNo') betNo: string,
|
||||
) {
|
||||
const bet = await this.bets.getBetByNo(betNo, userId);
|
||||
return jsonResponse(bet);
|
||||
if (!bet) return jsonResponse(null);
|
||||
const [enriched] = await this.matches.enrichBetsForHistory([bet], locale);
|
||||
return jsonResponse(enriched);
|
||||
}
|
||||
|
||||
@Get('wallet/transactions/stats')
|
||||
async transactionStats(@CurrentUser('id') userId: bigint) {
|
||||
const stats = await this.wallet.getTransactionStats(userId);
|
||||
return jsonResponse(stats);
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
|
||||
@@ -247,6 +247,35 @@ export class BetsService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getUserBetStats(userId: bigint) {
|
||||
const [byStatus, aggregates] = await Promise.all([
|
||||
this.prisma.bet.groupBy({
|
||||
by: ['status'],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
_sum: { stake: true, actualReturn: true, potentialReturn: true },
|
||||
}),
|
||||
this.prisma.bet.aggregate({
|
||||
where: { userId },
|
||||
_sum: { stake: true, actualReturn: true, potentialReturn: true },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
total: aggregates._count.id,
|
||||
totalStake: aggregates._sum.stake?.toString() ?? '0',
|
||||
totalReturn: aggregates._sum.actualReturn?.toString() ?? '0',
|
||||
totalPotentialReturn: aggregates._sum.potentialReturn?.toString() ?? '0',
|
||||
byStatus: byStatus.map((g) => ({
|
||||
status: g.status,
|
||||
count: g._count.id,
|
||||
totalStake: g._sum.stake?.toString() ?? '0',
|
||||
totalReturn: g._sum.actualReturn?.toString() ?? '0',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getBetByNo(betNo: string, userId?: bigint) {
|
||||
return this.prisma.bet.findFirst({
|
||||
where: { betNo, ...(userId ? { userId } : {}) },
|
||||
|
||||
@@ -262,31 +262,28 @@ export class MatchesService {
|
||||
const kw = opts.keyword?.trim();
|
||||
let idFilter: bigint[] | undefined;
|
||||
|
||||
if (kw || opts.status) {
|
||||
// 状态仅用于单场计数/展开列表筛选,不隐藏无该状态单场的联赛(含新建空联赛)
|
||||
if (kw) {
|
||||
const ids = new Set<bigint>();
|
||||
if (kw) {
|
||||
const trRows = await this.prisma.entityTranslation.findMany({
|
||||
where: {
|
||||
entityType: 'LEAGUE',
|
||||
fieldName: 'name',
|
||||
value: { contains: kw, mode: 'insensitive' },
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
for (const r of trRows) ids.add(r.entityId);
|
||||
}
|
||||
const trRows = await this.prisma.entityTranslation.findMany({
|
||||
where: {
|
||||
entityType: 'LEAGUE',
|
||||
fieldName: 'name',
|
||||
value: { contains: kw, mode: 'insensitive' },
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
for (const r of trRows) ids.add(r.entityId);
|
||||
const matchWhere: Prisma.MatchWhereInput = {
|
||||
deletedAt: null,
|
||||
isOutright: false,
|
||||
};
|
||||
if (opts.status) matchWhere.status = opts.status;
|
||||
if (kw) {
|
||||
matchWhere.OR = [
|
||||
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 },
|
||||
@@ -351,6 +348,27 @@ export class MatchesService {
|
||||
},
|
||||
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),
|
||||
);
|
||||
@@ -373,6 +391,50 @@ export class MatchesService {
|
||||
}
|
||||
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) {
|
||||
@@ -382,6 +444,10 @@ export class MatchesService {
|
||||
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 };
|
||||
@@ -501,11 +567,37 @@ export class MatchesService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async upsertTeamByCode(data: {
|
||||
code: string;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
teamMs?: string;
|
||||
logoUrl?: string;
|
||||
}) {
|
||||
const code = data.code.trim().toUpperCase();
|
||||
if (!code) throw new BadRequestException('请填写球队代码');
|
||||
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;
|
||||
@@ -563,32 +655,56 @@ export class MatchesService {
|
||||
});
|
||||
}
|
||||
}
|
||||
const homeTeam = await this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh || homeMs,
|
||||
names: {
|
||||
zh: homeZh || null,
|
||||
en: homeEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: homeMs || null,
|
||||
},
|
||||
image: data.homeTeamLogoUrl?.trim() || '',
|
||||
});
|
||||
const awayTeam = await this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh || awayMs,
|
||||
names: {
|
||||
zh: awayZh || null,
|
||||
en: awayEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: awayMs || null,
|
||||
},
|
||||
image: data.awayTeamLogoUrl?.trim() || '',
|
||||
});
|
||||
const homeCode = data.homeTeamCode?.trim().toUpperCase();
|
||||
const awayCode = data.awayTeamCode?.trim().toUpperCase();
|
||||
let homeTeam;
|
||||
let awayTeam;
|
||||
if (homeCode && awayCode) {
|
||||
if (homeCode === awayCode) {
|
||||
throw new BadRequestException('主客队不能为同一支球队,请选择不同的队伍');
|
||||
}
|
||||
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 new BadRequestException('主客队不能为同一支球队,请填写不同的队名');
|
||||
@@ -1199,13 +1315,18 @@ export class MatchesService {
|
||||
matchIds.length > 0
|
||||
? await this.prisma.match.findMany({
|
||||
where: { id: { in: matchIds } },
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
include: { homeTeam: true, awayTeam: true, score: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const matchMeta = new Map<
|
||||
string,
|
||||
{ leagueName: string; matchTitle: string; isOutright: boolean }
|
||||
{
|
||||
leagueName: string;
|
||||
matchTitle: string;
|
||||
isOutright: boolean;
|
||||
score: { ht: string | null; ft: string | null } | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const m of matches) {
|
||||
@@ -1214,10 +1335,20 @@ export class MatchesService {
|
||||
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,
|
||||
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1237,9 +1368,12 @@ export class MatchesService {
|
||||
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,
|
||||
@@ -1261,6 +1395,7 @@ export class MatchesService {
|
||||
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
|
||||
legs,
|
||||
isParlay,
|
||||
matchScore: isParlay ? null : firstScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +168,11 @@ export class OutrightService {
|
||||
this.getOutrightTitle(match.id, 'ms-MY'),
|
||||
]);
|
||||
|
||||
const addableFixtureTeams = await this.listAddableFixtureTeams(
|
||||
match.leagueId,
|
||||
fullMarket.id,
|
||||
);
|
||||
|
||||
return {
|
||||
id: match.id.toString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -187,9 +192,146 @@ export class OutrightService {
|
||||
playerVisible: visibility.playerVisible,
|
||||
playerHiddenReason: visibility.playerHiddenReason,
|
||||
selections,
|
||||
addableFixtureTeams,
|
||||
};
|
||||
}
|
||||
|
||||
private async listAddableFixtureTeams(leagueId: bigint, marketId: bigint) {
|
||||
const teams = await this.collectFixtureTeamsForLeague(leagueId);
|
||||
const openCodes = new Set(
|
||||
(
|
||||
await this.prisma.marketSelection.findMany({
|
||||
where: {
|
||||
marketId,
|
||||
status: 'OPEN',
|
||||
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
|
||||
},
|
||||
select: { selectionCode: true },
|
||||
})
|
||||
).map((s) => s.selectionCode),
|
||||
);
|
||||
|
||||
const result: Array<{
|
||||
teamCode: string;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
logoUrl: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const team of teams) {
|
||||
if (openCodes.has(team.code)) continue;
|
||||
const [teamZh, teamEn] = await Promise.all([
|
||||
this.getTranslation('TEAM', team.id, 'zh-CN'),
|
||||
this.getTranslation('TEAM', team.id, 'en-US'),
|
||||
]);
|
||||
result.push({
|
||||
teamCode: team.code,
|
||||
teamZh: teamZh || team.code,
|
||||
teamEn: teamEn || team.code,
|
||||
logoUrl: team.logoUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
|
||||
async getOrCreateAndSyncForLeague(leagueId: bigint) {
|
||||
let match = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!match) {
|
||||
const league = await this.prisma.league.findUnique({
|
||||
where: { id: leagueId },
|
||||
});
|
||||
if (!league) throw new NotFoundException('League not found');
|
||||
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
|
||||
this.getTranslation('LEAGUE', leagueId, 'en-US'),
|
||||
this.getTranslation('LEAGUE', leagueId, 'ms-MY'),
|
||||
]);
|
||||
await this.createForAdmin({
|
||||
leagueId,
|
||||
titleZh: leagueZh || league.code,
|
||||
titleEn: leagueEn || league.code,
|
||||
titleMs: leagueMs || undefined,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
match = await this.prisma.match.findFirstOrThrow({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
}
|
||||
return this.syncSelectionsFromLeagueFixtures(match.id);
|
||||
}
|
||||
|
||||
async syncSelectionsFromLeagueFixtures(matchId: bigint) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
const teams = await this.collectFixtureTeamsForLeague(match.leagueId);
|
||||
const fixtureCodes = new Set(teams.map((t) => t.code));
|
||||
|
||||
const existing = await this.prisma.marketSelection.findMany({
|
||||
where: {
|
||||
marketId: market.id,
|
||||
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
|
||||
},
|
||||
});
|
||||
|
||||
// 仅当球队重新出现在单场赛程时,恢复曾被关闭的选项;不因「暂无单场」而自动关闭
|
||||
for (const sel of existing) {
|
||||
if (fixtureCodes.has(sel.selectionCode) && sel.status === 'CLOSED') {
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingCodes = new Set(existing.map((s) => s.selectionCode));
|
||||
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
|
||||
|
||||
for (const team of teams) {
|
||||
if (existingCodes.has(team.code)) continue;
|
||||
const [teamZh, teamEn] = await Promise.all([
|
||||
this.getTranslation('TEAM', team.id, 'zh-CN'),
|
||||
this.getTranslation('TEAM', team.id, 'en-US'),
|
||||
]);
|
||||
sortOrder += 1;
|
||||
await this.prisma.marketSelection.create({
|
||||
data: {
|
||||
marketId: market.id,
|
||||
selectionCode: team.code,
|
||||
selectionName: teamZh || teamEn || team.code,
|
||||
odds: 10,
|
||||
sortOrder,
|
||||
status: 'OPEN',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
|
||||
private async collectFixtureTeamsForLeague(leagueId: bigint) {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: { leagueId, isOutright: false, deletedAt: null },
|
||||
select: { homeTeamId: true, awayTeamId: true },
|
||||
});
|
||||
const teamIds = [
|
||||
...new Set(matches.flatMap((m) => [m.homeTeamId, m.awayTeamId])),
|
||||
];
|
||||
if (teamIds.length === 0) return [];
|
||||
return this.prisma.team.findMany({
|
||||
where: {
|
||||
id: { in: teamIds },
|
||||
code: { not: PLACEHOLDER_TEAM_CODE },
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createForAdmin(data: {
|
||||
leagueId: bigint;
|
||||
titleZh: string;
|
||||
@@ -325,6 +467,17 @@ export class OutrightService {
|
||||
where: { marketId: market.id, selectionCode: code },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === 'CLOSED') {
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'OPEN',
|
||||
odds: data.odds,
|
||||
selectionName: data.teamZh.trim() || data.teamEn,
|
||||
},
|
||||
});
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
throw new BadRequestException('Selection already exists for this team code');
|
||||
}
|
||||
|
||||
@@ -347,6 +500,38 @@ export class OutrightService {
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
|
||||
async addSelectionsBatch(
|
||||
matchId: bigint,
|
||||
items: Array<{
|
||||
teamCode: string;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
odds: number;
|
||||
logoUrl?: string;
|
||||
}>,
|
||||
) {
|
||||
if (!items.length) {
|
||||
throw new BadRequestException('At least one team required');
|
||||
}
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const item of items) {
|
||||
try {
|
||||
await this.addSelection(matchId, item);
|
||||
added += 1;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '';
|
||||
if (msg.includes('already exists')) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const data = await this.getForAdmin(matchId);
|
||||
return { ...data, batchResult: { added, skipped } };
|
||||
}
|
||||
|
||||
async updateSelectionTeam(
|
||||
matchId: bigint,
|
||||
selectionId: bigint,
|
||||
|
||||
@@ -272,4 +272,30 @@ export class WalletService {
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getTransactionStats(userId: bigint) {
|
||||
const [aggregates, byType] = await Promise.all([
|
||||
this.prisma.walletTransaction.aggregate({
|
||||
where: { userId },
|
||||
_sum: { amount: true },
|
||||
_count: { id: true },
|
||||
}),
|
||||
this.prisma.walletTransaction.groupBy({
|
||||
by: ['transactionType'],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
total: aggregates._count.id,
|
||||
netAmount: aggregates._sum.amount?.toString() ?? '0',
|
||||
byType: byType.map((g) => ({
|
||||
transactionType: g.transactionType,
|
||||
count: g._count.id,
|
||||
totalAmount: g._sum.amount?.toString() ?? '0',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user