import { Injectable } 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'; import { appBadRequest, appNotFound } from '../../shared/common/app-error'; @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 appNotFound('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; }> = { 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 appBadRequest('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 appNotFound('SELECTION_NOT_FOUND'); if (newOdds <= 1) throw appBadRequest('ODDS_MIN'); 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 appNotFound('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 appNotFound('SELECTION_NOT_FOUND'); if (data.odds != null) { if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED'); 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 } : {}), }, }); } }