feat(admin+api+player): 优胜赛结算态、禁止新增单场与钱包流水展示优化
- API/管理端:优胜赛 SETTLED 后禁止新增单场,列表与子页展示结算状态 - 玩家端:已结算 outright 只读展示并高亮冠军 - 管理端:结算后 stale 标记驱动列表刷新;财务流水时间与备注 i18n 优化 - shared:txDisplayAmount 与 LEAGUE_OUTRIGHT_SETTLED 错误码
This commit is contained in:
@@ -306,7 +306,7 @@ describe('MatchesService listAdminLeagueMatches', () => {
|
||||
const leagueId = BigInt(1);
|
||||
|
||||
let prisma: {
|
||||
match: { findMany: jest.Mock; count: jest.Mock };
|
||||
match: { findMany: jest.Mock; findFirst: jest.Mock; count: jest.Mock };
|
||||
entityTranslation: { findFirst: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
@@ -314,7 +314,11 @@ describe('MatchesService listAdminLeagueMatches', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
match: { findMany: jest.fn(), count: jest.fn() },
|
||||
match: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn(),
|
||||
},
|
||||
entityTranslation: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -387,3 +391,38 @@ describe('MatchesService listAdminLeagueMatches', () => {
|
||||
expect(result.items[1].id).toBe('11');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatchesService createMatch outright guard', () => {
|
||||
const leagueId = BigInt(1);
|
||||
|
||||
it('rejects fixture creation when outright is settled', async () => {
|
||||
const prisma = {
|
||||
match: { create: jest.fn() },
|
||||
};
|
||||
const outright = {
|
||||
assertLeagueAllowsNewFixtures: jest.fn().mockRejectedValue(
|
||||
Object.assign(new Error('LEAGUE_OUTRIGHT_SETTLED'), {
|
||||
response: { code: 'LEAGUE_OUTRIGHT_SETTLED' },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new MatchesService(
|
||||
prisma as never,
|
||||
outright as never,
|
||||
{ betStatsForMatches: jest.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.createMatch({
|
||||
leagueId,
|
||||
homeTeamId: BigInt(10),
|
||||
awayTeamId: BigInt(11),
|
||||
startTime: new Date('2026-06-01T12:00:00Z'),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'LEAGUE_OUTRIGHT_SETTLED' }),
|
||||
});
|
||||
expect(outright.assertLeagueAllowsNewFixtures).toHaveBeenCalledWith(leagueId);
|
||||
expect(prisma.match.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,7 @@ export class MatchesService {
|
||||
externalStatus: string;
|
||||
}>;
|
||||
}) {
|
||||
await this.outright.assertLeagueAllowsNewFixtures(data.leagueId);
|
||||
const status = data.status ?? 'DRAFT';
|
||||
return this.prisma.match.create({
|
||||
data: {
|
||||
@@ -492,8 +493,11 @@ export class MatchesService {
|
||||
isOutright: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true, leagueId: true },
|
||||
select: { id: true, leagueId: true, status: true },
|
||||
});
|
||||
const outrightStatusByLeague = new Map(
|
||||
outrightMatches.map((m) => [m.leagueId.toString(), m.status]),
|
||||
);
|
||||
const outrightTeamCounts = new Map<string, number>();
|
||||
if (outrightMatches.length > 0) {
|
||||
const matchIdToLeagueId = new Map(
|
||||
@@ -543,6 +547,10 @@ export class MatchesService {
|
||||
fixtureTeamSets.get(item.id)?.size ?? 0;
|
||||
(item as { outrightTeamCount?: number }).outrightTeamCount =
|
||||
outrightTeamCounts.get(item.id) ?? 0;
|
||||
const outrightStatus = outrightStatusByLeague.get(item.id) ?? null;
|
||||
(item as { outrightStatus?: string | null }).outrightStatus = outrightStatus;
|
||||
(item as { isOutrightSettled?: boolean }).isOutrightSettled =
|
||||
outrightStatus === 'SETTLED';
|
||||
}
|
||||
|
||||
return { items, total, page: opts.page, pageSize: opts.pageSize };
|
||||
@@ -562,6 +570,16 @@ export class MatchesService {
|
||||
startTo?: Date;
|
||||
},
|
||||
) {
|
||||
const outrightMatch = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
select: { status: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const leagueMeta = {
|
||||
outrightStatus: outrightMatch?.status ?? null,
|
||||
isOutrightSettled: outrightMatch?.status === 'SETTLED',
|
||||
};
|
||||
|
||||
const where: Prisma.MatchWhereInput = {
|
||||
leagueId,
|
||||
deletedAt: null,
|
||||
@@ -648,7 +666,7 @@ export class MatchesService {
|
||||
|
||||
const total = filteredItems.length;
|
||||
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
|
||||
return { items: paginatedItems, total, page, pageSize };
|
||||
return { items: paginatedItems, total, page, pageSize, league: leagueMeta };
|
||||
}
|
||||
|
||||
const orderBy =
|
||||
@@ -698,10 +716,8 @@ export class MatchesService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
return { items, total, page, pageSize, league: leagueMeta };
|
||||
}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
|
||||
@@ -259,6 +259,18 @@ export class OutrightService {
|
||||
await this.syncOutrightStatusWithLeague(existing, league);
|
||||
}
|
||||
|
||||
/** 优胜赛(冠军盘)已结算时禁止再新增单场 */
|
||||
async assertLeagueAllowsNewFixtures(leagueId: bigint) {
|
||||
const outright = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
select: { status: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (outright?.status === 'SETTLED') {
|
||||
throw appBadRequest('LEAGUE_OUTRIGHT_SETTLED');
|
||||
}
|
||||
}
|
||||
|
||||
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
|
||||
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
|
||||
return this.prisma.match.count({
|
||||
@@ -288,7 +300,9 @@ export class OutrightService {
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!match) return { addedCount: 0, reopenedCount: 0 };
|
||||
if (!match || match.status === 'SETTLED') {
|
||||
return { addedCount: 0, reopenedCount: 0 };
|
||||
}
|
||||
return this.syncSelectionsFromLeagueFixtures(match.id);
|
||||
}
|
||||
|
||||
@@ -687,18 +701,19 @@ export class OutrightService {
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
status: { in: ['PUBLISHED', 'SETTLED'] },
|
||||
isOutright: true,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
league: { isActive: true, deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
score: true,
|
||||
markets: {
|
||||
where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' },
|
||||
where: { marketType: OUTRIGHT_MARKET_TYPE },
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: 'OPEN' },
|
||||
where: { selectionCode: { not: PLACEHOLDER_TEAM_CODE } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
@@ -716,10 +731,24 @@ export class OutrightService {
|
||||
const market = match.markets[0];
|
||||
if (!market) continue;
|
||||
|
||||
const isSettled = match.status === 'SETTLED';
|
||||
const visibleSelections = isSettled
|
||||
? market.selections
|
||||
: market.selections.filter((sel) => sel.status === 'OPEN');
|
||||
|
||||
if (!visibleSelections.length) continue;
|
||||
|
||||
let winnerTeamCode: string | null = null;
|
||||
if (match.score?.winnerTeamId) {
|
||||
const winner = await this.prisma.team.findUnique({
|
||||
where: { id: match.score.winnerTeamId },
|
||||
select: { code: true },
|
||||
});
|
||||
winnerTeamCode = winner?.code ?? null;
|
||||
}
|
||||
|
||||
const selections = await Promise.all(
|
||||
market.selections
|
||||
.filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE)
|
||||
.map(async (sel) => {
|
||||
visibleSelections.map(async (sel) => {
|
||||
const team = await this.prisma.team.findUnique({
|
||||
where: { code: sel.selectionCode },
|
||||
});
|
||||
@@ -743,12 +772,11 @@ export class OutrightService {
|
||||
logoUrl: team?.logoUrl ?? null,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
isWinner: Boolean(winnerTeamCode && sel.selectionCode === winnerTeamCode),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!selections.length) continue;
|
||||
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(match.id, 'zh-CN'),
|
||||
this.getOutrightTitle(match.id, 'en-US'),
|
||||
@@ -764,6 +792,11 @@ export class OutrightService {
|
||||
match.matchName?.trim() ||
|
||||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
|
||||
|
||||
const bettingOpen =
|
||||
match.status === 'PUBLISHED' &&
|
||||
market.status === 'OPEN' &&
|
||||
market.selections.some((sel) => sel.status === 'OPEN');
|
||||
|
||||
results.push({
|
||||
id: match.id.toString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -771,6 +804,9 @@ export class OutrightService {
|
||||
leagueName: leagueName || '',
|
||||
title: title.startsWith('*') ? title : `*${title}`,
|
||||
marketId: market.id.toString(),
|
||||
status: match.status,
|
||||
bettingOpen,
|
||||
winnerTeamCode,
|
||||
selectionCount: selections.length,
|
||||
selections,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user