This commit is contained in:
Executable
+238
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Req,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CouponsService } from './coupons.service';
|
||||
import { MatchesService } from '../matches/matches.service';
|
||||
import { SmartCouponService } from './services/smart-coupon.service';
|
||||
import {
|
||||
UserCouponService,
|
||||
CreateCouponDto,
|
||||
} from './services/user-coupon.service';
|
||||
import {
|
||||
AnalyzeMatchDto,
|
||||
DailyBankoDto,
|
||||
SuggestCouponDto,
|
||||
} from './dto/coupons-request.dto';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { JwtAuthGuard } from '../auth/guards/auth.guards'; // Assuming standard guard
|
||||
import { Sport } from '../matches/dto';
|
||||
|
||||
@ApiTags('Coupon')
|
||||
@Controller('coupon')
|
||||
export class CouponsController {
|
||||
private readonly logger = new Logger(CouponsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly couponsService: CouponsService,
|
||||
private readonly smartCouponService: SmartCouponService,
|
||||
private readonly userCouponService: UserCouponService,
|
||||
private readonly matchesService: MatchesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /coupon/analyze-match
|
||||
* Analyze a single match with V20+ single-match package
|
||||
*/
|
||||
@Post('analyze-match')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Analyze single match with V20 model' })
|
||||
@ApiResponse({ status: 200, description: 'Match analysis' })
|
||||
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
|
||||
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
||||
if (!analysis) {
|
||||
return { success: false, message: 'Analiz yapılamadı.' };
|
||||
}
|
||||
return { success: true, data: analysis };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /coupon/analyze (alias for /analyze-match - frontend compatibility)
|
||||
*/
|
||||
@Post('analyze')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Analyze single match with V20 model (alias)',
|
||||
deprecated: true,
|
||||
})
|
||||
async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) {
|
||||
return this.analyzeMatch(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /coupon
|
||||
* Alias for /coupon/create - frontend compatibility
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Create and save a user coupon (alias)' })
|
||||
async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||
return this.createCoupon(dto, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /coupon/daily-banko
|
||||
* Generate a high-confidence banko combo (2 matches)
|
||||
*/
|
||||
@Post('daily-banko')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Generate a high-confidence banko combo (2 matches)',
|
||||
})
|
||||
async getDailyBanko(@Body() dto: DailyBankoDto) {
|
||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||
let candidateMatches = dto.matchIds || [];
|
||||
if (candidateMatches.length === 0) {
|
||||
candidateMatches = await this.matchesService.findUpcomingMatches(
|
||||
Sport.FOOTBALL,
|
||||
20,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Auto-fetched ${candidateMatches.length} matches for daily-banko`,
|
||||
);
|
||||
} else {
|
||||
candidateMatches = await this.matchesService.filterUpcomingMatchIds(
|
||||
candidateMatches,
|
||||
Sport.FOOTBALL,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Sanitized candidate matches for daily-banko: ${candidateMatches.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidateMatches.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Kupon için uygun, henüz başlamamış maç bulunamadı.',
|
||||
};
|
||||
}
|
||||
|
||||
const coupon =
|
||||
await this.smartCouponService.generateDailyBankoCoupon(candidateMatches);
|
||||
if (!coupon) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.',
|
||||
};
|
||||
}
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /coupon/suggest
|
||||
* Generate Smart Coupon
|
||||
*/
|
||||
@Post('suggest')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Suggest Smart Coupon' })
|
||||
@ApiResponse({ status: 200, description: 'Smart Coupon generated' })
|
||||
async suggestCoupon(@Body() dto: SuggestCouponDto) {
|
||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||
let candidateMatches = dto.matchIds || [];
|
||||
if (candidateMatches.length === 0) {
|
||||
candidateMatches = await this.matchesService.findUpcomingMatches(
|
||||
Sport.FOOTBALL,
|
||||
20,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Auto-fetched ${candidateMatches.length} matches for suggest`,
|
||||
);
|
||||
} else {
|
||||
candidateMatches = await this.matchesService.filterUpcomingMatchIds(
|
||||
candidateMatches,
|
||||
Sport.FOOTBALL,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Sanitized candidate matches for suggest: ${candidateMatches.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidateMatches.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Tahmin için uygun, henüz başlamamış maç bulunamadı.',
|
||||
};
|
||||
}
|
||||
|
||||
const coupon = await this.smartCouponService.getSmartCoupon(
|
||||
candidateMatches,
|
||||
dto.strategy,
|
||||
{
|
||||
maxMatches: dto.maxMatches,
|
||||
minConfidence: dto.minConfidence,
|
||||
},
|
||||
);
|
||||
if (!coupon) {
|
||||
return { success: false, message: 'Kupon oluşturulamadı.' };
|
||||
}
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// USER COUPON ENDPOINTS (NEW)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* POST /coupon/create
|
||||
* Save a user generated coupon
|
||||
*/
|
||||
@Post('create')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Create and save a user coupon' })
|
||||
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||
// req.user is populated by JwtAuthGuard
|
||||
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /coupon/my-stats
|
||||
* Get user betting statistics (ROI, Win Rate)
|
||||
*/
|
||||
@Get('my-stats')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get user betting statistics' })
|
||||
async getUserStats(@Req() req: any) {
|
||||
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
||||
return { success: true, data: stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /coupon/history
|
||||
* Get coupon history (Public/System coupons)
|
||||
*/
|
||||
@Get('history')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get coupon history' })
|
||||
@ApiResponse({ status: 200, description: 'History retrieved' })
|
||||
async getHistory(@Query('limit') limit?: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
const results = await this.couponsService.getCouponHistory(
|
||||
Number(limit) || 10,
|
||||
);
|
||||
return { success: true, data: results };
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CouponsController } from './coupons.controller';
|
||||
import { SmartCouponService } from './services/smart-coupon.service';
|
||||
import { UserCouponService } from './services/user-coupon.service';
|
||||
import { CouponsService } from './coupons.service';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
import { ServicesModule } from '../../services/services.module';
|
||||
import { MatchesModule } from '../matches/matches.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, ServicesModule, MatchesModule],
|
||||
controllers: [CouponsController],
|
||||
providers: [CouponsService, SmartCouponService, UserCouponService],
|
||||
exports: [CouponsService, SmartCouponService, UserCouponService],
|
||||
})
|
||||
export class CouponsModule {}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { AiService } from '../../services/ai.service';
|
||||
// [REMOVED V16 IMPORTS]
|
||||
|
||||
export type RiskLevel = 'banko' | 'safe' | 'value';
|
||||
|
||||
export interface CouponMatch {
|
||||
matchId: string;
|
||||
matchName: string;
|
||||
prediction: string;
|
||||
confidence: number;
|
||||
odd: number;
|
||||
}
|
||||
|
||||
export interface GeneratedCoupon {
|
||||
id: string;
|
||||
matches: CouponMatch[];
|
||||
totalOdd: number;
|
||||
riskLevel: RiskLevel;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CouponsService {
|
||||
private readonly logger = new Logger(CouponsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly aiService: AiService,
|
||||
) {}
|
||||
/**
|
||||
* Legacy history/history methods...
|
||||
*/
|
||||
getCouponHistory(_limit: number = 10) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
ArrayMaxSize,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum CouponStrategyEnum {
|
||||
SAFE = 'SAFE',
|
||||
BALANCED = 'BALANCED',
|
||||
AGGRESSIVE = 'AGGRESSIVE',
|
||||
VALUE = 'VALUE',
|
||||
MIRACLE = 'MIRACLE',
|
||||
}
|
||||
|
||||
export class AnalyzeMatchDto {
|
||||
@ApiProperty({ description: 'Match ID to analyze' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
matchId: string;
|
||||
}
|
||||
|
||||
export class DailyBankoDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional match IDs — system fetches if empty',
|
||||
example: ['match-1', 'match-2'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
matchIds?: string[];
|
||||
}
|
||||
|
||||
export class SuggestCouponDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Match IDs — system fetches if empty',
|
||||
example: ['match-1', 'match-2'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
matchIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: CouponStrategyEnum,
|
||||
default: CouponStrategyEnum.BALANCED,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(CouponStrategyEnum)
|
||||
strategy?: CouponStrategyEnum;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
maxMatches?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Minimum confidence threshold (0-100)',
|
||||
example: 60,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
minConfidence?: number;
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
|
||||
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
||||
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
export type BetGrade = 'A' | 'B' | 'C' | 'PASS';
|
||||
|
||||
export interface PredictionPickRow {
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number;
|
||||
raw_confidence: number;
|
||||
calibrated_confidence: number;
|
||||
min_required_confidence: number;
|
||||
edge: number;
|
||||
play_score: number;
|
||||
playable: boolean;
|
||||
bet_grade: BetGrade;
|
||||
stake_units: number;
|
||||
decision_reasons: string[];
|
||||
}
|
||||
|
||||
export interface PredictionBetSummaryRow {
|
||||
market: string;
|
||||
pick: string;
|
||||
raw_confidence: number;
|
||||
calibrated_confidence: number;
|
||||
bet_grade: BetGrade;
|
||||
playable: boolean;
|
||||
stake_units: number;
|
||||
play_score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface SingleMatchPredictionPackage {
|
||||
model_version: string;
|
||||
match_info: {
|
||||
match_id: string;
|
||||
match_name: string;
|
||||
home_team: string;
|
||||
away_team: string;
|
||||
league: string;
|
||||
match_date_ms: number;
|
||||
};
|
||||
data_quality: {
|
||||
label: PredictionDataQuality;
|
||||
score: number;
|
||||
flags: string[];
|
||||
home_lineup_count: number;
|
||||
away_lineup_count: number;
|
||||
};
|
||||
risk: {
|
||||
level: PredictionRiskLevel;
|
||||
score: number;
|
||||
is_surprise_risk: boolean;
|
||||
surprise_type: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
engine_breakdown: {
|
||||
team: number;
|
||||
player: number;
|
||||
odds: number;
|
||||
referee: number;
|
||||
};
|
||||
main_pick: PredictionPickRow | null;
|
||||
value_pick: PredictionPickRow | null;
|
||||
bet_advice: {
|
||||
playable: boolean;
|
||||
suggested_stake_units: number;
|
||||
reason: string;
|
||||
};
|
||||
bet_summary: PredictionBetSummaryRow[];
|
||||
supporting_picks: PredictionPickRow[];
|
||||
aggressive_pick: {
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number | null;
|
||||
} | null;
|
||||
scenario_top5: Array<{
|
||||
score: string;
|
||||
prob: number;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
score_prediction: {
|
||||
ft: string;
|
||||
ht: string;
|
||||
xg_home: number;
|
||||
xg_away: number;
|
||||
xg_total: number;
|
||||
};
|
||||
market_board: Record<string, unknown>;
|
||||
reasoning_factors: string[];
|
||||
ai_commentary?: string | null;
|
||||
}
|
||||
|
||||
export interface SmartCouponResult {
|
||||
strategy: string;
|
||||
generated_at: string;
|
||||
match_count: number;
|
||||
bets: Array<{
|
||||
match_id: string;
|
||||
match_name: string;
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number;
|
||||
risk_level: PredictionRiskLevel;
|
||||
data_quality: PredictionDataQuality;
|
||||
}>;
|
||||
total_odds: number;
|
||||
expected_win_rate: number;
|
||||
rejected_matches: Array<{
|
||||
match_id: string;
|
||||
reason: string;
|
||||
threshold?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmartCouponService {
|
||||
private readonly logger = new Logger(SmartCouponService.name);
|
||||
private readonly aiEngineUrl: string;
|
||||
|
||||
constructor(private readonly geminiService: GeminiService) {
|
||||
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
|
||||
}
|
||||
|
||||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||
let prediction: SingleMatchPredictionPackage;
|
||||
try {
|
||||
const response = await axios.post<SingleMatchPredictionPackage>(
|
||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
);
|
||||
prediction = response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
throw new HttpException(
|
||||
`AI analyze failed: ${detail}`,
|
||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
'AI analyze failed',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate AI commentary (non-blocking — fail-safe)
|
||||
prediction.ai_commentary = await this.generateMatchCommentary(prediction);
|
||||
return prediction;
|
||||
}
|
||||
|
||||
private async generateMatchCommentary(
|
||||
prediction: SingleMatchPredictionPackage,
|
||||
): Promise<string | null> {
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.geminiService.generateText(
|
||||
JSON.stringify(prediction, null, 2),
|
||||
{
|
||||
model: 'gemini-2.0-flash',
|
||||
temperature: 0.7,
|
||||
maxTokens: 600,
|
||||
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
|
||||
},
|
||||
);
|
||||
return result.text || null;
|
||||
} catch (error) {
|
||||
this.logger.warn('AI commentary generation failed, skipping', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async generateDailyBankoCoupon(
|
||||
matchIds: string[],
|
||||
): Promise<SmartCouponResult | null> {
|
||||
if (matchIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getSmartCoupon(matchIds, 'SAFE', {
|
||||
maxMatches: 2,
|
||||
minConfidence: 78,
|
||||
});
|
||||
}
|
||||
|
||||
async getSmartCoupon(
|
||||
matchIds: string[],
|
||||
strategy:
|
||||
| 'SAFE'
|
||||
| 'BALANCED'
|
||||
| 'AGGRESSIVE'
|
||||
| 'VALUE'
|
||||
| 'MIRACLE' = 'BALANCED',
|
||||
options: { maxMatches?: number; minConfidence?: number } = {},
|
||||
): Promise<SmartCouponResult> {
|
||||
try {
|
||||
const response = await axios.post<SmartCouponResult>(
|
||||
`${this.aiEngineUrl}/v20plus/coupon`,
|
||||
{
|
||||
match_ids: matchIds,
|
||||
strategy,
|
||||
max_matches: options.maxMatches,
|
||||
min_confidence: options.minConfidence,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate smart coupon', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
throw new HttpException(
|
||||
`Coupon generation failed: ${detail}`,
|
||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
'Coupon generation failed',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz.
|
||||
|
||||
Kurallar:
|
||||
- Max 3-4 kısa paragraf, gereksiz uzatma
|
||||
- Playable olan marketleri ve nedenlerini açıkla
|
||||
- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz)
|
||||
- Tüm edge'ler negatifse "trap maç" olarak uyar
|
||||
- xG ve skor senaryolarına göre strateji öner
|
||||
- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama
|
||||
- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi)
|
||||
- "Ben olsam..." formatında kişisel tavsiye ver
|
||||
- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰
|
||||
- Markdown formatı KULLANMA, düz metin yaz
|
||||
- Bahis terminolojisi kullan: edge, value, implied odds, xG`;
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { User, UserCoupon, Match } from '@prisma/client';
|
||||
|
||||
export class CreateCouponDto {
|
||||
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
|
||||
items: {
|
||||
matchId: string;
|
||||
selection: string; // 'MS 1', '2.5 UST'
|
||||
odd: number;
|
||||
}[];
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface UserStatsDto {
|
||||
totalCoupons: number;
|
||||
wonCoupons: number;
|
||||
winRate: number; // Percentage
|
||||
totalInvested: number; // Unit based (1 unit per coupon)
|
||||
totalReturn: number;
|
||||
roi: number; // Return on Investment %
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserCouponService {
|
||||
private readonly logger = new Logger(UserCouponService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Kullanıcı için yeni bir kupon oluşturur ve kaydeder.
|
||||
*/
|
||||
async createCoupon(user: User, dto: CreateCouponDto): Promise<UserCoupon> {
|
||||
const totalOdds = dto.items.reduce((acc, item) => acc * item.odd, 1);
|
||||
|
||||
const coupon = await this.prisma.userCoupon.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
strategy: dto.strategy,
|
||||
totalOdds: parseFloat(totalOdds.toFixed(2)),
|
||||
isPublic: dto.isPublic || false,
|
||||
status: 'PENDING',
|
||||
couponItems: {
|
||||
create: dto.items.map((item) => ({
|
||||
matchId: item.matchId,
|
||||
selection: item.selection,
|
||||
oddAtTime: item.odd,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
couponItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Coupon created for user ${user.email} with odds ${totalOdds}`,
|
||||
);
|
||||
return coupon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bekleyen kuponların sonuçlarını kontrol eder ve günceller.
|
||||
* Bu metod bir Cron Job tarafından periyodik olarak çağrılmalıdır.
|
||||
*/
|
||||
async updatePendingCoupons(): Promise<void> {
|
||||
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
|
||||
const pendingCoupons = await this.prisma.userCoupon.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: {
|
||||
couponItems: {
|
||||
include: { match: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const coupon of pendingCoupons) {
|
||||
let isCouponWon = true;
|
||||
let isCouponLost = false;
|
||||
let allMatchesFinished = true;
|
||||
|
||||
for (const item of coupon.couponItems) {
|
||||
if (item.match.status !== 'FT') {
|
||||
allMatchesFinished = false;
|
||||
break; // Henüz bitmemiş maç var, kuponu güncelleme
|
||||
}
|
||||
|
||||
const isItemWon = this.checkSelection(item.selection, item.match);
|
||||
|
||||
// Sonucu item bazında güncelle
|
||||
if (item.isCorrect !== isItemWon) {
|
||||
await this.prisma.userCouponItem.update({
|
||||
where: { id: item.id },
|
||||
data: { isCorrect: isItemWon },
|
||||
});
|
||||
}
|
||||
|
||||
if (!isItemWon) {
|
||||
isCouponLost = true;
|
||||
isCouponWon = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCouponLost) {
|
||||
await this.prisma.userCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'LOST' },
|
||||
});
|
||||
} else if (allMatchesFinished && isCouponWon) {
|
||||
await this.prisma.userCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'WON' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basit bir kural seti ile bahsin tutup tutmadığını kontrol eder.
|
||||
* Gerçek dünyada bu daha karmaşık bir 'BetSettlementService' olmalıdır.
|
||||
*/
|
||||
private checkSelection(selection: string, match: Match): boolean {
|
||||
const home = match.scoreHome ?? 0;
|
||||
const away = match.scoreAway ?? 0;
|
||||
const total = home + away;
|
||||
|
||||
switch (selection) {
|
||||
case 'MS 1':
|
||||
return home > away;
|
||||
case 'MS X':
|
||||
return home === away;
|
||||
case 'MS 2':
|
||||
return away > home;
|
||||
case '1.5 UST':
|
||||
return total > 1.5;
|
||||
case '2.5 UST':
|
||||
return total > 2.5;
|
||||
case '3.5 UST':
|
||||
return total > 3.5;
|
||||
case '2.5 ALT':
|
||||
return total < 2.5;
|
||||
case 'KG VAR':
|
||||
return home > 0 && away > 0;
|
||||
case 'KG YOK':
|
||||
return home === 0 || away === 0;
|
||||
default:
|
||||
return false; // Bilinmeyen market
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının bahis performans istatistiklerini getirir.
|
||||
*/
|
||||
async getUserStatistics(userId: string): Promise<UserStatsDto> {
|
||||
const coupons = await this.prisma.userCoupon.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { in: ['WON', 'LOST'] },
|
||||
},
|
||||
});
|
||||
|
||||
const totalCoupons = coupons.length;
|
||||
if (totalCoupons === 0) {
|
||||
return {
|
||||
totalCoupons: 0,
|
||||
wonCoupons: 0,
|
||||
winRate: 0,
|
||||
totalInvested: 0,
|
||||
totalReturn: 0,
|
||||
roi: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const wonCoupons = coupons.filter((c) => c.status === 'WON');
|
||||
const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
|
||||
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
|
||||
const winRate = (wonCoupons.length / totalCoupons) * 100;
|
||||
const roi = ((totalReturn - totalInvested) / totalInvested) * 100;
|
||||
|
||||
return {
|
||||
totalCoupons,
|
||||
wonCoupons: wonCoupons.length,
|
||||
winRate: parseFloat(winRate.toFixed(2)),
|
||||
totalInvested,
|
||||
totalReturn: parseFloat(totalReturn.toFixed(2)),
|
||||
roi: parseFloat(roi.toFixed(2)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user