feat(admin,api,player): 结算预览分页、统计图表与返水限额

完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 13:54:33 +08:00
parent 6264b8806c
commit efff7c27e6
40 changed files with 3560 additions and 578 deletions

View File

@@ -110,9 +110,40 @@ describe('SettlementCalculator', () => {
});
expect(r).toBe('HALF_LOSE');
});
it('0-0: home -0.5 loses', () => {
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
expect(
settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -0.5,
score: s,
}),
).toBe('LOSE');
});
});
describe('Over/Under', () => {
it('0-0: under 2.5 wins, over 2.5 loses', () => {
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
const under = settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'UNDER',
totalLine: 2.5,
score: s,
});
const over = settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2.5,
score: s,
});
expect(under).toBe('WIN');
expect(over).toBe('LOSE');
expect(calculatePayout(100, 1.95, under).toNumber()).toBe(195);
});
it('S013: over 2.5 wins with 3 goals', () => {
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
expect(
@@ -177,6 +208,27 @@ describe('SettlementCalculator', () => {
});
});
describe('OUTRIGHT_WINNER', () => {
it('wins when selection matches winner team code', () => {
expect(
settleSelection({
marketType: 'OUTRIGHT_WINNER',
selectionCode: 'BRA',
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
winnerTeamCode: 'BRA',
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'OUTRIGHT_WINNER',
selectionCode: 'ARG',
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
winnerTeamCode: 'BRA',
}),
).toBe('LOSE');
});
});
describe('Quarter line detection', () => {
it('detects quarter lines', () => {
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);

View File

@@ -16,6 +16,8 @@ export interface SettlementInput {
totalLine?: number | null;
score: ScoreInput;
templateScores?: string[];
/** 冠军盘:获胜球队 code如 FRA、BRA */
winnerTeamCode?: string | null;
}
export function getShScore(score: ScoreInput): { home: number; away: number } {
@@ -196,7 +198,8 @@ export function settleSelection(input: SettlementInput): SelectionResult {
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
}
case 'OUTRIGHT_WINNER':
return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE';
if (!input.winnerTeamCode) return 'LOSE';
return selectionCode === input.winnerTeamCode ? 'WIN' : 'LOSE';
}
return 'LOSE';

View File

@@ -0,0 +1,14 @@
import { resolveSelectionCode } from './settlement-helpers';
describe('resolveSelectionCode', () => {
it('prefers database selection code', () => {
expect(resolveSelectionCode('UNDER', '小 2.5')).toBe('UNDER');
});
it('maps Chinese snapshot names when code is missing', () => {
expect(resolveSelectionCode(null, '主胜')).toBe('HOME');
expect(resolveSelectionCode('', '小 2.5')).toBe('UNDER');
expect(resolveSelectionCode(undefined, '大 2.5')).toBe('OVER');
expect(resolveSelectionCode(null, '主 -0.5')).toBe('HOME');
});
});

View File

@@ -0,0 +1,37 @@
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from './settlement-calculator';
const SNAPSHOT_1X2: Record<string, string> = {
: 'HOME',
: 'AWAY',
: 'DRAW',
: 'DRAW',
};
/** 盘口 code 缺失时,从下单快照名推断标准 selectionCode */
export function resolveSelectionCode(
selectionCode: string | null | undefined,
nameSnapshot: string,
): string {
if (selectionCode?.trim()) return selectionCode.trim();
const name = nameSnapshot.trim();
if (SNAPSHOT_1X2[name]) return SNAPSHOT_1X2[name];
if (name.startsWith('大')) return 'OVER';
if (name.startsWith('小')) return 'UNDER';
if (name.startsWith('主')) return 'HOME';
if (name.startsWith('客')) return 'AWAY';
if (name.includes('-')) {
return `SCORE_${name.replace('-', '_')}`;
}
return name;
}
export function templateScoresForMarket(marketType: string): string[] {
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
return [];
}

View File

@@ -9,22 +9,25 @@ import {
calculatePayout,
calculateParlayPayout,
ScoreInput,
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
SelectionResult,
} from './domain/settlement-calculator';
// 智能比分推荐已关闭
// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver';
import {
resolveSelectionCode,
templateScoresForMarket,
} from './domain/settlement-helpers';
function resolveSelectionCodeFromLeg(
selectionCode: string | null | undefined,
nameSnapshot: string,
): string {
if (selectionCode?.trim()) return selectionCode.trim();
if (nameSnapshot.includes('-')) {
return `SCORE_${nameSnapshot.replace('-', '_')}`;
}
return nameSnapshot;
}
type BetSelectionLeg = {
id: bigint;
matchId: bigint | null;
marketType: string;
selectionId: bigint;
selectionNameSnapshot: string;
handicapLine: Decimal | null;
totalLine: Decimal | null;
odds: Decimal;
resultStatus: string | null;
sortOrder: number;
};
@Injectable()
export class SettlementService {
@@ -34,6 +37,52 @@ export class SettlementService {
private agents: AgentsService,
) {}
private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise<string | null> {
if (!winnerTeamId) return null;
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
return team?.code ?? null;
}
private buildSettleInput(
sel: BetSelectionLeg,
selectionCode: string,
scoreInput: ScoreInput,
winnerTeamCode: string | null,
) {
return {
marketType: sel.marketType,
selectionCode,
handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine != null ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores: templateScoresForMarket(sel.marketType),
winnerTeamCode,
};
}
private settleLegResult(
sel: BetSelectionLeg,
selectionCode: string,
scoreInput: ScoreInput,
winnerTeamCode: string | null,
): SelectionResult {
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode));
}
private walletResultFromSelection(
result: SelectionResult,
): 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE' {
if (result === 'HALF_WIN') return 'HALF_WIN';
if (result === 'HALF_LOSE') return 'HALF_LOSE';
return result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID';
}
private betStatusFromSelection(result: SelectionResult): 'LOST' | 'PUSH' | 'WON' {
if (result === 'LOSE') return 'LOST';
if (result === 'PUSH' || result === 'VOID') return 'PUSH';
return 'WON';
}
async recordScore(
matchId: bigint,
htHome: number,
@@ -41,11 +90,49 @@ export class SettlementService {
ftHome: number,
ftAway: number,
operatorId: bigint,
winnerTeamId?: bigint,
) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw new NotFoundException('Match not found');
if (match.isOutright) {
if (!winnerTeamId) {
throw new BadRequestException('冠军盘结算需指定获胜球队 winnerTeamId');
}
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
if (!team) throw new BadRequestException('获胜球队不存在');
const outrightSel = await this.prisma.marketSelection.findFirst({
where: {
market: { matchId, marketType: 'OUTRIGHT_WINNER' },
selectionCode: team.code,
},
});
if (!outrightSel) {
throw new BadRequestException('该球队不在本冠军盘选项中');
}
}
await this.prisma.matchScore.upsert({
where: { matchId },
create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
create: {
matchId,
htHomeScore: match.isOutright ? 0 : htHome,
htAwayScore: match.isOutright ? 0 : htAway,
ftHomeScore: match.isOutright ? 0 : ftHome,
ftAwayScore: match.isOutright ? 0 : ftAway,
winnerTeamId: match.isOutright ? winnerTeamId : null,
recordedBy: operatorId,
},
update: {
htHomeScore: match.isOutright ? 0 : htHome,
htAwayScore: match.isOutright ? 0 : htAway,
ftHomeScore: match.isOutright ? 0 : ftHome,
ftAwayScore: match.isOutright ? 0 : ftAway,
winnerTeamId: match.isOutright ? winnerTeamId : null,
recordedBy: operatorId,
},
});
await this.prisma.match.update({
@@ -53,75 +140,18 @@ export class SettlementService {
data: { status: 'PENDING_SETTLEMENT' },
});
return { matchId, htHome, htAway, ftHome, ftAway };
return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null };
}
async previewSettlement(matchId: bigint, operatorId: bigint) {
async previewSettlement(
matchId: bigint,
operatorId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw new BadRequestException('Score not recorded');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId } },
},
include: { selections: true },
});
const parlayBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
betType: 'PARLAY',
selections: { some: { matchId } },
},
include: { selections: true },
});
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
const items: Array<{ betId: bigint; betNo: string; result: string; payout: Decimal }> = [];
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE') {
const sel = bet.selections[0];
const template =
sel.marketType === 'FT_CORRECT_SCORE'
? FT_CORRECT_SCORE_TEMPLATE
: sel.marketType.includes('CORRECT_SCORE')
? HT_CORRECT_SCORE_TEMPLATE
: [];
const result = settleSelection({
marketType: sel.marketType,
selectionCode: sel.selectionNameSnapshot.includes('-')
? `SCORE_${sel.selectionNameSnapshot.replace('-', '_')}`
: sel.selectionNameSnapshot,
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores: template,
});
const payout = calculatePayout(bet.stake, sel.odds, result);
items.push({ betId: bet.id, betNo: bet.betNo, result, payout });
if (result === 'LOSE') {
// no payout
} else if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
} else {
totalPayout = totalPayout.add(payout);
}
}
}
const computation = await this.computePreviewComputation(matchId);
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
@@ -131,24 +161,324 @@ export class SettlementService {
ftHomeScore: score.ftHomeScore,
ftAwayScore: score.ftAwayScore,
status: 'PREVIEW',
totalBets: pendingBets.length,
totalPayout,
totalRefund,
totalBets: computation.pendingBets.length,
totalPayout: computation.totalPayout,
totalRefund: computation.totalRefund,
operatorId,
},
});
return this.buildPreviewResponse(computation, batch, opts);
}
async getPreviewSettlementItems(
batchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException('Batch not found');
if (batch.status !== 'PREVIEW') {
throw new BadRequestException('Batch is not in preview');
}
const computation = await this.computePreviewComputation(batch.matchId);
const itemsPage = this.paginatePreviewItems(computation.items, opts);
return {
items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
total: itemsPage.total,
page: itemsPage.page,
pageSize: itemsPage.pageSize,
};
}
private buildPreviewResponse(
computation: {
scoreInput: ScoreInput;
winnerTeamCode: string | null;
pendingBets: Array<{ betType: string }>;
items: Array<{
betId: bigint;
betNo: string;
betType: string;
result: string;
payout: Decimal;
note?: string;
}>;
totalPayout: Decimal;
totalRefund: Decimal;
lostOnThisMatch: number;
pendingOtherMatches: number;
wonLegsOnMatch: number;
},
batch: { id: bigint },
opts?: { page?: number; pageSize?: number },
) {
const { pendingBets } = computation;
const itemsPage = this.paginatePreviewItems(computation.items, opts);
return {
batch,
score: scoreInput,
score: computation.scoreInput,
winnerTeamCode: computation.winnerTeamCode,
pendingBetCount: pendingBets.length,
singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length,
parlayBetCount: parlayBets.length,
parlayBetCount: pendingBets.filter((b) => b.betType === 'PARLAY').length,
lostOnThisMatch: computation.lostOnThisMatch,
pendingOtherMatches: computation.pendingOtherMatches,
wonLegsOnMatch: computation.wonLegsOnMatch,
items: {
items: itemsPage.items.map((item) => this.serializePreviewItem(item)),
total: itemsPage.total,
page: itemsPage.page,
pageSize: itemsPage.pageSize,
},
totalPayout: computation.totalPayout.toString(),
totalRefund: computation.totalRefund.toString(),
};
}
private serializePreviewItem(item: {
betId: bigint;
betNo: string;
betType: string;
result: string;
payout: Decimal;
note?: string;
}) {
return {
betId: item.betId.toString(),
betNo: item.betNo,
betType: item.betType,
result: item.result,
payout: item.payout.toString(),
note: item.note,
};
}
private paginatePreviewItems<T>(all: T[], opts?: { page?: number; pageSize?: number }) {
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
const total = all.length;
const start = (page - 1) * pageSize;
return {
items: all.slice(start, start + pageSize),
total,
page,
pageSize,
};
}
private async computePreviewComputation(matchId: bigint) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw new BadRequestException('Score not recorded');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
const selectionCodes = await this.loadSelectionCodes(pendingBets);
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
let lostOnThisMatch = 0;
let pendingOtherMatches = 0;
let wonLegsOnMatch = 0;
const items: Array<{
betId: bigint;
betNo: string;
betType: string;
result: string;
payout: Decimal;
note?: string;
}> = [];
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
const sel = bet.selections[0];
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
} else if (result !== 'LOSE') {
totalPayout = totalPayout.add(payout);
}
} else if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
const legResults = bet.selections.map((sel) => {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
return {
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
};
});
const parlay = calculateParlayPayout(bet.stake, legResults);
items.push({
betId: bet.id,
betNo: bet.betNo,
betType: 'SINGLE',
result: parlay.betResult === 'LOST' ? 'LOSE' : parlay.betResult,
payout: parlay.payout,
note: '单关含多腿,按串关规则合并结算',
});
if (parlay.betResult === 'PUSH') {
totalRefund = totalRefund.add(bet.stake);
} else if (parlay.betResult === 'WON') {
totalPayout = totalPayout.add(parlay.payout);
}
} else {
for (const sel of bet.selections) {
if (sel.matchId?.toString() !== matchId.toString()) continue;
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
}
const preview = this.previewParlayForMatch(
bet,
matchId,
scoreInput,
winnerTeamCode,
selectionCodes,
);
if (preview.kind === 'SETTLED') {
items.push({
betId: bet.id,
betNo: bet.betNo,
betType: 'PARLAY',
result: preview.betResult,
payout: preview.payout,
});
if (preview.betResult === 'PUSH') {
totalRefund = totalRefund.add(bet.stake);
} else if (preview.betResult === 'WON') {
totalPayout = totalPayout.add(preview.payout);
}
} else if (preview.kind === 'LOST_ON_THIS_MATCH') {
lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
betType: 'PARLAY',
result: 'LOST',
payout: preview.payout,
note: '本场已有输腿,串关整单作废',
});
} else {
pendingOtherMatches += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
betType: 'PARLAY',
result: 'PENDING_OTHER_MATCHES',
payout: new Decimal(0),
note: '本场腿已出结果,待其他场次结算后派彩',
});
}
}
}
return {
scoreInput,
winnerTeamCode,
pendingBets,
items,
totalPayout,
totalRefund,
lostOnThisMatch,
pendingOtherMatches,
wonLegsOnMatch,
};
}
private previewParlayForMatch(
bet: { stake: Decimal; selections: BetSelectionLeg[] },
matchId: bigint,
scoreInput: ScoreInput,
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
):
| { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
| { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal }
| { kind: 'PENDING_OTHER_MATCHES' } {
for (const sel of bet.selections) {
if (sel.matchId?.toString() !== matchId.toString()) continue;
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
if (result === 'LOSE' || result === 'HALF_LOSE') {
return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
}
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
for (const sel of bet.selections) {
if (sel.matchId?.toString() === matchId.toString()) {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
legResults.push({
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
});
} else if (sel.resultStatus) {
legResults.push({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
});
} else {
return { kind: 'PENDING_OTHER_MATCHES' };
}
}
if (legResults.length !== bet.selections.length) {
return { kind: 'PENDING_OTHER_MATCHES' };
}
const settled = calculateParlayPayout(bet.stake, legResults);
return { kind: 'SETTLED', betResult: settled.betResult, payout: settled.payout };
}
private async loadSelectionCodes(
bets: Array<{ selections: Array<{ selectionId: bigint }> }>,
): Promise<Map<string, string | null>> {
const ids = new Set<bigint>();
for (const bet of bets) {
for (const sel of bet.selections) ids.add(sel.selectionId);
}
if (!ids.size) return new Map();
const rows = await this.prisma.marketSelection.findMany({
where: { id: { in: [...ids] } },
select: { id: true, selectionCode: true },
});
return new Map(rows.map((r) => [r.id.toString(), r.selectionCode]));
}
async confirmSettlement(batchId: bigint, operatorId: bigint) {
const batch = await this.prisma.settlementBatch.findUnique({
where: { id: batchId },
@@ -168,40 +498,30 @@ export class SettlementService {
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId: batch.matchId } },
},
include: { selections: true, user: true },
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
});
const selectionCodes = await this.loadSelectionCodes(pendingBets);
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE') {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
const sel = bet.selections[0];
const selection = await tx.marketSelection.findUnique({
where: { id: sel.selectionId },
});
const result = settleSelection({
marketType: sel.marketType,
selectionCode: selection?.selectionCode ?? sel.selectionNameSnapshot,
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores:
sel.marketType === 'FT_CORRECT_SCORE'
? FT_CORRECT_SCORE_TEMPLATE
: HT_CORRECT_SCORE_TEMPLATE,
});
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
const betStatus =
result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON';
const betStatus = this.betStatusFromSelection(result);
await tx.bet.update({
where: { id: bet.id },
@@ -222,7 +542,8 @@ export class SettlementService {
bet.stake,
payout,
bet.betNo,
result === 'HALF_WIN' ? 'HALF_WIN' : result === 'HALF_LOSE' ? 'HALF_LOSE' : result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID',
this.walletResultFromSelection(result),
tx,
);
if (bet.agentId) agentIds.add(bet.agentId);
@@ -236,20 +557,69 @@ export class SettlementService {
payout,
},
});
} else if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
for (const sel of bet.selections) {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
legResults.push({ odds: sel.odds, result });
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result, effectiveOdds: sel.odds },
});
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
await tx.bet.update({
where: { id: bet.id },
data: {
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
await this.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
tx,
);
if (bet.agentId) agentIds.add(bet.agentId);
await tx.settlementItem.create({
data: {
batchId,
betId: bet.id,
userId: bet.userId,
result: betStatus,
payout: parlayResult.payout,
},
});
} else {
// Parlay: update this leg's result, check if all legs settled
for (const sel of bet.selections) {
if (sel.matchId?.toString() === batch.matchId.toString()) {
const selection = await tx.marketSelection.findUnique({
where: { id: sel.selectionId },
});
const result = settleSelection({
marketType: sel.marketType,
selectionCode: selection?.selectionCode ?? '',
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
});
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result },
@@ -257,20 +627,29 @@ export class SettlementService {
}
}
const updated = await tx.betSelection.findMany({ where: { betId: bet.id } });
const updated = await tx.betSelection.findMany({
where: { betId: bet.id },
orderBy: { sortOrder: 'asc' },
});
const allHaveResult = updated.every((s) => s.resultStatus != null);
if (allHaveResult) {
const legResults = updated.map((s) => ({
odds: s.odds,
result: s.resultStatus as 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
result: s.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
await tx.bet.update({
where: { id: bet.id },
data: {
status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON',
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
@@ -281,10 +660,25 @@ export class SettlementService {
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN',
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
tx,
);
if (bet.agentId) agentIds.add(bet.agentId);
await tx.settlementItem.create({
data: {
batchId,
betId: bet.id,
userId: bet.userId,
result: betStatus,
payout: parlayResult.payout,
},
});
}
}
}
@@ -307,7 +701,10 @@ export class SettlementService {
return { success: true, batchId: batchId.toString() };
}
async getMatchBetStats(matchId: bigint) {
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
@@ -405,28 +802,50 @@ export class SettlementService {
return a.selectionName.localeCompare(b.selectionName);
});
const bets = Array.from(legs)
.map((leg) => ({
id: leg.bet.id.toString(),
betNo: leg.bet.betNo,
username: leg.bet.user.username,
betType: leg.bet.betType,
status: leg.bet.status,
settlementStatus: leg.bet.settlementStatus,
stake: leg.bet.stake.toString(),
potentialReturn: leg.bet.potentialReturn?.toString() ?? null,
actualReturn: leg.bet.actualReturn.toString(),
placedAt: leg.bet.placedAt.toISOString(),
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
const betsById = new Map<
string,
{
bet: (typeof legs)[0]['bet'];
matchLegs: (typeof legs);
}
>();
for (const leg of legs) {
const key = leg.betId.toString();
const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
row.matchLegs.push(leg);
betsById.set(key, row);
}
const allBets = Array.from(betsById.values())
.map(({ bet, matchLegs }) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: matchLegs[0].bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: matchLegs.length,
selections: matchLegs.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}))
.sort(
(a, b) =>
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
);
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
const total = allBets.length;
const start = (page - 1) * pageSize;
return {
summary: {
totalBets: betById.size,
@@ -438,13 +857,283 @@ export class SettlementService {
legCount: legs.length,
},
bySelection,
bets,
bets: {
items: allBets.slice(start, start + pageSize),
total,
page,
pageSize,
},
};
}
/* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import
async suggestSmartScores(...) { ... }
*/
private async computeBetOutcome(
bet: {
id: bigint;
betType: string;
stake: Decimal;
actualReturn: Decimal;
selections: BetSelectionLeg[];
},
matchId: bigint,
scoreInput: ScoreInput,
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
) {
if (bet.betType === 'SINGLE') {
const sel = bet.selections[0];
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
return {
payout,
betStatus: this.betStatusFromSelection(result),
legUpdates: new Map([[sel.id.toString(), result]]),
};
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
for (const sel of bet.selections) {
let result: SelectionResult;
if (sel.matchId?.toString() === matchId.toString()) {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
legUpdates.set(sel.id.toString(), result);
} else {
if (!sel.resultStatus) {
throw new BadRequestException(`Parlay bet ${bet.id} has unsettled legs`);
}
result = sel.resultStatus as SelectionResult;
}
legResults.push({ odds: sel.odds, result });
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
return { payout: parlayResult.payout, betStatus, legUpdates };
}
async previewResettlement(
matchId: bigint,
scoreInput: ScoreInput,
operatorId: bigint,
reason?: string,
winnerTeamId?: bigint,
) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw new NotFoundException('Match not found');
if (match.status !== 'SETTLED') {
throw new BadRequestException('Only settled matches can be resettled');
}
const winnerTeamCode = winnerTeamId
? await this.resolveWinnerTeamCode(winnerTeamId)
: null;
const settledBets = await this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
selections: { some: { matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
const selectionCodes = await this.loadSelectionCodes(settledBets);
const items: Array<{
betId: bigint;
betNo: string;
oldPayout: Decimal;
newPayout: Decimal;
delta: Decimal;
oldStatus: string;
newStatus: string;
}> = [];
let totalClawback = new Decimal(0);
let totalTopup = new Decimal(0);
for (const bet of settledBets) {
const oldPayout = new Decimal(bet.actualReturn);
const outcome = await this.computeBetOutcome(
bet,
matchId,
scoreInput,
winnerTeamCode,
selectionCodes,
);
const delta = outcome.payout.sub(oldPayout);
if (delta.eq(0) && outcome.betStatus === bet.status) continue;
items.push({
betId: bet.id,
betNo: bet.betNo,
oldPayout,
newPayout: outcome.payout,
delta,
oldStatus: bet.status,
newStatus: outcome.betStatus,
});
if (delta.gt(0)) totalTopup = totalTopup.add(delta);
else if (delta.lt(0)) totalClawback = totalClawback.add(delta.abs());
}
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
batchNo: generateBatchNo('RST'),
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
status: 'PREVIEW',
totalBets: items.length,
totalPayout: totalTopup,
totalRefund: totalClawback,
operatorId,
isResettle: true,
reason: reason?.trim() || null,
},
});
return {
batch,
score: scoreInput,
winnerTeamCode,
items,
totalClawback,
totalTopup,
affectedCount: items.length,
};
}
async confirmResettlement(batchId: bigint, operatorId: bigint) {
const batch = await this.prisma.settlementBatch.findUnique({
where: { id: batchId },
include: { match: true },
});
if (!batch) throw new NotFoundException('Batch not found');
if (!batch.isResettle) throw new BadRequestException('Not a resettle batch');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(
(
await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } })
)?.winnerTeamId,
);
const settledBets = await this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
selections: { some: { matchId: batch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
const selectionCodes = await this.loadSelectionCodes(settledBets);
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,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
for (const bet of settledBets) {
const oldPayout = new Decimal(bet.actualReturn);
const outcome = await this.computeBetOutcome(
bet,
batch.matchId,
scoreInput,
winnerTeamCode,
selectionCodes,
);
const delta = outcome.payout.sub(oldPayout);
if (delta.eq(0) && outcome.betStatus === bet.status) continue;
for (const [legId, result] of outcome.legUpdates) {
const leg = bet.selections.find((s) => s.id.toString() === legId);
await tx.betSelection.update({
where: { id: BigInt(legId) },
data: {
resultStatus: result,
effectiveOdds: leg?.odds ?? undefined,
},
});
}
await tx.bet.update({
where: { id: bet.id },
data: {
status: outcome.betStatus,
actualReturn: outcome.payout,
settlementStatus: 'RESETTLED',
},
});
await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);
if (bet.agentId) agentIds.add(bet.agentId);
await tx.settlementItem.create({
data: {
batchId,
betId: bet.id,
userId: bet.userId,
result: outcome.betStatus,
payout: outcome.payout,
},
});
}
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
});
});
for (const agentId of agentIds) {
await this.agents.recalculateUsedCredit(agentId);
}
return { success: true, batchId: batchId.toString() };
}
async voidMatchBets(matchId: bigint) {
const bets = await this.prisma.bet.findMany({