diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 1fa5f3c..c5e769a 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -330,6 +330,24 @@ export const adminPagesMs: Record = { 'settlement.est_payout': 'Anggaran bayaran', 'settlement.refund_amount': 'Jumlah bayaran balik', 'settlement.confirm_btn': 'Sahkan penyelesaian', + 'settlement.smart.btn': 'Skor pintar', + 'settlement.smart.title': 'Cadangan skor pintar', + 'settlement.smart.hint': 'Berdasarkan pertaruhan tunggal menunggu; parlay tidak disertakan. Klik kad untuk guna skor.', + 'settlement.smart.target_hold': 'Sasaran pegangan', + 'settlement.smart.recalc': 'Kira semula', + 'settlement.smart.apply': 'Guna skor ini', + 'settlement.smart.applied': 'Skor telah diisi', + 'settlement.smart.no_bets': 'Tiada pertaruhan tunggal menunggu', + 'settlement.smart.empty': 'Tiada cadangan', + 'settlement.smart.meta': 'Tunggal {singles}, parlay {parlays} dilangkau, {n} skor dibanding', + 'settlement.smart.hold': 'Pegangan', + 'settlement.smart.payout': 'Bayaran', + 'settlement.smart.win_stake': 'Stake menang %', + 'settlement.smart.wl': 'Menang/Kalah', + 'settlement.smart.strategy.MIN_PAYOUT': 'Pegangan maks (bayaran min)', + 'settlement.smart.strategy.MAX_PAYOUT': 'Bayaran pemain maks', + 'settlement.smart.strategy.BALANCED': 'Seimbang (~50% stake menang)', + 'settlement.smart.strategy.TARGET_HOLD': 'Sasaran pegangan', 'msg.score_recorded': 'Skor disimpan', 'msg.settlement_confirmed': 'Penyelesaian disahkan', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 345b69a..8933202 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -330,6 +330,24 @@ export const adminPagesZh: Record = { 'settlement.est_payout': '预计派彩', 'settlement.refund_amount': '退款金额', 'settlement.confirm_btn': '确认结算', + 'settlement.smart.btn': '智能比分', + 'settlement.smart.title': '智能推荐比分', + 'settlement.smart.hint': '根据本场待结算单关注单,在合理比分范围内穷举并计算派彩;串关仅统计不参与推荐。点击方案可填入录分框。', + 'settlement.smart.target_hold': '目标平台留存', + 'settlement.smart.recalc': '重新计算', + 'settlement.smart.apply': '采用此比分', + 'settlement.smart.applied': '已填入推荐比分', + 'settlement.smart.no_bets': '本场无待结算单关注单', + 'settlement.smart.empty': '未找到可用方案', + 'settlement.smart.meta': '单关 {singles} 笔,串关 {parlays} 笔未参与,已比对 {n} 组比分', + 'settlement.smart.hold': '留存', + 'settlement.smart.payout': '派彩', + 'settlement.smart.win_stake': '玩家赢注占比', + 'settlement.smart.wl': '赢/输单', + 'settlement.smart.strategy.MIN_PAYOUT': '平台最大留存(最低派彩)', + 'settlement.smart.strategy.MAX_PAYOUT': '玩家最大派彩', + 'settlement.smart.strategy.BALANCED': '投注均衡(约50%赢注额)', + 'settlement.smart.strategy.TARGET_HOLD': '目标留存率', 'msg.score_recorded': '比分已录入', 'msg.settlement_confirmed': '结算已确认', @@ -809,6 +827,24 @@ export const adminPagesEn: Record = { 'settlement.est_payout': 'Est. payout', 'settlement.refund_amount': 'Refund amount', 'settlement.confirm_btn': 'Confirm settlement', + 'settlement.smart.btn': 'Smart score', + 'settlement.smart.title': 'Smart score suggestions', + 'settlement.smart.hint': 'Enumerates valid scores from pending single bets and estimates payout. Parlays are excluded. Click a card to apply.', + 'settlement.smart.target_hold': 'Target house hold', + 'settlement.smart.recalc': 'Recalculate', + 'settlement.smart.apply': 'Apply score', + 'settlement.smart.applied': 'Score applied', + 'settlement.smart.no_bets': 'No pending single bets', + 'settlement.smart.empty': 'No suggestion found', + 'settlement.smart.meta': 'Singles {singles}, parlays {parlays} skipped, {n} scores compared', + 'settlement.smart.hold': 'Hold', + 'settlement.smart.payout': 'Payout', + 'settlement.smart.win_stake': 'Win stake %', + 'settlement.smart.wl': 'W/L bets', + 'settlement.smart.strategy.MIN_PAYOUT': 'Max house hold (min payout)', + 'settlement.smart.strategy.MAX_PAYOUT': 'Max player payout', + 'settlement.smart.strategy.BALANCED': 'Balanced (~50% win stake)', + 'settlement.smart.strategy.TARGET_HOLD': 'Target hold rate', 'msg.score_recorded': 'Score saved', 'msg.settlement_confirmed': 'Settlement confirmed', diff --git a/apps/admin/src/views/Settlement.vue b/apps/admin/src/views/Settlement.vue index 18ddf16..fb781b2 100644 --- a/apps/admin/src/views/Settlement.vue +++ b/apps/admin/src/views/Settlement.vue @@ -59,6 +59,8 @@ const preview = ref | null>(null); const stats = ref(null); const statsLoading = ref(false); +// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score) + const matchId = computed(() => String(route.params.id ?? '')); const statusSummary = computed(() => { @@ -263,6 +265,11 @@ onMounted(() => {
+ {{ t('settlement.record_score') }} {{ t('settlement.preview_btn') }} @@ -272,6 +279,8 @@ onMounted(() => {
+ +
{{ t('settlement.preview_title') }}
@@ -665,4 +674,8 @@ onMounted(() => { .confirm-btn { margin-top: 24px; } + +/* 智能比分弹窗样式(功能已关闭,保留便于恢复) +.smart-hint { ... } +*/ diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 28038e5..7733f39 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -354,6 +354,22 @@ class ScoreDto { ftAway!: number; } +/* 智能比分推荐已关闭 +class SmartScoreSuggestDto { + @IsOptional() + @IsArray() + strategies?: Array<'MIN_PAYOUT' | 'MAX_PAYOUT' | 'BALANCED' | 'TARGET_HOLD'>; + + @IsOptional() + @IsNumber() + targetHoldPct?: number; + + @IsOptional() + @IsNumber() + maxGoals?: number; +} +*/ + class MarketTemplatesDto { @IsArray() marketTypes!: string[]; @@ -1195,6 +1211,10 @@ export class AdminController { return jsonResponse(data); } + // 智能比分推荐已关闭 + // @Post('matches/:id/settlement/smart-score') + // async suggestSmartScore(...) { ... } + @Post('matches/:id/settlement/score') async recordScore( @CurrentUser('id') operatorId: bigint, diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 4a10428..825ec8d 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -354,7 +354,10 @@ export class MatchesService { const matchStats = await this.betStatsForMatches( leagueMatches.map((m) => m.id), ); - const leagueBetRollup = new Map(); + const leagueBetRollup = new Map< + string, + { betCount: number; totalStake: Decimal; pendingCount: number } + >(); for (const lm of leagueMatches) { const lid = lm.leagueId.toString(); const cur = leagueBetRollup.get(lid) ?? { @@ -365,7 +368,7 @@ export class MatchesService { const ms = matchStats.get(lm.id.toString()); if (ms) { cur.betCount += ms.betCount; - cur.totalStake = cur.totalStake.add(ms.totalStake); + cur.totalStake = cur.totalStake.add(new Decimal(ms.totalStake)); cur.pendingCount += ms.pendingCount; } leagueBetRollup.set(lid, cur); @@ -417,7 +420,7 @@ export class MatchesService { ]); const raw = betStatsMap.get(m.id.toString()); const betCount = raw?.betCount ?? 0; - const totalStake = raw?.totalStake.toString() ?? '0'; + const totalStake = raw?.totalStake ?? '0'; const pendingBets = raw?.pendingCount ?? 0; return { id: m.id.toString(), @@ -442,11 +445,8 @@ export class MatchesService { /** 批量汇总多场关联注单(按 bet 去重计注单数) */ async betStatsForMatches( matchIds: bigint[], - ): Promise> { - const result = new Map< - string, - MatchBetStatsSummary & { totalStake: Decimal } - >(); + ): Promise> { + const result = new Map(); if (!matchIds.length) return result; const legs = await this.prisma.betSelection.findMany({ @@ -481,7 +481,7 @@ export class MatchesService { if (!bets) { result.set(mid, { betCount: 0, - totalStake: new Decimal(0), + totalStake: '0', pendingCount: 0, }); continue; @@ -494,7 +494,7 @@ export class MatchesService { } result.set(mid, { betCount: bets.size, - totalStake, + totalStake: totalStake.toString(), pendingCount, }); } diff --git a/apps/api/src/domains/ledger/wallet.service.ts b/apps/api/src/domains/ledger/wallet.service.ts index fcccab0..eddee7e 100644 --- a/apps/api/src/domains/ledger/wallet.service.ts +++ b/apps/api/src/domains/ledger/wallet.service.ts @@ -33,6 +33,7 @@ export class WalletService { operatorId: bigint, remark?: string, referenceId?: string, + transactionType = 'MANUAL_DEPOSIT', ) { const amt = new Decimal(amount); if (amt.lte(0)) throw new BadRequestException('Amount must be positive'); @@ -55,7 +56,7 @@ export class WalletService { transactionId: generateTransactionId(), userId, walletId: w.id, - transactionType: 'MANUAL_DEPOSIT', + transactionType, amount: amt, balanceBefore, balanceAfter, diff --git a/apps/api/src/domains/operations/cashback/cashback.service.ts b/apps/api/src/domains/operations/cashback/cashback.service.ts index 6aaacc9..b38c027 100644 --- a/apps/api/src/domains/operations/cashback/cashback.service.ts +++ b/apps/api/src/domains/operations/cashback/cashback.service.ts @@ -86,6 +86,7 @@ export class CashbackService { operatorId, `Cashback batch ${batch.batchNo}`, batch.batchNo, + 'CASHBACK_DEPOSIT', ); } } diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts index a8f4912..08cbdce 100644 --- a/apps/api/src/domains/settlement/settlement.service.ts +++ b/apps/api/src/domains/settlement/settlement.service.ts @@ -12,6 +12,19 @@ import { FT_CORRECT_SCORE_TEMPLATE, HT_CORRECT_SCORE_TEMPLATE, } from './domain/settlement-calculator'; +// 智能比分推荐已关闭 +// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver'; + +function resolveSelectionCodeFromLeg( + selectionCode: string | null | undefined, + nameSnapshot: string, +): string { + if (selectionCode?.trim()) return selectionCode.trim(); + if (nameSnapshot.includes('-')) { + return `SCORE_${nameSnapshot.replace('-', '_')}`; + } + return nameSnapshot; +} @Injectable() export class SettlementService { @@ -429,6 +442,10 @@ export class SettlementService { }; } + /* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import + async suggestSmartScores(...) { ... } + */ + async voidMatchBets(matchId: bigint) { const bets = await this.prisma.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId } } }, diff --git a/apps/api/src/domains/settlement/smart-score.solver.ts b/apps/api/src/domains/settlement/smart-score.solver.ts new file mode 100644 index 0000000..dc821ee --- /dev/null +++ b/apps/api/src/domains/settlement/smart-score.solver.ts @@ -0,0 +1,235 @@ +import Decimal from 'decimal.js'; +import { + calculatePayout, + settleSelection, + FT_CORRECT_SCORE_TEMPLATE, + HT_CORRECT_SCORE_TEMPLATE, + type ScoreInput, +} from './domain/settlement-calculator'; + +export type SmartScoreStrategy = + | 'MIN_PAYOUT' + | 'MAX_PAYOUT' + | 'BALANCED' + | 'TARGET_HOLD'; + +export type SmartScoreSimBet = { + stake: Decimal; + odds: Decimal; + marketType: string; + selectionCode: string; + handicapLine: number | null; + totalLine: number | null; +}; + +export type ScoreEvaluation = { + htHome: number; + htAway: number; + ftHome: number; + ftAway: number; + totalStake: string; + totalPayout: string; + totalRefund: string; + houseProfit: string; + houseHoldPct: number; + playerWinStakePct: number; + winBets: number; + loseBets: number; + pushBets: number; +}; + +function templateForMarket(marketType: string): string[] { + if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE; + if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE; + return []; +} + +export function evaluateScoreForSingleBets( + bets: SmartScoreSimBet[], + score: ScoreInput, +): Omit & { + score: ScoreInput; +} { + let totalStake = new Decimal(0); + let totalPayout = new Decimal(0); + let totalRefund = new Decimal(0); + let winStake = new Decimal(0); + let loseStake = new Decimal(0); + let winBets = 0; + let loseBets = 0; + let pushBets = 0; + + for (const bet of bets) { + totalStake = totalStake.add(bet.stake); + const result = settleSelection({ + marketType: bet.marketType, + selectionCode: bet.selectionCode, + handicapLine: bet.handicapLine, + totalLine: bet.totalLine, + score, + templateScores: templateForMarket(bet.marketType), + }); + const payout = calculatePayout(bet.stake, bet.odds, result); + + if (result === 'LOSE') { + loseBets += 1; + loseStake = loseStake.add(bet.stake); + } else if (result === 'PUSH' || result === 'VOID') { + pushBets += 1; + totalRefund = totalRefund.add(bet.stake); + } else { + winBets += 1; + winStake = winStake.add(bet.stake); + totalPayout = totalPayout.add(payout); + } + } + + const houseProfit = totalStake.sub(totalPayout).sub(totalRefund); + const houseHoldPct = totalStake.gt(0) + ? houseProfit.div(totalStake).mul(100).toNumber() + : 0; + const playerWinStakePct = totalStake.gt(0) + ? winStake.div(totalStake).mul(100).toNumber() + : 0; + + return { + score, + totalStake: totalStake.toString(), + totalPayout: totalPayout.toString(), + totalRefund: totalRefund.toString(), + houseProfit: houseProfit.toString(), + houseHoldPct: Math.round(houseHoldPct * 100) / 100, + playerWinStakePct: Math.round(playerWinStakePct * 100) / 100, + winBets, + loseBets, + pushBets, + }; +} + +function strategyScore( + evalResult: ReturnType, + strategy: SmartScoreStrategy, + targetHoldPct: number, +): number { + const payout = new Decimal(evalResult.totalPayout).add(evalResult.totalRefund); + const hold = evalResult.houseHoldPct; + const winPct = evalResult.playerWinStakePct; + + switch (strategy) { + case 'MIN_PAYOUT': + return payout.toNumber(); + case 'MAX_PAYOUT': + return -payout.toNumber(); + case 'BALANCED': + return Math.abs(winPct - 50); + case 'TARGET_HOLD': + return Math.abs(hold - targetHoldPct); + default: + return payout.toNumber(); + } +} + +function scoreKey(s: ScoreInput): string { + return `${s.htHome}-${s.htAway}-${s.ftHome}-${s.ftAway}`; +} + +export function suggestScoresForBets( + bets: SmartScoreSimBet[], + options: { + strategies?: SmartScoreStrategy[]; + targetHoldPct?: number; + maxGoals?: number; + alternativesPerStrategy?: number; + } = {}, +): { + suggestions: Array<{ + strategy: SmartScoreStrategy; + rank: number; + evaluation: ScoreEvaluation; + }>; + searchedCandidates: number; +} { + const strategies = options.strategies ?? [ + 'MIN_PAYOUT', + 'MAX_PAYOUT', + 'BALANCED', + 'TARGET_HOLD', + ]; + const targetHoldPct = Math.min(100, Math.max(0, options.targetHoldPct ?? 30)); + const maxGoals = Math.min(8, Math.max(2, options.maxGoals ?? 5)); + + if (!bets.length) { + const zero: ScoreInput = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }; + const ev = evaluateScoreForSingleBets(bets, zero); + return { + suggestions: strategies.map((strategy, i) => ({ + strategy, + rank: 1, + evaluation: { + htHome: 0, + htAway: 0, + ftHome: 0, + ftAway: 0, + ...ev, + }, + })), + searchedCandidates: 1, + }; + } + + const allEvals: ReturnType[] = []; + for (let htHome = 0; htHome <= maxGoals; htHome++) { + for (let htAway = 0; htAway <= maxGoals; htAway++) { + for (let ftHome = htHome; ftHome <= maxGoals; ftHome++) { + for (let ftAway = htAway; ftAway <= maxGoals; ftAway++) { + const score: ScoreInput = { htHome, htAway, ftHome, ftAway }; + allEvals.push(evaluateScoreForSingleBets(bets, score)); + } + } + } + } + + const suggestions: Array<{ + strategy: SmartScoreStrategy; + rank: number; + evaluation: ScoreEvaluation; + }> = []; + const usedKeys = new Set(); + + for (const strategy of strategies) { + const sorted = [...allEvals].sort( + (a, b) => + strategyScore(a, strategy, targetHoldPct) - + strategyScore(b, strategy, targetHoldPct), + ); + let rank = 0; + for (const ev of sorted) { + const key = scoreKey(ev.score); + if (usedKeys.has(`${strategy}:${key}`)) continue; + usedKeys.add(`${strategy}:${key}`); + rank += 1; + suggestions.push({ + strategy, + rank, + evaluation: { + htHome: ev.score.htHome, + htAway: ev.score.htAway, + ftHome: ev.score.ftHome, + ftAway: ev.score.ftAway, + totalStake: ev.totalStake, + totalPayout: ev.totalPayout, + totalRefund: ev.totalRefund, + houseProfit: ev.houseProfit, + houseHoldPct: ev.houseHoldPct, + playerWinStakePct: ev.playerWinStakePct, + winBets: ev.winBets, + loseBets: ev.loseBets, + pushBets: ev.pushBets, + }, + }); + if (rank >= (options.alternativesPerStrategy ?? 1)) break; + } + } + + return { suggestions, searchedCandidates: allEvals.length }; +} diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index d3b5111..f7fc0b5 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -63,7 +63,7 @@ const i18n = createI18n({ tx_bet_push: '投注退水', tx_bet_refund: '投注退款', tx_bet_void: '投注撤销', - tx_cashback: '返水', + tx_cashback: '返水发放', tx_resettle: '重新结算', }, bet: { @@ -288,7 +288,7 @@ const i18n = createI18n({ tx_bet_push: 'Bet Push', tx_bet_refund: 'Bet Refund', tx_bet_void: 'Bet Voided', - tx_cashback: 'Cashback', + tx_cashback: 'Cashback Distribution', tx_resettle: 'Resettlement', }, bet: { @@ -519,7 +519,7 @@ const i18n = createI18n({ tx_bet_push: 'Pertaruhan Seri', tx_bet_refund: 'Bayaran Balik', tx_bet_void: 'Pertaruhan Dibatalkan', - tx_cashback: 'Cashback', + tx_cashback: 'Pembayaran Cashback', tx_resettle: 'Penyelesaian Semula', }, bet: { diff --git a/apps/player/src/views/MatchDetailView.vue b/apps/player/src/views/MatchDetailView.vue index 6b36f76..db3d9ce 100644 --- a/apps/player/src/views/MatchDetailView.vue +++ b/apps/player/src/views/MatchDetailView.vue @@ -623,7 +623,6 @@ function hasSlipPickForMarket(marketType: string) { } @media (prefers-reduced-motion: reduce) { - .vs-img { animation: none; filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35)); } .hz-path, .hz-beam { animation: none; opacity: 0; } } diff --git a/apps/player/src/views/WalletView.vue b/apps/player/src/views/WalletView.vue index e29b0fd..8ccc1aa 100644 --- a/apps/player/src/views/WalletView.vue +++ b/apps/player/src/views/WalletView.vue @@ -23,6 +23,7 @@ const TX_KEY_MAP: Record = { BET_VOID: 'wallet.tx_bet_void', BET_VOID_REFUND: 'wallet.tx_bet_void', CASHBACK: 'wallet.tx_cashback', + CASHBACK_DEPOSIT: 'wallet.tx_cashback', RESETTLE_REVERSE: 'wallet.tx_resettle', DEPOSIT: 'wallet.tx_deposit', WITHDRAW: 'wallet.tx_withdraw', @@ -45,7 +46,6 @@ onMounted(async () => {