fix(settlement): 要求封盘后才能结算并优化预览流程

封盘前禁止录入比分与生成预览;待结算未确认前可解除封盘。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:49:34 +08:00
parent 03e72ca9b2
commit e469611138
9 changed files with 210 additions and 54 deletions

View File

@@ -1925,24 +1925,12 @@ export class AdminController {
@Body() dto?: SettlementPreviewDto,
) {
const matchId = BigInt(id);
const hasScore =
dto?.htHome !== undefined ||
dto?.htAway !== undefined ||
dto?.ftHome !== undefined ||
dto?.ftAway !== undefined ||
dto?.winnerTeamId !== undefined;
if (hasScore) {
await this.settlement.recordScore(
matchId,
dto!.htHome ?? 0,
dto!.htAway ?? 0,
dto!.ftHome ?? 0,
dto!.ftAway ?? 0,
operatorId,
dto!.winnerTeamId != null ? BigInt(dto!.winnerTeamId) : undefined,
);
}
const preview = await this.settlement.previewSettlement(matchId, operatorId, {
htHome: dto?.htHome,
htAway: dto?.htAway,
ftHome: dto?.ftHome,
ftAway: dto?.ftAway,
winnerTeamId: dto?.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
page: dto?.page ? Math.max(1, dto.page) : 1,
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
});

View File

@@ -1104,11 +1104,21 @@ export class MatchesService {
async reopenMatch(matchId: bigint, startTime?: Date) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
if (match.status !== 'CLOSED') throw appBadRequest('MATCH_NOT_REOPENABLE');
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');

View File

@@ -27,15 +27,16 @@ async function confirmMatchSettlement(
fx: BetFlowFixtureIds,
score: { htHome: number; htAway: number; ftHome: number; ftAway: number },
) {
await deps.settlement.recordScore(
fx.matchId,
score.htHome,
score.htAway,
score.ftHome,
score.ftAway,
fx.operatorId,
);
const preview = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId);
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
const preview = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: score.htHome,
htAway: score.htAway,
ftHome: score.ftHome,
ftAway: score.ftAway,
});
await deps.settlement.confirmSettlement(preview.batch.id, fx.operatorId);
}

View File

@@ -17,6 +17,8 @@ import {
templateScoresForMarket,
} from './domain/settlement-helpers';
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
type BetSelectionLeg = {
id: bigint;
matchId: bigint | null;
@@ -84,6 +86,12 @@ export class SettlementService {
return 'WON';
}
private assertMatchClosedForSettlement(status: string) {
if (!SETTLEMENT_ENTRY_STATUSES.has(status)) {
throw appBadRequest('MATCH_MUST_CLOSE_FOR_SETTLEMENT');
}
}
async recordScore(
matchId: bigint,
htHome: number,
@@ -97,6 +105,7 @@ export class SettlementService {
where: { id: matchId, deletedAt: null },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
if (!winnerTeamId) {
@@ -147,20 +156,32 @@ export class SettlementService {
async previewSettlement(
matchId: bigint,
operatorId: bigint,
opts?: { page?: number; pageSize?: number },
opts?: {
page?: number;
pageSize?: number;
htHome?: number;
htAway?: number;
ftHome?: number;
ftAway?: number;
winnerTeamId?: bigint;
},
) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw appBadRequest('SCORE_NOT_RECORDED');
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
const computation = await this.computePreviewComputation(matchId);
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
const computation = await this.computePreviewComputation(matchId, scoreSource);
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
batchNo: generateBatchNo('STL'),
htHomeScore: score.htHomeScore,
htAwayScore: score.htAwayScore,
ftHomeScore: score.ftHomeScore,
ftAwayScore: score.ftAwayScore,
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
status: 'PREVIEW',
totalBets: computation.pendingBets.length,
totalPayout: computation.totalPayout,
@@ -169,6 +190,13 @@ export class SettlementService {
},
});
if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') {
await this.prisma.match.update({
where: { id: matchId },
data: { status: 'PENDING_SETTLEMENT' },
});
}
return this.buildPreviewResponse(computation, batch, opts);
}
@@ -182,7 +210,16 @@ export class SettlementService {
throw appBadRequest('SETTLEMENT_BATCH_NOT_PREVIEW');
}
const computation = await this.computePreviewComputation(batch.matchId);
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const computation = await this.computePreviewComputation(batch.matchId, {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
winnerTeamId: existingScore?.winnerTeamId ?? null,
});
const itemsPage = this.paginatePreviewItems(computation.items, opts);
return {
items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
@@ -269,17 +306,90 @@ export class SettlementService {
};
}
private async computePreviewComputation(matchId: bigint) {
private async resolvePreviewScoreSource(
matchId: bigint,
isOutright: boolean,
opts?: {
htHome?: number;
htAway?: number;
ftHome?: number;
ftAway?: number;
winnerTeamId?: bigint;
},
) {
const hasRequestScore =
opts?.htHome !== undefined ||
opts?.htAway !== undefined ||
opts?.ftHome !== undefined ||
opts?.ftAway !== undefined ||
opts?.winnerTeamId !== undefined;
if (hasRequestScore) {
if (isOutright) {
if (!opts?.winnerTeamId) throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
const team = await this.prisma.team.findUnique({ where: { id: opts.winnerTeamId } });
if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND');
const outrightSel = await this.prisma.marketSelection.findFirst({
where: {
market: { matchId, marketType: 'OUTRIGHT_WINNER' },
selectionCode: team.code,
},
});
if (!outrightSel) throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
}
return {
htHome: opts?.htHome ?? 0,
htAway: opts?.htAway ?? 0,
ftHome: opts?.ftHome ?? 0,
ftAway: opts?.ftAway ?? 0,
winnerTeamId: isOutright ? (opts?.winnerTeamId ?? null) : null,
};
}
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw appBadRequest('SCORE_NOT_RECORDED');
const scoreInput: ScoreInput = {
return {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
winnerTeamId: score.winnerTeamId,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
}
private async computePreviewComputation(
matchId: bigint,
scoreSource?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: bigint | null;
},
) {
let scoreInput: ScoreInput;
let winnerTeamCode: string | null;
if (scoreSource) {
scoreInput = {
htHome: scoreSource.htHome,
htAway: scoreSource.htAway,
ftHome: scoreSource.ftHome,
ftAway: scoreSource.ftAway,
};
winnerTeamCode = await this.resolveWinnerTeamCode(scoreSource.winnerTeamId ?? null);
} else {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw appBadRequest('SCORE_NOT_RECORDED');
scoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
}
const pendingBets = await this.prisma.bet.findMany({
where: {
@@ -487,19 +597,20 @@ export class SettlementService {
});
if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const score = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
if (!score) throw appBadRequest('SCORE_NOT_FOUND');
if (batch.match.status !== 'PENDING_SETTLEMENT') {
throw appBadRequest('MATCH_NOT_SETTLEABLE');
}
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const pendingBets = await this.prisma.bet.findMany({
where: {
@@ -513,6 +624,26 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
const sel = bet.selections[0];
@@ -710,6 +841,7 @@ export class SettlementService {
where: { id: matchId, deletedAt: null },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
const legs = await this.prisma.betSelection.findMany({
where: { matchId },