|
|
|
|
@@ -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({
|
|
|
|
|
|