From e46961113805039b4ef363e20e3adfef025dad7f Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Thu, 11 Jun 2026 17:49:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(settlement):=20=E8=A6=81=E6=B1=82=E5=B0=81?= =?UTF-8?q?=E7=9B=98=E5=90=8E=E6=89=8D=E8=83=BD=E7=BB=93=E7=AE=97=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 封盘前禁止录入比分与生成预览;待结算未确认前可解除封盘。 Co-authored-by: Cursor --- apps/admin/src/i18n/admin-pages-ms.ts | 3 +- apps/admin/src/i18n/admin-pages.ts | 6 +- apps/admin/src/views/Settlement.vue | 10 + .../src/views/matches/LeagueMatchesPanel.vue | 6 +- .../applications/admin/admin.controller.ts | 22 +-- .../src/domains/catalog/matches.service.ts | 12 +- .../smoke-tests/smoke-test.bet-flow-probes.ts | 19 +- .../domains/settlement/settlement.service.ts | 176 +++++++++++++++--- packages/shared/src/api-errors.ts | 10 + 9 files changed, 210 insertions(+), 54 deletions(-) diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 991d59c..98fd1c8 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -523,10 +523,11 @@ export const adminPagesMs: Record = { 'settlement.ht_score': 'Skor separuh masa', 'settlement.ft_score': 'Skor penuh masa', 'settlement.record_score': 'Simpan skor', - 'settlement.preview_hint': 'Isi skor dan klik pratonton — skor disimpan secara automatik', + 'settlement.preview_hint': 'Pratonton menukar perlawanan ke menunggu penyelesaian (skor disimpan selepas pengesahan; boleh buka semula sebelum itu)', 'settlement.preview_btn': 'Pratonton penyelesaian', 'settlement.preview_failed': 'Gagal menjana pratonton penyelesaian', 'settlement.err_score_not_recorded': 'Sila masukkan skor separuh masa dan penuh masa sebelum penyelesaian', + 'settlement.must_close_first': 'Tutup pertaruhan sebelum penyelesaian', 'settlement.preview_title': 'Pratonton penyelesaian', 'settlement.single_count': 'Pertaruhan tunggal', 'settlement.est_payout': 'Anggaran bayaran', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 10a3379..a5e8b16 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -546,10 +546,11 @@ export const adminPagesZh: Record = { 'settlement.ht_score': '半场比分', 'settlement.ft_score': '全场比分', 'settlement.record_score': '录入比分', - 'settlement.preview_hint': '填写比分后点击生成预览,系统将自动保存并计算派彩', + 'settlement.preview_hint': '填写比分后点击生成预览,赛事将进入待结算并计算派彩(正式比分在确认结算后保存;未确认前仍可解除封盘)', 'settlement.preview_btn': '生成结算预览', 'settlement.preview_failed': '生成结算预览失败', 'settlement.err_score_not_recorded': '请先填写半场与全场比分后再生成预览', + 'settlement.must_close_first': '请先封盘后再结算', 'settlement.preview_title': '结算预览', 'settlement.single_count': '单关注单数', 'settlement.preview_pending_bets': '待结算注单', @@ -1516,10 +1517,11 @@ export const adminPagesEn: Record = { 'settlement.ht_score': 'Half-time score', 'settlement.ft_score': 'Full-time score', 'settlement.record_score': 'Save score', - 'settlement.preview_hint': 'Enter scores and click preview — scores are saved automatically', + 'settlement.preview_hint': 'Preview moves the match to pending settlement and calculates payouts (scores are saved on confirm; you can reopen betting before confirming)', 'settlement.preview_btn': 'Preview settlement', 'settlement.preview_failed': 'Failed to generate settlement preview', 'settlement.err_score_not_recorded': 'Enter half-time and full-time scores before preview', + 'settlement.must_close_first': 'Close betting before settlement', 'settlement.preview_title': 'Settlement preview', 'settlement.single_count': 'Single bets', 'settlement.preview_pending_bets': 'Pending bets', diff --git a/apps/admin/src/views/Settlement.vue b/apps/admin/src/views/Settlement.vue index 6f1353d..8c13d97 100644 --- a/apps/admin/src/views/Settlement.vue +++ b/apps/admin/src/views/Settlement.vue @@ -300,6 +300,15 @@ async function loadMatch() { router.replace('/matches'); return; } + const settleable = + detail.status === 'CLOSED' || + detail.status === 'PENDING_SETTLEMENT' || + detail.status === 'SETTLED'; + if (!settleable) { + ElMessage.warning(t('settlement.must_close_first')); + router.replace('/matches'); + return; + } match.value = detail; if (detail.score) { score.value = { ...detail.score }; @@ -372,6 +381,7 @@ async function previewSettlement() { const itemsPage = data.data.items as PreviewItemsPage; previewPage.value = itemsPage.page; previewPageSize.value = itemsPage.pageSize; + await loadMatch(); } catch (e: unknown) { ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); } finally { diff --git a/apps/admin/src/views/matches/LeagueMatchesPanel.vue b/apps/admin/src/views/matches/LeagueMatchesPanel.vue index aca360a..c490f2d 100644 --- a/apps/admin/src/views/matches/LeagueMatchesPanel.vue +++ b/apps/admin/src/views/matches/LeagueMatchesPanel.vue @@ -166,10 +166,12 @@ function canCloseRow(row: unknown) { return matchStatus(row) === 'PUBLISHED'; } function canReopenRow(row: unknown) { - return matchStatus(row) === 'CLOSED'; + const s = matchStatus(row); + return s === 'CLOSED' || s === 'PENDING_SETTLEMENT'; } function canSettleRow(row: unknown) { - return matchStatus(row) !== 'DRAFT'; + const s = matchStatus(row); + return s === 'CLOSED' || s === 'PENDING_SETTLEMENT' || s === 'SETTLED'; } function settleButtonLabel(row: unknown) { return matchStatus(row) === 'SETTLED' ? t('common.resettle') : t('common.settle'); diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 73d09ee..42fddc0 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -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, }); diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 6924825..2617ab9 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -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'); diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts index 4074719..4fe111b 100644 --- a/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts @@ -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); } diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts index 8cf672a..d312575 100644 --- a/apps/api/src/domains/settlement/settlement.service.ts +++ b/apps/api/src/domains/settlement/settlement.service.ts @@ -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(); 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 }, diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts index 49cd738..75368cd 100644 --- a/packages/shared/src/api-errors.ts +++ b/packages/shared/src/api-errors.ts @@ -129,6 +129,16 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Match cannot be reopened in current status', 'ms-MY': 'Perlawanan tidak boleh dibuka semula dalam status semasa', }, + MATCH_NOT_SETTLEABLE: { + 'zh-CN': '当前状态不可确认结算', + 'en-US': 'Match cannot be settled in current status', + 'ms-MY': 'Perlawanan tidak boleh diselesaikan dalam status semasa', + }, + MATCH_MUST_CLOSE_FOR_SETTLEMENT: { + 'zh-CN': '请先封盘后再结算', + 'en-US': 'Close betting before settlement', + 'ms-MY': 'Tutup pertaruhan sebelum penyelesaian', + }, MATCH_REOPEN_KICKOFF_REQUIRED: { 'zh-CN': '开赛时间已过,请设置新的未来开赛时间', 'en-US': 'Kickoff has passed; set a new future start time',