- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持 - 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮 - API 新增联赛列表与子场查询,按 locale 返回队名并修复编译 - 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
267 lines
8.1 KiB
TypeScript
267 lines
8.1 KiB
TypeScript
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
|
import { Decimal } from '@prisma/client/runtime/library';
|
|
import {
|
|
FT_CORRECT_SCORE_TEMPLATE,
|
|
HT_CORRECT_SCORE_TEMPLATE,
|
|
} from '../settlement/domain/settlement-calculator';
|
|
|
|
@Injectable()
|
|
export class MarketsService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
async generateTemplates(matchId: bigint, marketTypes: string[]) {
|
|
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
|
|
if (!match) throw new NotFoundException('Match not found');
|
|
|
|
const created = [];
|
|
|
|
for (const marketType of marketTypes) {
|
|
const existing = await this.prisma.market.findFirst({
|
|
where: { matchId, marketType },
|
|
});
|
|
if (existing) continue;
|
|
|
|
const config = this.getMarketConfig(marketType);
|
|
const market = await this.prisma.market.create({
|
|
data: {
|
|
matchId,
|
|
marketType,
|
|
period: config.period,
|
|
lineValue: config.lineValue,
|
|
allowSingle: true,
|
|
allowParlay: config.allowParlay,
|
|
sortOrder: config.sortOrder,
|
|
selections: {
|
|
create: config.selections.map((s, i) => ({
|
|
selectionCode: s.code,
|
|
selectionName: s.name,
|
|
odds: s.odds ?? 1.01,
|
|
sortOrder: i,
|
|
})),
|
|
},
|
|
},
|
|
include: { selections: true },
|
|
});
|
|
created.push(market);
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
|
|
const sideLabel = side === 'home' ? '主队' : '客队';
|
|
const value = side === 'home' ? line : -line;
|
|
const lineText = value > 0 ? `+${value}` : `${value}`;
|
|
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
|
|
}
|
|
|
|
private formatOuName(side: 'over' | 'under', line: number, half = false) {
|
|
const sideLabel = side === 'over' ? '大' : '小';
|
|
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
|
|
}
|
|
|
|
private formatScoreName(code: string) {
|
|
return code.replace('SCORE_', '').replace('_', '-');
|
|
}
|
|
|
|
private getMarketConfig(marketType: string) {
|
|
const configs: Record<string, {
|
|
period: string;
|
|
lineValue?: number;
|
|
allowParlay: boolean;
|
|
sortOrder: number;
|
|
selections: Array<{ code: string; name: string; odds?: number }>;
|
|
}> = {
|
|
FT_1X2: {
|
|
period: 'FT',
|
|
allowParlay: true,
|
|
sortOrder: 1,
|
|
selections: [
|
|
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
|
{ code: 'DRAW', name: '和', odds: 3.2 },
|
|
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
|
],
|
|
},
|
|
HT_1X2: {
|
|
period: 'HT',
|
|
allowParlay: true,
|
|
sortOrder: 5,
|
|
selections: [
|
|
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
|
|
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
|
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
|
|
],
|
|
},
|
|
FT_HANDICAP: {
|
|
period: 'FT',
|
|
lineValue: -0.5,
|
|
allowParlay: true,
|
|
sortOrder: 2,
|
|
selections: [
|
|
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
|
|
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
|
|
],
|
|
},
|
|
HT_HANDICAP: {
|
|
period: 'HT',
|
|
lineValue: -0.5,
|
|
allowParlay: true,
|
|
sortOrder: 6,
|
|
selections: [
|
|
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
|
|
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
|
|
],
|
|
},
|
|
FT_OVER_UNDER: {
|
|
period: 'FT',
|
|
lineValue: 2.5,
|
|
allowParlay: true,
|
|
sortOrder: 3,
|
|
selections: [
|
|
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
|
|
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
|
|
],
|
|
},
|
|
HT_OVER_UNDER: {
|
|
period: 'HT',
|
|
lineValue: 1.5,
|
|
allowParlay: true,
|
|
sortOrder: 7,
|
|
selections: [
|
|
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
|
|
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
|
|
],
|
|
},
|
|
FT_ODD_EVEN: {
|
|
period: 'FT',
|
|
allowParlay: true,
|
|
sortOrder: 4,
|
|
selections: [
|
|
{ code: 'ODD', name: '单', odds: 1.9 },
|
|
{ code: 'EVEN', name: '双', odds: 1.9 },
|
|
],
|
|
},
|
|
FT_CORRECT_SCORE: {
|
|
period: 'FT',
|
|
allowParlay: true,
|
|
sortOrder: 8,
|
|
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
|
code,
|
|
name: this.formatScoreName(code),
|
|
odds: 8.0,
|
|
})),
|
|
},
|
|
HT_CORRECT_SCORE: {
|
|
period: 'HT',
|
|
allowParlay: true,
|
|
sortOrder: 9,
|
|
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
|
code,
|
|
name: this.formatScoreName(code),
|
|
odds: 6.0,
|
|
})),
|
|
},
|
|
SH_CORRECT_SCORE: {
|
|
period: 'SH',
|
|
allowParlay: true,
|
|
sortOrder: 10,
|
|
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
|
code,
|
|
name: this.formatScoreName(code),
|
|
odds: 6.0,
|
|
})),
|
|
},
|
|
OUTRIGHT_WINNER: {
|
|
period: 'OUTRIGHT',
|
|
allowParlay: false,
|
|
sortOrder: 1,
|
|
selections: [],
|
|
},
|
|
};
|
|
|
|
const config = configs[marketType];
|
|
if (!config) throw new BadRequestException(`Unknown market type: ${marketType}`);
|
|
return config;
|
|
}
|
|
|
|
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
|
|
const selection = await this.prisma.marketSelection.findUnique({
|
|
where: { id: selectionId },
|
|
});
|
|
if (!selection) throw new NotFoundException('Selection not found');
|
|
if (newOdds <= 1) throw new BadRequestException('Odds must be > 1.00');
|
|
|
|
const newVersion = selection.oddsVersion + BigInt(1);
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await tx.oddsChangeLog.create({
|
|
data: {
|
|
selectionId,
|
|
oldOdds: selection.odds,
|
|
newOdds,
|
|
oddsVersion: newVersion,
|
|
changedBy: operatorId,
|
|
},
|
|
});
|
|
|
|
return tx.marketSelection.update({
|
|
where: { id: selectionId },
|
|
data: { odds: newOdds, oddsVersion: newVersion },
|
|
});
|
|
});
|
|
}
|
|
|
|
async batchUpdateOdds(
|
|
updates: Array<{ selectionId: bigint; odds: number }>,
|
|
operatorId: bigint,
|
|
) {
|
|
const results = [];
|
|
for (const u of updates) {
|
|
results.push(await this.updateOdds(u.selectionId, u.odds, operatorId));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async updateMarket(
|
|
marketId: bigint,
|
|
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
|
|
) {
|
|
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
|
|
if (!market) throw new NotFoundException('Market not found');
|
|
|
|
return this.prisma.market.update({
|
|
where: { id: marketId },
|
|
data: {
|
|
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
|
|
...(data.status !== undefined ? { status: data.status } : {}),
|
|
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateSelection(
|
|
selectionId: bigint,
|
|
data: { selectionName?: string; odds?: number; status?: string },
|
|
operatorId?: bigint,
|
|
) {
|
|
const selection = await this.prisma.marketSelection.findUnique({
|
|
where: { id: selectionId },
|
|
});
|
|
if (!selection) throw new NotFoundException('Selection not found');
|
|
|
|
if (data.odds != null) {
|
|
if (!operatorId) throw new BadRequestException('Operator required for odds update');
|
|
return this.updateOdds(selectionId, data.odds, operatorId);
|
|
}
|
|
|
|
return this.prisma.marketSelection.update({
|
|
where: { id: selectionId },
|
|
data: {
|
|
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
|
|
...(data.status !== undefined ? { status: data.status } : {}),
|
|
},
|
|
});
|
|
}
|
|
}
|