feat(admin,api,player): 结算预览分页、统计图表与返水限额
完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,6 +172,9 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'bet.col.player': 'Pemain',
|
'bet.col.player': 'Pemain',
|
||||||
'bet.col.agent': 'Ejen',
|
'bet.col.agent': 'Ejen',
|
||||||
'bet.col.selection': 'Pilihan',
|
'bet.col.selection': 'Pilihan',
|
||||||
|
'bet.col.content': 'Kandungan taruhan',
|
||||||
|
'bet.col.match': 'Perlawanan',
|
||||||
|
'bet.legs_more': '+{n} lagi…',
|
||||||
'bet.col.selection_count': 'Bil. pilihan',
|
'bet.col.selection_count': 'Bil. pilihan',
|
||||||
'bet.col.stake': 'Stake',
|
'bet.col.stake': 'Stake',
|
||||||
'bet.col.odds': 'Odds',
|
'bet.col.odds': 'Odds',
|
||||||
@@ -210,7 +213,25 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'cashback.preview_title': 'Pratonton rebat',
|
'cashback.preview_title': 'Pratonton rebat',
|
||||||
'cashback.stat.players': 'Pemain',
|
'cashback.stat.players': 'Pemain',
|
||||||
'cashback.stat.total': 'Jumlah rebat',
|
'cashback.stat.total': 'Jumlah rebat',
|
||||||
|
'cashback.stat.lines': 'Baris butiran',
|
||||||
|
'cashback.batch_no': 'No. kelompok',
|
||||||
|
'cashback.table_title': 'Butiran rebat pemain',
|
||||||
|
'cashback.table_total': 'Jumlah',
|
||||||
|
'cashback.empty_items': 'Tiada rebat layak dalam tempoh ini',
|
||||||
|
'cashback.col.index': '#',
|
||||||
|
'cashback.col.player': 'Pemain',
|
||||||
|
'cashback.col.agent': 'Ejen',
|
||||||
|
'cashback.col.effective_stake': 'Stake berkesan',
|
||||||
|
'cashback.col.rate': 'Kadar',
|
||||||
|
'cashback.col.amount': 'Rebat',
|
||||||
'cashback.confirm_issue': 'Sahkan bayaran',
|
'cashback.confirm_issue': 'Sahkan bayaran',
|
||||||
|
'cashback.rules_title': 'Peraturan rebat',
|
||||||
|
'cashback.rule_period': 'Pilih julat tarikh. Taruhan dikira mengikut masa penyelesaian dalam tempoh tersebut.',
|
||||||
|
'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, dan kadar 0.',
|
||||||
|
'cashback.rule_formula': 'Setiap taruhan: stake × kadar rebat. Jumlah diagregat mengikut pemain.',
|
||||||
|
'cashback.rule_rate': 'Keutamaan kadar: pemain > ejen > global > kadar lalai ejen (cth. 0.01 = 1%).',
|
||||||
|
'cashback.rule_flow': 'Aliran: pratonton → semak jumlah → sahkan bayaran (kredit dompet, entri CASHBACK).',
|
||||||
|
'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.',
|
||||||
|
|
||||||
'user.field.player_id': 'ID pemain',
|
'user.field.player_id': 'ID pemain',
|
||||||
'user.field.bet_count': 'Bilangan pertaruhan',
|
'user.field.bet_count': 'Bilangan pertaruhan',
|
||||||
@@ -313,8 +334,12 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'settlement.stats_parlay': 'Parlay',
|
'settlement.stats_parlay': 'Parlay',
|
||||||
'settlement.stats_total_stake': 'Jumlah stake',
|
'settlement.stats_total_stake': 'Jumlah stake',
|
||||||
'settlement.stats_potential': 'Menang maksimum',
|
'settlement.stats_potential': 'Menang maksimum',
|
||||||
|
'settlement.chart.bet_type': 'Tunggal vs parlay',
|
||||||
|
'settlement.chart.status': 'Taburan status pertaruhan',
|
||||||
|
'settlement.chart.stake_by_selection': 'TOP6 pilihan mengikut taruhan tunggal',
|
||||||
'settlement.stats_by_market': 'Ikut pasaran / pilihan',
|
'settlement.stats_by_market': 'Ikut pasaran / pilihan',
|
||||||
'settlement.bet_list': 'Semua pertaruhan',
|
'settlement.bet_list': 'Pertaruhan berkaitan',
|
||||||
|
'settlement.bet_list_hint': 'Dikumpulkan mengikut pertaruhan; parlay sama perlawanan tunjuk ×kaki',
|
||||||
'settlement.no_bets': 'Tiada pertaruhan untuk perlawanan ini',
|
'settlement.no_bets': 'Tiada pertaruhan untuk perlawanan ini',
|
||||||
'settlement.col.market': 'Pasaran',
|
'settlement.col.market': 'Pasaran',
|
||||||
'settlement.col.selection': 'Pilihan',
|
'settlement.col.selection': 'Pilihan',
|
||||||
@@ -324,7 +349,10 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'settlement.ht_score': 'Skor separuh masa',
|
'settlement.ht_score': 'Skor separuh masa',
|
||||||
'settlement.ft_score': 'Skor penuh masa',
|
'settlement.ft_score': 'Skor penuh masa',
|
||||||
'settlement.record_score': 'Simpan skor',
|
'settlement.record_score': 'Simpan skor',
|
||||||
|
'settlement.preview_hint': 'Skor di atas disimpan secara automatik sebelum pratonton',
|
||||||
'settlement.preview_btn': 'Pratonton penyelesaian',
|
'settlement.preview_btn': 'Pratonton penyelesaian',
|
||||||
|
'settlement.preview_failed': 'Gagal menjana pratonton penyelesaian',
|
||||||
|
'settlement.err_score_not_recorded': 'Sila masukkan skor separuh masa dan penuh masa sebelum penyelesaian',
|
||||||
'settlement.preview_title': 'Pratonton penyelesaian',
|
'settlement.preview_title': 'Pratonton penyelesaian',
|
||||||
'settlement.single_count': 'Pertaruhan tunggal',
|
'settlement.single_count': 'Pertaruhan tunggal',
|
||||||
'settlement.est_payout': 'Anggaran bayaran',
|
'settlement.est_payout': 'Anggaran bayaran',
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'bet.col.player': '玩家',
|
'bet.col.player': '玩家',
|
||||||
'bet.col.agent': '所属代理',
|
'bet.col.agent': '所属代理',
|
||||||
'bet.col.selection': '选项',
|
'bet.col.selection': '选项',
|
||||||
|
'bet.col.content': '投注内容',
|
||||||
|
'bet.col.match': '赛事',
|
||||||
|
'bet.legs_more': '还有 {n} 项…',
|
||||||
'bet.col.selection_count': '投注项数',
|
'bet.col.selection_count': '投注项数',
|
||||||
'bet.col.stake': '投注额',
|
'bet.col.stake': '投注额',
|
||||||
'bet.col.odds': '赔率',
|
'bet.col.odds': '赔率',
|
||||||
@@ -210,7 +213,25 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'cashback.preview_title': '返水预览',
|
'cashback.preview_title': '返水预览',
|
||||||
'cashback.stat.players': '涉及玩家数',
|
'cashback.stat.players': '涉及玩家数',
|
||||||
'cashback.stat.total': '返水总金额',
|
'cashback.stat.total': '返水总金额',
|
||||||
|
'cashback.stat.lines': '明细条数',
|
||||||
|
'cashback.batch_no': '批次号',
|
||||||
|
'cashback.table_title': '玩家返水明细',
|
||||||
|
'cashback.table_total': '合计',
|
||||||
|
'cashback.empty_items': '本周期内无符合条件的返水记录',
|
||||||
|
'cashback.col.index': '#',
|
||||||
|
'cashback.col.player': '玩家',
|
||||||
|
'cashback.col.agent': '所属代理',
|
||||||
|
'cashback.col.effective_stake': '有效投注',
|
||||||
|
'cashback.col.rate': '返水比例',
|
||||||
|
'cashback.col.amount': '返水金额',
|
||||||
'cashback.confirm_issue': '确认发放',
|
'cashback.confirm_issue': '确认发放',
|
||||||
|
'cashback.rules_title': '返水规则说明',
|
||||||
|
'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。',
|
||||||
|
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单。',
|
||||||
|
'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。',
|
||||||
|
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 所属代理默认返水率(在代理/玩家管理中配置,如 0.01 表示 1%)。',
|
||||||
|
'cashback.rule_flow': '操作流程:生成预览 → 核对涉及玩家数与总金额 → 确认发放(金额入账玩家钱包,并生成 CASHBACK 账变流水)。',
|
||||||
|
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。',
|
||||||
|
|
||||||
'user.field.player_id': '玩家 ID',
|
'user.field.player_id': '玩家 ID',
|
||||||
'user.field.bet_count': '注单数',
|
'user.field.bet_count': '注单数',
|
||||||
@@ -313,23 +334,61 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'settlement.stats_parlay': '串关',
|
'settlement.stats_parlay': '串关',
|
||||||
'settlement.stats_total_stake': '总投注额',
|
'settlement.stats_total_stake': '总投注额',
|
||||||
'settlement.stats_potential': '最大可赢',
|
'settlement.stats_potential': '最大可赢',
|
||||||
|
'settlement.chart.bet_type': '单关 / 串关占比',
|
||||||
|
'settlement.chart.status': '注单状态分布',
|
||||||
|
'settlement.chart.stake_by_selection': '选项单关投注额 TOP6',
|
||||||
'settlement.stats_by_market': '按玩法 / 选项汇总',
|
'settlement.stats_by_market': '按玩法 / 选项汇总',
|
||||||
'settlement.bet_list': '全部注单',
|
'settlement.bet_list': '相关注单',
|
||||||
|
'settlement.bet_list_hint': '按注单聚合;同场串关含多腿时显示 ×腿数',
|
||||||
'settlement.no_bets': '本场暂无注单',
|
'settlement.no_bets': '本场暂无注单',
|
||||||
'settlement.col.market': '玩法',
|
'settlement.col.market': '玩法',
|
||||||
'settlement.col.selection': '选项',
|
'settlement.col.selection': '选项',
|
||||||
'settlement.col.legs': '笔数',
|
'settlement.col.legs': '腿数',
|
||||||
'settlement.col.single_stake': '单关投注额',
|
'settlement.col.single_stake': '单关投注额',
|
||||||
'settlement.col.parlay_legs': '串关腿数',
|
'settlement.col.parlay_legs': '串关腿数',
|
||||||
'settlement.ht_score': '半场比分',
|
'settlement.ht_score': '半场比分',
|
||||||
'settlement.ft_score': '全场比分',
|
'settlement.ft_score': '全场比分',
|
||||||
'settlement.record_score': '录入比分',
|
'settlement.record_score': '录入比分',
|
||||||
|
'settlement.preview_hint': '生成预览前会自动保存上方比分',
|
||||||
'settlement.preview_btn': '生成结算预览',
|
'settlement.preview_btn': '生成结算预览',
|
||||||
|
'settlement.preview_failed': '生成结算预览失败',
|
||||||
|
'settlement.err_score_not_recorded': '请先录入半场与全场比分后再结算',
|
||||||
'settlement.preview_title': '结算预览',
|
'settlement.preview_title': '结算预览',
|
||||||
'settlement.single_count': '单关注单数',
|
'settlement.single_count': '单关注单数',
|
||||||
|
'settlement.preview_pending_bets': '待结算注单',
|
||||||
|
'settlement.preview_bet_mix': '单关 / 串关',
|
||||||
|
'settlement.preview_items_title': '逐笔预览({n} 笔)',
|
||||||
|
'settlement.preview_items_scroll_hint': '笔数较多时可滚动查看',
|
||||||
|
'settlement.preview_col.result': '本场结果',
|
||||||
|
'settlement.preview_zero_parlay_hint':
|
||||||
|
'预计派彩为 0:本场虽有 {legs} 条赢腿,但均在跨场串关内({pending} 笔待其他场次),须等其他比赛结算后才派彩。',
|
||||||
|
'settlement.preview_zero_lost_hint':
|
||||||
|
'预计派彩为 0:{count} 笔串关在本场已有输腿,整单作废;其余单关/串关本场亦未中奖。',
|
||||||
|
'settlement.preview_zero_default_hint': '预计派彩为 0:本场相关注单均未中奖或暂不满足派彩条件。',
|
||||||
|
'settlement.preview.result.WIN': '赢',
|
||||||
|
'settlement.preview.result.LOSE': '输',
|
||||||
|
'settlement.preview.result.LOST': '整单输',
|
||||||
|
'settlement.preview.result.WON': '整单赢',
|
||||||
|
'settlement.preview.result.PUSH': '走盘',
|
||||||
|
'settlement.preview.result.PENDING_OTHER_MATCHES': '待其他场次',
|
||||||
'settlement.est_payout': '预计派彩',
|
'settlement.est_payout': '预计派彩',
|
||||||
'settlement.refund_amount': '退款金额',
|
'settlement.refund_amount': '退款金额',
|
||||||
'settlement.confirm_btn': '确认结算',
|
'settlement.confirm_btn': '确认结算',
|
||||||
|
'settlement.resettle_reason': '重结算原因',
|
||||||
|
'settlement.resettle_preview': '重结算预览',
|
||||||
|
'settlement.resettle_preview_title': '重结算预览',
|
||||||
|
'settlement.resettle_affected': '影响注单数',
|
||||||
|
'settlement.resettle_topup': '需补发金额',
|
||||||
|
'settlement.resettle_clawback': '需扣回金额',
|
||||||
|
'settlement.resettle_confirm': '确认重结算',
|
||||||
|
'user.betting_limits': '投注限额',
|
||||||
|
'user.betting_limits_hint': '全局下注校验:最小/最大投注、最高派彩、每日投注上限',
|
||||||
|
'user.limit.min_stake': '最小投注',
|
||||||
|
'user.limit.max_stake_single': '单关最大投注',
|
||||||
|
'user.limit.max_stake_parlay': '串关最大投注',
|
||||||
|
'user.limit.max_payout_single': '单关最高派彩',
|
||||||
|
'user.limit.max_payout_parlay': '串关最高派彩',
|
||||||
|
'user.limit.daily_stake': '每日投注上限',
|
||||||
'settlement.smart.btn': '智能比分',
|
'settlement.smart.btn': '智能比分',
|
||||||
'settlement.smart.title': '智能推荐比分',
|
'settlement.smart.title': '智能推荐比分',
|
||||||
'settlement.smart.hint': '根据本场待结算单关注单,在合理比分范围内穷举并计算派彩;串关仅统计不参与推荐。点击方案可填入录分框。',
|
'settlement.smart.hint': '根据本场待结算单关注单,在合理比分范围内穷举并计算派彩;串关仅统计不参与推荐。点击方案可填入录分框。',
|
||||||
@@ -350,6 +409,7 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'settlement.smart.strategy.TARGET_HOLD': '目标留存率',
|
'settlement.smart.strategy.TARGET_HOLD': '目标留存率',
|
||||||
'msg.score_recorded': '比分已录入',
|
'msg.score_recorded': '比分已录入',
|
||||||
'msg.settlement_confirmed': '结算已确认',
|
'msg.settlement_confirmed': '结算已确认',
|
||||||
|
'msg.resettle_confirmed': '重结算已确认',
|
||||||
|
|
||||||
'agent_portal.create_player_section': '创建玩家',
|
'agent_portal.create_player_section': '创建玩家',
|
||||||
'agent_portal.deposit_section': '上分操作',
|
'agent_portal.deposit_section': '上分操作',
|
||||||
@@ -669,6 +729,9 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'bet.col.player': 'Player',
|
'bet.col.player': 'Player',
|
||||||
'bet.col.agent': 'Agent',
|
'bet.col.agent': 'Agent',
|
||||||
'bet.col.selection': 'Pick',
|
'bet.col.selection': 'Pick',
|
||||||
|
'bet.col.content': 'Selections',
|
||||||
|
'bet.col.match': 'Match',
|
||||||
|
'bet.legs_more': '+{n} more…',
|
||||||
'bet.col.selection_count': 'Legs',
|
'bet.col.selection_count': 'Legs',
|
||||||
'bet.col.stake': 'Stake',
|
'bet.col.stake': 'Stake',
|
||||||
'bet.col.odds': 'Odds',
|
'bet.col.odds': 'Odds',
|
||||||
@@ -707,7 +770,25 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'cashback.preview_title': 'Cashback preview',
|
'cashback.preview_title': 'Cashback preview',
|
||||||
'cashback.stat.players': 'Players',
|
'cashback.stat.players': 'Players',
|
||||||
'cashback.stat.total': 'Total cashback',
|
'cashback.stat.total': 'Total cashback',
|
||||||
|
'cashback.stat.lines': 'Line items',
|
||||||
|
'cashback.batch_no': 'Batch no.',
|
||||||
|
'cashback.table_title': 'Player cashback breakdown',
|
||||||
|
'cashback.table_total': 'Total',
|
||||||
|
'cashback.empty_items': 'No eligible cashback in this period',
|
||||||
|
'cashback.col.index': '#',
|
||||||
|
'cashback.col.player': 'Player',
|
||||||
|
'cashback.col.agent': 'Agent',
|
||||||
|
'cashback.col.effective_stake': 'Effective stake',
|
||||||
|
'cashback.col.rate': 'Rate',
|
||||||
|
'cashback.col.amount': 'Cashback',
|
||||||
'cashback.confirm_issue': 'Confirm payout',
|
'cashback.confirm_issue': 'Confirm payout',
|
||||||
|
'cashback.rules_title': 'Cashback rules',
|
||||||
|
'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.',
|
||||||
|
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, and zero-rate bets.',
|
||||||
|
'cashback.rule_formula': 'Per bet: stake × applicable cashback rate. Amounts are summed per player into one line item.',
|
||||||
|
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > agent default rate (set under Agents/Players, e.g. 0.01 = 1%).',
|
||||||
|
'cashback.rule_flow': 'Flow: preview → verify player count and total → confirm payout (credits wallet, creates CASHBACK ledger entries).',
|
||||||
|
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0.',
|
||||||
|
|
||||||
'user.field.player_id': 'Player ID',
|
'user.field.player_id': 'Player ID',
|
||||||
'user.field.bet_count': 'Bet count',
|
'user.field.bet_count': 'Bet count',
|
||||||
@@ -810,8 +891,12 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'settlement.stats_parlay': 'Parlays',
|
'settlement.stats_parlay': 'Parlays',
|
||||||
'settlement.stats_total_stake': 'Total stake',
|
'settlement.stats_total_stake': 'Total stake',
|
||||||
'settlement.stats_potential': 'Max potential win',
|
'settlement.stats_potential': 'Max potential win',
|
||||||
|
'settlement.chart.bet_type': 'Single vs parlay',
|
||||||
|
'settlement.chart.status': 'Bet status mix',
|
||||||
|
'settlement.chart.stake_by_selection': 'Top selections by single stake',
|
||||||
'settlement.stats_by_market': 'By market / selection',
|
'settlement.stats_by_market': 'By market / selection',
|
||||||
'settlement.bet_list': 'All bets',
|
'settlement.bet_list': 'Related bets',
|
||||||
|
'settlement.bet_list_hint': 'Grouped by bet; same-match parlays show ×legs',
|
||||||
'settlement.no_bets': 'No bets on this match',
|
'settlement.no_bets': 'No bets on this match',
|
||||||
'settlement.col.market': 'Market',
|
'settlement.col.market': 'Market',
|
||||||
'settlement.col.selection': 'Selection',
|
'settlement.col.selection': 'Selection',
|
||||||
@@ -821,12 +906,47 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'settlement.ht_score': 'Half-time score',
|
'settlement.ht_score': 'Half-time score',
|
||||||
'settlement.ft_score': 'Full-time score',
|
'settlement.ft_score': 'Full-time score',
|
||||||
'settlement.record_score': 'Save score',
|
'settlement.record_score': 'Save score',
|
||||||
|
'settlement.preview_hint': 'Current scores are saved automatically before preview',
|
||||||
'settlement.preview_btn': 'Preview settlement',
|
'settlement.preview_btn': 'Preview settlement',
|
||||||
|
'settlement.preview_failed': 'Failed to generate settlement preview',
|
||||||
|
'settlement.err_score_not_recorded': 'Enter half-time and full-time scores before settling',
|
||||||
'settlement.preview_title': 'Settlement preview',
|
'settlement.preview_title': 'Settlement preview',
|
||||||
'settlement.single_count': 'Single bets',
|
'settlement.single_count': 'Single bets',
|
||||||
|
'settlement.preview_pending_bets': 'Pending bets',
|
||||||
|
'settlement.preview_bet_mix': 'Single / parlay',
|
||||||
|
'settlement.preview_items_title': 'Per-bet preview ({n})',
|
||||||
|
'settlement.preview_items_scroll_hint': 'Scroll when the list is long',
|
||||||
|
'settlement.preview_col.result': 'Match result',
|
||||||
|
'settlement.preview_zero_parlay_hint':
|
||||||
|
'Est. payout is 0: {legs} winning leg(s) on this match are in cross-match parlays ({pending} bet(s) awaiting other fixtures).',
|
||||||
|
'settlement.preview_zero_lost_hint':
|
||||||
|
'Est. payout is 0: {count} parlay(s) already lost on this match; other bets did not win here either.',
|
||||||
|
'settlement.preview_zero_default_hint':
|
||||||
|
'Est. payout is 0: no winning or refundable outcome on this match yet.',
|
||||||
|
'settlement.preview.result.WIN': 'Win',
|
||||||
|
'settlement.preview.result.LOSE': 'Lose',
|
||||||
|
'settlement.preview.result.LOST': 'Parlay lost',
|
||||||
|
'settlement.preview.result.WON': 'Parlay won',
|
||||||
|
'settlement.preview.result.PUSH': 'Push',
|
||||||
|
'settlement.preview.result.PENDING_OTHER_MATCHES': 'Awaiting other matches',
|
||||||
'settlement.est_payout': 'Est. payout',
|
'settlement.est_payout': 'Est. payout',
|
||||||
'settlement.refund_amount': 'Refund amount',
|
'settlement.refund_amount': 'Refund amount',
|
||||||
'settlement.confirm_btn': 'Confirm settlement',
|
'settlement.confirm_btn': 'Confirm settlement',
|
||||||
|
'settlement.resettle_reason': 'Resettle reason',
|
||||||
|
'settlement.resettle_preview': 'Resettle preview',
|
||||||
|
'settlement.resettle_preview_title': 'Resettle preview',
|
||||||
|
'settlement.resettle_affected': 'Affected bets',
|
||||||
|
'settlement.resettle_topup': 'Top-up required',
|
||||||
|
'settlement.resettle_clawback': 'Clawback required',
|
||||||
|
'settlement.resettle_confirm': 'Confirm resettle',
|
||||||
|
'user.betting_limits': 'Betting limits',
|
||||||
|
'user.betting_limits_hint': 'Global stake/payout/daily limits for player bets',
|
||||||
|
'user.limit.min_stake': 'Min stake',
|
||||||
|
'user.limit.max_stake_single': 'Max single stake',
|
||||||
|
'user.limit.max_stake_parlay': 'Max parlay stake',
|
||||||
|
'user.limit.max_payout_single': 'Max single payout',
|
||||||
|
'user.limit.max_payout_parlay': 'Max parlay payout',
|
||||||
|
'user.limit.daily_stake': 'Daily stake limit',
|
||||||
'settlement.smart.btn': 'Smart score',
|
'settlement.smart.btn': 'Smart score',
|
||||||
'settlement.smart.title': 'Smart score suggestions',
|
'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.hint': 'Enumerates valid scores from pending single bets and estimates payout. Parlays are excluded. Click a card to apply.',
|
||||||
@@ -847,6 +967,7 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'settlement.smart.strategy.TARGET_HOLD': 'Target hold rate',
|
'settlement.smart.strategy.TARGET_HOLD': 'Target hold rate',
|
||||||
'msg.score_recorded': 'Score saved',
|
'msg.score_recorded': 'Score saved',
|
||||||
'msg.settlement_confirmed': 'Settlement confirmed',
|
'msg.settlement_confirmed': 'Settlement confirmed',
|
||||||
|
'msg.resettle_confirmed': 'Resettle confirmed',
|
||||||
|
|
||||||
'agent_portal.create_player_section': 'Create player',
|
'agent_portal.create_player_section': 'Create player',
|
||||||
'agent_portal.deposit_section': 'Top up',
|
'agent_portal.deposit_section': 'Top up',
|
||||||
|
|||||||
123
apps/admin/src/utils/settlement-charts.ts
Normal file
123
apps/admin/src/utils/settlement-charts.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import { buildBarChartOption, buildPieChartOption, type PieSegment } from './dashboard-charts';
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
PENDING: '#e8a040',
|
||||||
|
WON: '#2fb56a',
|
||||||
|
LOST: '#ff453a',
|
||||||
|
PUSH: '#8e8e93',
|
||||||
|
VOID: '#555',
|
||||||
|
};
|
||||||
|
|
||||||
|
function withCompactHeader(option: EChartsOption, text: string, subtext?: string): EChartsOption {
|
||||||
|
const grid = (option.grid ?? {}) as { top?: number; bottom?: number; left?: number; right?: number };
|
||||||
|
const top = subtext ? 36 : 24;
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
title: {
|
||||||
|
text,
|
||||||
|
subtext,
|
||||||
|
left: 'center',
|
||||||
|
top: 0,
|
||||||
|
textStyle: { color: '#ccc', fontSize: 11, fontWeight: 600 },
|
||||||
|
subtextStyle: { color: '#777', fontSize: 9 },
|
||||||
|
itemGap: 2,
|
||||||
|
},
|
||||||
|
grid: { ...grid, top: Math.max(top, grid.top ?? 0) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactPie(option: EChartsOption, centerLines?: string[]): EChartsOption {
|
||||||
|
const series = option.series as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (series?.[0]) {
|
||||||
|
series[0].center = ['50%', '55%'];
|
||||||
|
series[0].radius = ['34%', '50%'];
|
||||||
|
series[0].label = { show: false };
|
||||||
|
series[0].labelLine = { show: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = centerLines?.filter(Boolean) ?? [];
|
||||||
|
const graphic = lines.map((line, i) => ({
|
||||||
|
type: 'text' as const,
|
||||||
|
left: 'center',
|
||||||
|
top: lines.length === 1 ? '52%' : `${48 + i * 12}%`,
|
||||||
|
style: {
|
||||||
|
text: line,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
fill: i === 0 ? '#eee' : '#888',
|
||||||
|
fontSize: i === 0 ? 15 : 9,
|
||||||
|
fontWeight: i === 0 ? 700 : 400,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
left: 'center',
|
||||||
|
itemWidth: 8,
|
||||||
|
itemHeight: 8,
|
||||||
|
textStyle: { color: '#999', fontSize: 9 },
|
||||||
|
},
|
||||||
|
graphic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBetTypePieOption(input: {
|
||||||
|
title: string;
|
||||||
|
singleBets: number;
|
||||||
|
parlayBets: number;
|
||||||
|
totalBets: number;
|
||||||
|
legCount: number;
|
||||||
|
labels: { single: string; parlay: string };
|
||||||
|
subtext: string;
|
||||||
|
centerLabel: string;
|
||||||
|
}): EChartsOption {
|
||||||
|
const segments: PieSegment[] = [
|
||||||
|
{ label: input.labels.single, value: input.singleBets, color: '#2fb56a' },
|
||||||
|
{ label: input.labels.parlay, value: input.parlayBets, color: '#fb923c' },
|
||||||
|
].filter((s) => s.value > 0);
|
||||||
|
|
||||||
|
const base = buildPieChartOption(input.title, segments, { pieTooltip: '{b}:{c}({d}%)' });
|
||||||
|
const withHeader = withCompactHeader(base, input.title, input.subtext);
|
||||||
|
return compactPie(withHeader, [String(input.totalBets), input.centerLabel]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatusPieOption(input: {
|
||||||
|
title: string;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
labelFor: (status: string) => string;
|
||||||
|
subtext: string;
|
||||||
|
}): EChartsOption {
|
||||||
|
const segments: PieSegment[] = Object.entries(input.statusCounts)
|
||||||
|
.filter(([, count]) => count > 0)
|
||||||
|
.map(([status, value]) => ({
|
||||||
|
label: input.labelFor(status),
|
||||||
|
value,
|
||||||
|
color: STATUS_COLORS[status] ?? '#666',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
const base = buildPieChartOption(input.title, segments, { pieTooltip: '{b}:{c}({d}%)' });
|
||||||
|
const withHeader = withCompactHeader(base, input.title, input.subtext);
|
||||||
|
return compactPie(withHeader, [String(total)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectionStakeBarOption(input: {
|
||||||
|
title: string;
|
||||||
|
subtext: string;
|
||||||
|
labels: string[];
|
||||||
|
stakes: number[];
|
||||||
|
seriesName: string;
|
||||||
|
}): EChartsOption {
|
||||||
|
const base = buildBarChartOption(
|
||||||
|
input.labels,
|
||||||
|
[{ name: input.seriesName, color: '#2fb56a', values: input.stakes }],
|
||||||
|
{ amountAxis: true },
|
||||||
|
);
|
||||||
|
return withCompactHeader(
|
||||||
|
{ ...base, grid: { left: 40, right: 6, top: 36, bottom: 22 }, legend: { show: false } },
|
||||||
|
input.title,
|
||||||
|
input.subtext,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -179,6 +179,32 @@ async function openDetail(row: BetListRow) {
|
|||||||
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
|
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('bet.col.content')" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.selectionPreviews?.length"
|
||||||
|
:content="row.selectionSummary || ''"
|
||||||
|
placement="top"
|
||||||
|
:show-after="300"
|
||||||
|
>
|
||||||
|
<div class="bet-content-cell">
|
||||||
|
<div
|
||||||
|
v-for="(leg, i) in (row.selectionPreviews ?? []).slice(0, 3)"
|
||||||
|
:key="i"
|
||||||
|
class="bet-leg-line"
|
||||||
|
>
|
||||||
|
<span class="bet-match">{{ leg.matchLabel }}</span>
|
||||||
|
<span class="bet-pick">{{ leg.selectionName }}</span>
|
||||||
|
<span class="bet-leg-odds">@{{ leg.odds }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="(row.selectionPreviews?.length ?? 0) > 3" class="bet-leg-more">
|
||||||
|
{{ t('bet.legs_more', { n: (row.selectionPreviews?.length ?? 0) - 3 }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="bet-content-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column :label="t('bet.col.selection_count')" width="88" align="center">
|
<el-table-column :label="t('bet.col.selection_count')" width="88" align="center">
|
||||||
<template #default="{ row }">{{ row.selectionCount }}</template>
|
<template #default="{ row }">{{ row.selectionCount }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -267,6 +293,12 @@ async function openDetail(row: BetListRow) {
|
|||||||
<div class="selections-title">{{ t('bet.selections_title', { n: detail.selections.length }) }}</div>
|
<div class="selections-title">{{ t('bet.selections_title', { n: detail.selections.length }) }}</div>
|
||||||
<el-table :data="detail.selections" size="small" stripe border>
|
<el-table :data="detail.selections" size="small" stripe border>
|
||||||
<el-table-column type="index" label="#" width="44" />
|
<el-table-column type="index" label="#" width="44" />
|
||||||
|
<el-table-column :label="t('bet.col.match')" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="detail-match-cell">{{ row.matchLabel }}</div>
|
||||||
|
<div v-if="row.leagueName" class="detail-league">{{ row.leagueName }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="selectionName" :label="t('bet.col.selection')" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="selectionName" :label="t('bet.col.selection')" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="marketType" :label="t('bet.col.market')" width="100" />
|
<el-table-column prop="marketType" :label="t('bet.col.market')" width="100" />
|
||||||
<el-table-column prop="period" :label="t('bet.col.period')" width="72">
|
<el-table-column prop="period" :label="t('bet.col.period')" width="72">
|
||||||
@@ -310,7 +342,55 @@ async function openDetail(row: BetListRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bets-table {
|
.bets-table {
|
||||||
min-width: 1180px;
|
min-width: 1380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-content-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-leg-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-match {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-pick {
|
||||||
|
color: var(--green-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-leg-odds {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-leg-more {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-content-empty {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-match-cell {
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-league {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
}
|
}
|
||||||
.bet-no {
|
.bet-no {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -1,28 +1,90 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import type { TableColumnCtx } from 'element-plus';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
interface CashbackBatch {
|
||||||
|
id: string;
|
||||||
|
batchNo: string;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
playerCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CashbackPreviewItem {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
agentUsername: string | null;
|
||||||
|
effectiveStake: string;
|
||||||
|
rate: string;
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CashbackPreview {
|
||||||
|
batch: CashbackBatch;
|
||||||
|
items: CashbackPreviewItem[];
|
||||||
|
totalAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useAdminLocale();
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
const preview = ref<Record<string, unknown> | null>(null);
|
const preview = ref<CashbackPreview | null>(null);
|
||||||
|
const rulesVisible = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
const period = ref({
|
const period = ref({
|
||||||
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
|
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
|
||||||
end: new Date().toISOString().slice(0, 10),
|
end: new Date().toISOString().slice(0, 10),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function generatePreview() {
|
const previewItems = computed(() => preview.value?.items ?? []);
|
||||||
const { data } = await api.post('/admin/cashbacks/preview', {
|
|
||||||
periodStart: period.value.start,
|
function formatRate(value: string | number | null | undefined) {
|
||||||
periodEnd: period.value.end,
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '—';
|
||||||
|
return `${(n * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeriodDate(value: string) {
|
||||||
|
if (!value) return '—';
|
||||||
|
return value.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableSummary(param: {
|
||||||
|
columns: TableColumnCtx<CashbackPreviewItem>[];
|
||||||
|
data: CashbackPreviewItem[];
|
||||||
|
}) {
|
||||||
|
const { columns, data } = param;
|
||||||
|
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 0);
|
||||||
|
const amountSum = data.reduce((s, row) => s + Number(row.amount), 0);
|
||||||
|
|
||||||
|
return columns.map((col, index) => {
|
||||||
|
if (index === 0) return t('cashback.table_total');
|
||||||
|
if (col.property === 'effectiveStake') return formatAmount(stakeSum);
|
||||||
|
if (col.property === 'amount') return formatAmount(amountSum);
|
||||||
|
return '';
|
||||||
});
|
});
|
||||||
preview.value = data.data;
|
}
|
||||||
|
|
||||||
|
async function generatePreview() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/admin/cashbacks/preview', {
|
||||||
|
periodStart: period.value.start,
|
||||||
|
periodEnd: period.value.end,
|
||||||
|
});
|
||||||
|
preview.value = data.data as CashbackPreview;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (!preview.value?.batch) return;
|
if (!preview.value?.batch) return;
|
||||||
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
|
await api.post(`/admin/cashbacks/${preview.value.batch.id}/confirm`);
|
||||||
ElMessage.success(t('msg.cashback_issued'));
|
ElMessage.success(t('msg.cashback_issued'));
|
||||||
preview.value = null;
|
preview.value = null;
|
||||||
}
|
}
|
||||||
@@ -30,49 +92,261 @@ async function confirm() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-scroll">
|
<div class="page-scroll">
|
||||||
<el-card class="tool-card" shadow="never">
|
<el-card class="tool-card" shadow="never">
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
<el-form-item :label="t('cashback.start_date')">
|
<el-form-item :label="t('cashback.start_date')">
|
||||||
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('cashback.end_date')">
|
<el-form-item :label="t('cashback.end_date')">
|
||||||
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="generatePreview">{{ t('cashback.preview_btn') }}</el-button>
|
<el-button type="primary" :loading="loading" @click="generatePreview">
|
||||||
</el-form-item>
|
{{ t('cashback.preview_btn') }}
|
||||||
</el-form>
|
</el-button>
|
||||||
</div>
|
<button
|
||||||
</el-card>
|
type="button"
|
||||||
|
class="rules-help-btn"
|
||||||
|
:aria-label="t('cashback.rules_title')"
|
||||||
|
:title="t('cashback.rules_title')"
|
||||||
|
@click="rulesVisible = true"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
<el-dialog
|
||||||
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
|
v-model="rulesVisible"
|
||||||
<el-row :gutter="20" class="preview-stats">
|
:title="t('cashback.rules_title')"
|
||||||
<el-col :span="8">
|
width="560px"
|
||||||
<div class="pstat">
|
destroy-on-close
|
||||||
<div class="pstat-value">{{ (preview.batch as { playerCount: number })?.playerCount ?? 0 }}</div>
|
>
|
||||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
<ul class="rules-list">
|
||||||
|
<li>{{ t('cashback.rule_period') }}</li>
|
||||||
|
<li>{{ t('cashback.rule_eligible') }}</li>
|
||||||
|
<li>{{ t('cashback.rule_formula') }}</li>
|
||||||
|
<li>{{ t('cashback.rule_rate') }}</li>
|
||||||
|
<li>{{ t('cashback.rule_flow') }}</li>
|
||||||
|
<li class="rules-note">{{ t('cashback.rule_note_zero') }}</li>
|
||||||
|
</ul>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<template v-if="preview">
|
||||||
|
<el-card class="preview-card" shadow="never">
|
||||||
|
<div class="preview-head">
|
||||||
|
<div>
|
||||||
|
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
|
||||||
|
<div class="preview-meta">
|
||||||
|
<span>{{ t('cashback.batch_no') }}:{{ preview.batch.batchNo }}</span>
|
||||||
|
<span class="meta-sep">·</span>
|
||||||
|
<span>
|
||||||
|
{{ formatPeriodDate(preview.batch.periodStart) }}
|
||||||
|
—
|
||||||
|
{{ formatPeriodDate(preview.batch.periodEnd) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
|
||||||
<el-col :span="8">
|
<el-row :gutter="20" class="preview-stats">
|
||||||
<div class="pstat">
|
<el-col :span="8">
|
||||||
<div class="pstat-value">{{ preview.totalAmount }}</div>
|
<div class="pstat">
|
||||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
<div class="pstat-value">{{ preview.batch.playerCount ?? 0 }}</div>
|
||||||
|
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="pstat">
|
||||||
|
<div class="pstat-value pstat-green">{{ formatAmount(preview.totalAmount) }}</div>
|
||||||
|
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="pstat">
|
||||||
|
<div class="pstat-value">{{ previewItems.length }}</div>
|
||||||
|
<div class="pstat-label">{{ t('cashback.stat.lines') }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="data-card" shadow="never">
|
||||||
|
<div class="table-title">{{ t('cashback.table_title') }}</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table
|
||||||
|
:data="previewItems"
|
||||||
|
stripe
|
||||||
|
class="cashback-table"
|
||||||
|
show-summary
|
||||||
|
:summary-method="tableSummary"
|
||||||
|
:empty-text="t('cashback.empty_items')"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
|
||||||
|
<el-table-column
|
||||||
|
prop="username"
|
||||||
|
:label="t('cashback.col.player')"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="agentUsername"
|
||||||
|
:label="t('cashback.col.agent')"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="effectiveStake"
|
||||||
|
:label="t('cashback.col.effective_stake')"
|
||||||
|
min-width="120"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.effectiveStake)" placement="top">
|
||||||
|
<span>{{ formatAmount(row.effectiveStake) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
|
||||||
|
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||||
|
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
|
||||||
</el-row>
|
<el-button type="success" class="confirm-btn" :disabled="previewItems.length === 0" @click="confirm">
|
||||||
<el-button type="success" @click="confirm" style="margin-top: 20px">{{ t('cashback.confirm_issue') }}</el-button>
|
{{ t('cashback.confirm_issue') }}
|
||||||
</el-card>
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||||
.preview-card { border-radius: 12px; }
|
|
||||||
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
|
.rules-help-btn {
|
||||||
.preview-stats { }
|
margin-left: 10px;
|
||||||
.pstat { padding: 16px; background: #f9f9fb; border-radius: 10px; text-align: center; }
|
width: 28px;
|
||||||
.pstat-value { font-size: 26px; font-weight: 700; color: var(--green-glow); }
|
height: 28px;
|
||||||
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-help-btn:hover {
|
||||||
|
color: var(--green-glow);
|
||||||
|
border-color: rgba(47, 181, 106, 0.45);
|
||||||
|
background: rgba(47, 181, 106, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-note {
|
||||||
|
color: #888;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card,
|
||||||
|
.data-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-sep {
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stats {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pstat {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pstat-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pstat-green {
|
||||||
|
color: var(--green-glow);
|
||||||
|
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pstat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-cell {
|
||||||
|
color: var(--green-glow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import { VChart } from '../components/dashboard/echarts-setup';
|
||||||
import { formatAmount } from '../utils/format-amount';
|
import { formatAmount } from '../utils/format-amount';
|
||||||
|
import {
|
||||||
|
buildBetTypePieOption,
|
||||||
|
buildSelectionStakeBarOption,
|
||||||
|
buildStatusPieOption,
|
||||||
|
} from '../utils/settlement-charts';
|
||||||
import {
|
import {
|
||||||
betStatusLabel,
|
betStatusLabel,
|
||||||
betStatusTagType,
|
betStatusTagType,
|
||||||
@@ -32,20 +38,28 @@ interface SettlementBetStats {
|
|||||||
singleStake: string;
|
singleStake: string;
|
||||||
parlayLegCount: number;
|
parlayLegCount: number;
|
||||||
}>;
|
}>;
|
||||||
bets: Array<{
|
bets: {
|
||||||
id: string;
|
items: Array<{
|
||||||
betNo: string;
|
id: string;
|
||||||
username: string;
|
betNo: string;
|
||||||
betType: string;
|
username: string;
|
||||||
status: string;
|
betType: string;
|
||||||
stake: string;
|
status: string;
|
||||||
potentialReturn: string | null;
|
stake: string;
|
||||||
placedAt: string;
|
potentialReturn: string | null;
|
||||||
marketType: string;
|
placedAt: string;
|
||||||
period: string | null;
|
legCountOnMatch: number;
|
||||||
selectionName: string;
|
selections: Array<{
|
||||||
odds: string;
|
marketType: string;
|
||||||
}>;
|
period: string | null;
|
||||||
|
selectionName: string;
|
||||||
|
odds: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t, locale, localeTag } = useAdminLocale();
|
const { t, locale, localeTag } = useAdminLocale();
|
||||||
@@ -56,16 +70,111 @@ const loading = ref(false);
|
|||||||
const match = ref<AdminMatchDetail | null>(null);
|
const match = ref<AdminMatchDetail | null>(null);
|
||||||
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
||||||
const preview = ref<Record<string, unknown> | null>(null);
|
const preview = ref<Record<string, unknown> | null>(null);
|
||||||
|
const resettlePreview = ref<Record<string, unknown> | null>(null);
|
||||||
|
const resettleReason = ref('');
|
||||||
const stats = ref<SettlementBetStats | null>(null);
|
const stats = ref<SettlementBetStats | null>(null);
|
||||||
const statsLoading = ref(false);
|
const statsLoading = ref(false);
|
||||||
|
const betPage = ref(1);
|
||||||
|
const betPageSize = ref(10);
|
||||||
|
const previewPage = ref(1);
|
||||||
|
const previewPageSize = ref(10);
|
||||||
|
|
||||||
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score)
|
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score)
|
||||||
|
|
||||||
const matchId = computed(() => String(route.params.id ?? ''));
|
const matchId = computed(() => String(route.params.id ?? ''));
|
||||||
|
|
||||||
const statusSummary = computed(() => {
|
type PreviewItem = {
|
||||||
const counts = stats.value?.summary.statusCounts ?? {};
|
betNo: string;
|
||||||
return ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH'].filter((s) => (counts[s] ?? 0) > 0);
|
betType: string;
|
||||||
|
result: string;
|
||||||
|
payout: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewItemsPage = {
|
||||||
|
items: PreviewItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewItemsPage = computed(
|
||||||
|
() => (preview.value?.items as PreviewItemsPage | undefined) ?? { items: [], total: 0, page: 1, pageSize: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewZeroHint = computed(() => {
|
||||||
|
const p = preview.value;
|
||||||
|
if (!p) return '';
|
||||||
|
const payout = Number(p.totalPayout ?? 0);
|
||||||
|
const refund = Number(p.totalRefund ?? 0);
|
||||||
|
if (payout > 0 || refund > 0) return '';
|
||||||
|
const pending = Number(p.pendingOtherMatches ?? 0);
|
||||||
|
const wonLegs = Number(p.wonLegsOnMatch ?? 0);
|
||||||
|
if (pending > 0 && wonLegs > 0) {
|
||||||
|
return t('settlement.preview_zero_parlay_hint', { pending: String(pending), legs: String(wonLegs) });
|
||||||
|
}
|
||||||
|
if (Number(p.lostOnThisMatch ?? 0) > 0) {
|
||||||
|
return t('settlement.preview_zero_lost_hint', { count: String(p.lostOnThisMatch) });
|
||||||
|
}
|
||||||
|
return t('settlement.preview_zero_default_hint');
|
||||||
|
});
|
||||||
|
|
||||||
|
function previewResultLabel(result: string) {
|
||||||
|
const key = `settlement.preview.result.${result}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label !== key ? label : betResultLabel(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const betTypeChartOption = computed(() => {
|
||||||
|
const s = stats.value?.summary;
|
||||||
|
if (!s) return null;
|
||||||
|
return buildBetTypePieOption({
|
||||||
|
title: t('settlement.chart.bet_type'),
|
||||||
|
singleBets: s.singleBets,
|
||||||
|
parlayBets: s.parlayBets,
|
||||||
|
totalBets: s.totalBets,
|
||||||
|
legCount: s.legCount,
|
||||||
|
labels: {
|
||||||
|
single: t('settlement.stats_single'),
|
||||||
|
parlay: t('settlement.stats_parlay'),
|
||||||
|
},
|
||||||
|
subtext: `${s.singleBets} ${t('settlement.stats_single')} · ${s.parlayBets} ${t('settlement.stats_parlay')} · ${s.legCount} ${t('settlement.col.legs')}`,
|
||||||
|
centerLabel: t('settlement.stats_total_bets'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusChartOption = computed(() => {
|
||||||
|
const s = stats.value?.summary;
|
||||||
|
if (!s) return null;
|
||||||
|
const parts = ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH']
|
||||||
|
.filter((st) => (s.statusCounts[st] ?? 0) > 0)
|
||||||
|
.map((st) => `${betStatusLabel(st)} ${s.statusCounts[st]}`);
|
||||||
|
return buildStatusPieOption({
|
||||||
|
title: t('settlement.chart.status'),
|
||||||
|
statusCounts: s.statusCounts,
|
||||||
|
labelFor: (st) => betStatusLabel(st),
|
||||||
|
subtext: parts.join(' · ') || t('settlement.no_bets'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionStakeChartOption = computed(() => {
|
||||||
|
const s = stats.value?.summary;
|
||||||
|
const rows = stats.value?.bySelection ?? [];
|
||||||
|
if (!s) return null;
|
||||||
|
const top = [...rows]
|
||||||
|
.sort((a, b) => Number(b.singleStake) - Number(a.singleStake))
|
||||||
|
.slice(0, 6);
|
||||||
|
const labels = top.map((row) => {
|
||||||
|
const name = selectionDisplay(row);
|
||||||
|
return name.length > 8 ? `${name.slice(0, 8)}…` : name;
|
||||||
|
});
|
||||||
|
return buildSelectionStakeBarOption({
|
||||||
|
title: t('settlement.chart.stake_by_selection'),
|
||||||
|
subtext: `${t('settlement.stats_total_stake')} ${formatAmount(s.totalStake)} · ${t('settlement.stats_potential')} ${formatAmount(s.totalPotentialReturn)}`,
|
||||||
|
labels: labels.length ? labels : [t('settlement.no_bets')],
|
||||||
|
stakes: top.length ? top.map((row) => Number(row.singleStake)) : [0],
|
||||||
|
seriesName: t('settlement.col.single_stake'),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const leagueLabel = computed(() => {
|
const leagueLabel = computed(() => {
|
||||||
@@ -135,12 +244,28 @@ function formatTime(v: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchBetSelectionSummary(
|
||||||
|
row: SettlementBetStats['bets']['items'][number],
|
||||||
|
) {
|
||||||
|
return row.selections
|
||||||
|
.map((leg) => {
|
||||||
|
const market = marketLabel(leg.marketType);
|
||||||
|
const sel = selectionDisplay(leg);
|
||||||
|
return `${market} ${sel} @${leg.odds}`;
|
||||||
|
})
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
if (!matchId.value) return;
|
if (!matchId.value) return;
|
||||||
statsLoading.value = true;
|
statsLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`);
|
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`, {
|
||||||
|
params: { page: betPage.value, pageSize: betPageSize.value },
|
||||||
|
});
|
||||||
stats.value = data.data as SettlementBetStats;
|
stats.value = data.data as SettlementBetStats;
|
||||||
|
betPage.value = stats.value.bets.page;
|
||||||
|
betPageSize.value = stats.value.bets.pageSize;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||||
@@ -149,6 +274,17 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onBetPageChange(page: number) {
|
||||||
|
betPage.value = page;
|
||||||
|
void loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBetPageSizeChange(size: number) {
|
||||||
|
betPageSize.value = size;
|
||||||
|
betPage.value = 1;
|
||||||
|
void loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMatch() {
|
async function loadMatch() {
|
||||||
if (!matchId.value) return;
|
if (!matchId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -166,6 +302,7 @@ async function loadMatch() {
|
|||||||
if (detail.score) {
|
if (detail.score) {
|
||||||
score.value = { ...detail.score };
|
score.value = { ...detail.score };
|
||||||
}
|
}
|
||||||
|
betPage.value = 1;
|
||||||
await loadStats();
|
await loadStats();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
@@ -175,15 +312,91 @@ async function loadMatch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordScore() {
|
const isSettled = computed(() => match.value?.status === 'SETTLED');
|
||||||
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
|
|
||||||
ElMessage.success(t('msg.score_recorded'));
|
async function previewResettlement() {
|
||||||
|
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
|
||||||
|
...score.value,
|
||||||
|
reason: resettleReason.value.trim() || undefined,
|
||||||
|
});
|
||||||
|
resettlePreview.value = data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmResettle() {
|
||||||
|
if (!resettlePreview.value?.batch) return;
|
||||||
|
await api.post(`/admin/resettle/${(resettlePreview.value.batch as { id: string }).id}/confirm`);
|
||||||
|
ElMessage.success(t('msg.resettle_confirmed'));
|
||||||
|
resettlePreview.value = null;
|
||||||
await loadMatch();
|
await loadMatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settlementApiError(e: unknown, fallback: string) {
|
||||||
|
const err = e as { response?: { data?: { error?: string; message?: string } } };
|
||||||
|
const raw = err.response?.data?.error ?? err.response?.data?.message ?? fallback;
|
||||||
|
if (raw === 'Score not recorded' || raw === 'Score not found') {
|
||||||
|
return t('settlement.err_score_not_recorded');
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScore() {
|
||||||
|
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordScore() {
|
||||||
|
try {
|
||||||
|
await saveScore();
|
||||||
|
ElMessage.success(t('msg.score_recorded'));
|
||||||
|
await loadMatch();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(settlementApiError(e, t('msg.save_failed')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreviewItems(page = previewPage.value, pageSize = previewPageSize.value) {
|
||||||
|
const batch = preview.value?.batch as { id: string } | undefined;
|
||||||
|
if (!batch) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/settlement/${batch.id}/preview-items`, {
|
||||||
|
params: { page, pageSize },
|
||||||
|
});
|
||||||
|
if (preview.value) {
|
||||||
|
preview.value = { ...preview.value, items: data.data as PreviewItemsPage };
|
||||||
|
}
|
||||||
|
previewPage.value = (data.data as PreviewItemsPage).page;
|
||||||
|
previewPageSize.value = (data.data as PreviewItemsPage).pageSize;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function previewSettlement() {
|
async function previewSettlement() {
|
||||||
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`);
|
preview.value = null;
|
||||||
preview.value = data.data;
|
previewPage.value = 1;
|
||||||
|
try {
|
||||||
|
await saveScore();
|
||||||
|
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: previewPageSize.value,
|
||||||
|
});
|
||||||
|
preview.value = data.data;
|
||||||
|
const itemsPage = data.data.items as PreviewItemsPage;
|
||||||
|
previewPage.value = itemsPage.page;
|
||||||
|
previewPageSize.value = itemsPage.pageSize;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewPageChange(page: number) {
|
||||||
|
previewPage.value = page;
|
||||||
|
void loadPreviewItems(page, previewPageSize.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewPageSizeChange(size: number) {
|
||||||
|
previewPageSize.value = size;
|
||||||
|
previewPage.value = 1;
|
||||||
|
void loadPreviewItems(1, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
@@ -204,7 +417,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="loading" class="settlement-page page-scroll">
|
<div v-loading="loading" class="settlement-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button size="small" text @click="goBack">← {{ t('settlement.back') }}</el-button>
|
<el-button size="small" text @click="goBack">← {{ t('settlement.back') }}</el-button>
|
||||||
@@ -274,6 +487,18 @@ onMounted(() => {
|
|||||||
<el-button type="primary" size="small" @click="previewSettlement">
|
<el-button type="primary" size="small" @click="previewSettlement">
|
||||||
{{ t('settlement.preview_btn') }}
|
{{ t('settlement.preview_btn') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
|
||||||
|
<template v-if="isSettled">
|
||||||
|
<el-input
|
||||||
|
v-model="resettleReason"
|
||||||
|
size="small"
|
||||||
|
:placeholder="t('settlement.resettle_reason')"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<el-button type="warning" size="small" plain @click="previewResettlement">
|
||||||
|
{{ t('settlement.resettle_preview') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,130 +506,193 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 智能比分弹窗已关闭(见 Settlement.vue git 历史) -->
|
<!-- 智能比分弹窗已关闭(见 Settlement.vue git 历史) -->
|
||||||
|
|
||||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
<el-card v-if="resettlePreview" class="preview-card" shadow="never">
|
||||||
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
|
<div class="preview-title">{{ t('settlement.resettle_preview_title') }}</div>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<div class="pstat">
|
<div class="pstat">
|
||||||
<div class="pstat-value">{{ preview.singleBetCount }}</div>
|
<div class="pstat-value">{{ resettlePreview.affectedCount }}</div>
|
||||||
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
|
<div class="pstat-label">{{ t('settlement.resettle_affected') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<div class="pstat">
|
<div class="pstat">
|
||||||
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
|
<div class="pstat-value pstat-green">{{ resettlePreview.totalTopup }}</div>
|
||||||
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
|
<div class="pstat-label">{{ t('settlement.resettle_topup') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<div class="pstat">
|
<div class="pstat">
|
||||||
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
|
<div class="pstat-value pstat-orange">{{ resettlePreview.totalClawback }}</div>
|
||||||
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
|
<div class="pstat-label">{{ t('settlement.resettle_clawback') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-button type="success" class="confirm-btn" @click="confirm">
|
<el-button type="warning" class="confirm-btn" @click="confirmResettle">
|
||||||
{{ t('settlement.confirm_btn') }}
|
{{ t('settlement.resettle_confirm') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
|
<el-card v-if="preview" class="preview-card preview-card--compact" shadow="never">
|
||||||
<div class="section-title">{{ t('settlement.stats_title') }}</div>
|
<div class="preview-bar">
|
||||||
<template v-if="stats">
|
<span class="preview-bar-title">{{ t('settlement.preview_title') }}</span>
|
||||||
<div class="summary-grid">
|
<div class="preview-metrics">
|
||||||
<div class="sstat">
|
<div class="preview-metric">
|
||||||
<div class="sstat-value">{{ stats.summary.totalBets }}</div>
|
<span class="preview-metric-value">{{ preview.pendingBetCount ?? preview.singleBetCount }}</span>
|
||||||
<div class="sstat-label">{{ t('settlement.stats_total_bets') }}</div>
|
<span class="preview-metric-label">{{ t('settlement.preview_pending_bets') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sstat">
|
<div class="preview-metric">
|
||||||
<div class="sstat-value">{{ stats.summary.singleBets }}</div>
|
<span class="preview-metric-value">{{ preview.singleBetCount }} / {{ preview.parlayBetCount }}</span>
|
||||||
<div class="sstat-label">{{ t('settlement.stats_single') }}</div>
|
<span class="preview-metric-label">{{ t('settlement.preview_bet_mix') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sstat">
|
<div class="preview-metric">
|
||||||
<div class="sstat-value">{{ stats.summary.parlayBets }}</div>
|
<span class="preview-metric-value preview-metric-green">{{ formatAmount(String(preview.totalPayout ?? 0)) }}</span>
|
||||||
<div class="sstat-label">{{ t('settlement.stats_parlay') }}</div>
|
<span class="preview-metric-label">{{ t('settlement.est_payout') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sstat">
|
<div class="preview-metric">
|
||||||
<div class="sstat-value">{{ formatAmount(stats.summary.totalStake) }}</div>
|
<span class="preview-metric-value preview-metric-orange">{{ formatAmount(String(preview.totalRefund ?? 0)) }}</span>
|
||||||
<div class="sstat-label">{{ t('settlement.stats_total_stake') }}</div>
|
<span class="preview-metric-label">{{ t('settlement.refund_amount') }}</span>
|
||||||
</div>
|
|
||||||
<div class="sstat">
|
|
||||||
<div class="sstat-value sstat-muted">{{ formatAmount(stats.summary.totalPotentialReturn) }}</div>
|
|
||||||
<div class="sstat-label">{{ t('settlement.stats_potential') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="statusSummary.length" class="status-chips">
|
<el-button type="success" size="small" @click="confirm">
|
||||||
<el-tag
|
{{ t('settlement.confirm_btn') }}
|
||||||
v-for="st in statusSummary"
|
</el-button>
|
||||||
:key="st"
|
</div>
|
||||||
size="small"
|
<p v-if="previewZeroHint" class="preview-zero-hint">{{ previewZeroHint }}</p>
|
||||||
:type="betStatusTagType(st)"
|
<div v-if="previewItemsPage.total > 0" class="preview-items-wrap">
|
||||||
effect="plain"
|
<div class="preview-items-head">
|
||||||
>
|
<span class="preview-items-title">
|
||||||
{{ betStatusLabel(st) }} {{ stats.summary.statusCounts[st] }}
|
{{ t('settlement.preview_items_title', { n: previewItemsPage.total }) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
|
||||||
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
|
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
|
||||||
<el-table
|
<el-table-column :label="t('common.type')" width="68">
|
||||||
v-if="stats.bySelection.length"
|
|
||||||
:data="stats.bySelection"
|
|
||||||
size="small"
|
|
||||||
stripe
|
|
||||||
class="stats-table"
|
|
||||||
>
|
|
||||||
<el-table-column :label="t('settlement.col.market')" min-width="140">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ marketLabel(row.marketType) }}
|
|
||||||
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('settlement.col.selection')" min-width="160">
|
|
||||||
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('settlement.col.legs')" width="88" align="center" prop="legCount" />
|
|
||||||
<el-table-column :label="t('settlement.col.single_stake')" width="120" align="right">
|
|
||||||
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('settlement.col.parlay_legs')" width="100" align="center" prop="parlayLegCount" />
|
|
||||||
</el-table>
|
|
||||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
|
||||||
|
|
||||||
<div class="subsection-title">{{ t('settlement.bet_list') }} ({{ stats.bets.length }})</div>
|
|
||||||
<el-table
|
|
||||||
v-if="stats.bets.length"
|
|
||||||
:data="stats.bets"
|
|
||||||
size="small"
|
|
||||||
stripe
|
|
||||||
max-height="420"
|
|
||||||
class="stats-table"
|
|
||||||
>
|
|
||||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="150" />
|
|
||||||
<el-table-column prop="username" :label="t('bet.col.player')" width="110" />
|
|
||||||
<el-table-column :label="t('common.type')" width="72">
|
|
||||||
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
|
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('settlement.col.market')" min-width="120">
|
<el-table-column :label="t('settlement.preview_col.result')" width="108">
|
||||||
<template #default="{ row }">{{ marketLabel(row.marketType) }}</template>
|
<template #default="{ row }">{{ previewResultLabel(row.result) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('settlement.col.selection')" min-width="120">
|
<el-table-column :label="t('settlement.est_payout')" width="92" align="right">
|
||||||
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
|
<template #default="{ row }">{{ formatAmount(row.payout) }}</template>
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('bet.col.odds')" width="72" align="right" prop="odds" />
|
|
||||||
<el-table-column :label="t('bet.col.stake')" width="100" align="right">
|
|
||||||
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('common.status')" width="96">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('bet.col.placed_at')" width="130">
|
|
||||||
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.field.remark')" min-width="160" show-overflow-tooltip prop="note" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
<el-pagination
|
||||||
</template>
|
v-if="previewItemsPage.total > 0"
|
||||||
|
class="preview-pager bet-pager"
|
||||||
|
size="small"
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="previewItemsPage.total"
|
||||||
|
:current-page="previewItemsPage.page"
|
||||||
|
:page-size="previewItemsPage.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@current-change="onPreviewPageChange"
|
||||||
|
@size-change="onPreviewPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
|
||||||
|
<div v-if="stats" class="stats-body">
|
||||||
|
<div class="stats-charts">
|
||||||
|
<div v-if="betTypeChartOption" class="mini-chart">
|
||||||
|
<VChart class="mini-chart-canvas" :option="betTypeChartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-if="statusChartOption" class="mini-chart">
|
||||||
|
<VChart class="mini-chart-canvas" :option="statusChartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectionStakeChartOption" class="mini-chart">
|
||||||
|
<VChart class="mini-chart-canvas" :option="selectionStakeChartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-tables">
|
||||||
|
<div class="stats-table-block">
|
||||||
|
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
|
||||||
|
<el-table
|
||||||
|
v-if="stats.bySelection.length"
|
||||||
|
:data="stats.bySelection"
|
||||||
|
size="small"
|
||||||
|
stripe
|
||||||
|
class="stats-table"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<el-table-column :label="t('settlement.col.market')" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ marketLabel(row.marketType) }}
|
||||||
|
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('settlement.col.selection')" min-width="120">
|
||||||
|
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('settlement.col.legs')" width="72" align="center" prop="legCount" />
|
||||||
|
<el-table-column :label="t('settlement.col.single_stake')" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('settlement.col.parlay_legs')" width="88" align="center" prop="parlayLegCount" />
|
||||||
|
</el-table>
|
||||||
|
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-table-block stats-table-block--bets">
|
||||||
|
<div class="subsection-title">
|
||||||
|
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
|
||||||
|
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table
|
||||||
|
v-if="stats.bets.items.length"
|
||||||
|
:data="stats.bets.items"
|
||||||
|
size="small"
|
||||||
|
stripe
|
||||||
|
class="stats-table"
|
||||||
|
>
|
||||||
|
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
|
||||||
|
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
|
||||||
|
<el-table-column :label="t('common.type')" width="72">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ betTypeLabel(row.betType) }}
|
||||||
|
<span v-if="row.legCountOnMatch > 1" class="leg-badge">×{{ row.legCountOnMatch }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('bet.col.content')" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="bet-content-cell">{{ matchBetSelectionSummary(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('bet.col.stake')" width="88" align="right">
|
||||||
|
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.status')" width="88">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('bet.col.placed_at')" width="120">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-if="stats.bets.total > 0"
|
||||||
|
class="bet-pager"
|
||||||
|
size="small"
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="stats.bets.total"
|
||||||
|
:current-page="stats.bets.page"
|
||||||
|
:page-size="stats.bets.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@current-change="onBetPageChange"
|
||||||
|
@size-change="onBetPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -413,11 +701,11 @@ onMounted(() => {
|
|||||||
.settlement-page {
|
.settlement-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
flex: none !important;
|
height: 100%;
|
||||||
min-height: auto !important;
|
min-height: 0;
|
||||||
align-self: flex-start;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -426,6 +714,7 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -449,11 +738,41 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settle-top-card,
|
.settle-top-card,
|
||||||
.stats-card,
|
|
||||||
.preview-card {
|
.preview-card {
|
||||||
|
flex-shrink: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settle-top-card :deep(.el-card__body) {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card :deep(.el-card__body) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.settle-top-row {
|
.settle-top-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -528,54 +847,88 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subsection-title {
|
.subsection-title {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
margin: 18px 0 10px;
|
margin: 0 0 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.subsection-hint {
|
||||||
display: grid;
|
margin-left: 8px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
font-size: 10px;
|
||||||
gap: 12px;
|
font-weight: 400;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstat {
|
.leg-badge {
|
||||||
padding: 12px;
|
margin-left: 4px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
font-size: 10px;
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sstat-value {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sstat-muted {
|
|
||||||
color: var(--green-text, #2fb56a);
|
color: var(--green-text, #2fb56a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstat-label {
|
.bet-content-cell {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: #bbb;
|
||||||
margin-top: 4px;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chips {
|
.stats-charts {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart {
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 4px 6px 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart-canvas {
|
||||||
|
height: 148px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-tables {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table-block {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table-block--bets .table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-pager {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-table {
|
.stats-table {
|
||||||
@@ -635,15 +988,109 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-title {
|
.preview-title {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card--compact :deep(.el-card__body) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-bar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metric-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metric-green {
|
||||||
|
color: var(--green-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metric-orange {
|
||||||
|
color: #e8a040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zero-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(232, 160, 64, 0.12);
|
||||||
|
border: 1px solid rgba(232, 160, 64, 0.25);
|
||||||
|
color: #e8c080;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items-wrap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items-table {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pager {
|
||||||
|
margin-top: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pstat {
|
.pstat {
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid #2a2a2a;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -651,7 +1098,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pstat-value {
|
.pstat-value {
|
||||||
font-size: 26px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
@@ -672,7 +1119,23 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-btn {
|
.confirm-btn {
|
||||||
margin-top: 24px;
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.stats-charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-tables {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table-block {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)
|
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)
|
||||||
|
|||||||
@@ -47,14 +47,48 @@ const editingId = ref('');
|
|||||||
|
|
||||||
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
||||||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||||
|
const bettingLimits = ref({
|
||||||
|
minStake: 1,
|
||||||
|
maxStakeSingle: 50000,
|
||||||
|
maxStakeParlay: 20000,
|
||||||
|
maxPayoutSingle: 500000,
|
||||||
|
maxPayoutParlay: 1000000,
|
||||||
|
dailyStakeLimit: 200000,
|
||||||
|
});
|
||||||
const settingsSaving = ref(false);
|
const settingsSaving = ref(false);
|
||||||
|
const limitsSaving = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAgentOptions();
|
loadAgentOptions();
|
||||||
loadPlayerSettings();
|
loadPlayerSettings();
|
||||||
|
loadBettingLimits();
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadBettingLimits() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/admin/settings/betting-limits');
|
||||||
|
bettingLimits.value = data.data;
|
||||||
|
} catch {
|
||||||
|
/* 使用默认值 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBettingLimits() {
|
||||||
|
limitsSaving.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
|
||||||
|
bettingLimits.value = data.data;
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
loadBettingLimits();
|
||||||
|
} finally {
|
||||||
|
limitsSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPlayerSettings() {
|
async function loadPlayerSettings() {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/admin/users/settings/account');
|
const { data } = await api.get('/admin/users/settings/account');
|
||||||
@@ -312,6 +346,38 @@ function statusLabel(s: string) {
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="settings-card" shadow="never">
|
||||||
|
<div class="global-settings">
|
||||||
|
<span class="settings-title">{{ t('user.betting_limits') }}</span>
|
||||||
|
<span class="settings-desc">{{ t('user.betting_limits_hint') }}</span>
|
||||||
|
<el-form inline size="small" class="settings-form limits-form">
|
||||||
|
<el-form-item :label="t('user.limit.min_stake')">
|
||||||
|
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.limit.max_stake_single')">
|
||||||
|
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.limit.max_stake_parlay')">
|
||||||
|
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.limit.max_payout_single')">
|
||||||
|
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.limit.max_payout_parlay')">
|
||||||
|
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.limit.daily_stake')">
|
||||||
|
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card class="filter-card" shadow="never">
|
<el-card class="filter-card" shadow="never">
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
<el-form-item :label="t('common.keyword')">
|
<el-form-item :label="t('common.keyword')">
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
export interface BetSelectionPreview {
|
||||||
|
matchId: string | null;
|
||||||
|
matchLabel: string;
|
||||||
|
leagueName: string;
|
||||||
|
marketType: string;
|
||||||
|
period: string | null;
|
||||||
|
selectionName: string;
|
||||||
|
odds: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BetListRow {
|
export interface BetListRow {
|
||||||
id: string;
|
id: string;
|
||||||
betNo: string;
|
betNo: string;
|
||||||
@@ -16,11 +26,15 @@ export interface BetListRow {
|
|||||||
placedAt: string;
|
placedAt: string;
|
||||||
settledAt: string | null;
|
settledAt: string | null;
|
||||||
selectionCount: number;
|
selectionCount: number;
|
||||||
|
selectionSummary: string;
|
||||||
|
selectionPreviews: BetSelectionPreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BetSelectionDetail {
|
export interface BetSelectionDetail {
|
||||||
id: string;
|
id: string;
|
||||||
matchId: string | null;
|
matchId: string | null;
|
||||||
|
matchLabel: string;
|
||||||
|
leagueName: string;
|
||||||
marketType: string;
|
marketType: string;
|
||||||
period: string | null;
|
period: string | null;
|
||||||
selectionName: string;
|
selectionName: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": false,
|
||||||
|
"tsConfigPath": "tsconfig.build.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,17 +507,20 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const permCodes = [
|
const permCodes = [
|
||||||
'users.create', 'users.view', 'agents.create', 'agents.view',
|
'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
|
||||||
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
||||||
'cashback.confirm', 'content.manage', 'reports.view',
|
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
|
||||||
|
'bets.view', 'settings.manage', 'audit.view',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const permIds = new Map<string, bigint>();
|
||||||
for (const code of permCodes) {
|
for (const code of permCodes) {
|
||||||
const perm = await prisma.permission.upsert({
|
const perm = await prisma.permission.upsert({
|
||||||
where: { code },
|
where: { code },
|
||||||
create: { code, name: code, module: code.split('.')[0] },
|
create: { code, name: code, module: code.split('.')[0] },
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
|
permIds.set(code, perm.id);
|
||||||
await prisma.rolePermission.upsert({
|
await prisma.rolePermission.upsert({
|
||||||
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
|
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
|
||||||
create: { roleId: superAdminRole.id, permissionId: perm.id },
|
create: { roleId: superAdminRole.id, permissionId: perm.id },
|
||||||
@@ -525,6 +528,49 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureRole(code: string, name: string, permissions: string[]) {
|
||||||
|
const role = await prisma.role.upsert({
|
||||||
|
where: { code },
|
||||||
|
create: { code, name, description: name },
|
||||||
|
update: { name },
|
||||||
|
});
|
||||||
|
for (const p of permissions) {
|
||||||
|
const pid = permIds.get(p);
|
||||||
|
if (!pid) continue;
|
||||||
|
await prisma.rolePermission.upsert({
|
||||||
|
where: { roleId_permissionId: { roleId: role.id, permissionId: pid } },
|
||||||
|
create: { roleId: role.id, permissionId: pid },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureRole('MATCH_ADMIN', 'Match Admin', [
|
||||||
|
'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
|
||||||
|
]);
|
||||||
|
await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
|
||||||
|
'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
|
||||||
|
'reports.view', 'bets.view', 'audit.view',
|
||||||
|
]);
|
||||||
|
await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
|
||||||
|
|
||||||
|
const defaultBettingLimits = [
|
||||||
|
['bet.min_stake', '1', '最小单注金额'],
|
||||||
|
['bet.max_stake_single', '50000', '单关最大投注额'],
|
||||||
|
['bet.max_stake_parlay', '20000', '串关最大投注额'],
|
||||||
|
['bet.max_payout_single', '500000', '单关最高派彩'],
|
||||||
|
['bet.max_payout_parlay', '1000000', '串关最高派彩'],
|
||||||
|
['bet.daily_stake_limit', '200000', '玩家每日投注上限'],
|
||||||
|
] as const;
|
||||||
|
for (const [key, value, desc] of defaultBettingLimits) {
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { configKey: key },
|
||||||
|
create: { configKey: key, configValue: value, description: desc },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const hash = await bcrypt.hash('Admin@123', 10);
|
const hash = await bcrypt.hash('Admin@123', 10);
|
||||||
const agentHash = await bcrypt.hash('Agent@123', 10);
|
const agentHash = await bcrypt.hash('Agent@123', 10);
|
||||||
const playerHash = await bcrypt.hash('Player@123', 10);
|
const playerHash = await bcrypt.hash('Player@123', 10);
|
||||||
|
|||||||
19
apps/api/src/applications/admin/admin-permissions.ts
Normal file
19
apps/api/src/applications/admin/admin-permissions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** 后台权限码 — 与 seed 中 permissions 表一致 */
|
||||||
|
export const P = {
|
||||||
|
reports: 'reports.view',
|
||||||
|
usersView: 'users.view',
|
||||||
|
usersCreate: 'users.create',
|
||||||
|
settings: 'settings.manage',
|
||||||
|
agentsView: 'agents.view',
|
||||||
|
agentsCreate: 'agents.create',
|
||||||
|
agentsCredit: 'agents.credit',
|
||||||
|
walletDeposit: 'wallet.deposit',
|
||||||
|
walletWithdraw: 'wallet.withdraw',
|
||||||
|
matches: 'matches.manage',
|
||||||
|
settlement: 'settlement.confirm',
|
||||||
|
resettle: 'settlement.resettle',
|
||||||
|
bets: 'bets.view',
|
||||||
|
cashback: 'cashback.confirm',
|
||||||
|
content: 'content.manage',
|
||||||
|
audit: 'audit.view',
|
||||||
|
} as const;
|
||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard, AdminGuard } from '../../domains/identity/guards';
|
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
||||||
import { ContentService } from '../../domains/operations/content/content.service';
|
import { ContentService } from '../../domains/operations/content/content.service';
|
||||||
import { CurrentUser } from '../../shared/common/decorators';
|
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
|
||||||
import { jsonResponse } from '../../shared/common/filters';
|
import { jsonResponse } from '../../shared/common/filters';
|
||||||
import { UsersService } from '../../domains/identity/users.service';
|
import { UsersService } from '../../domains/identity/users.service';
|
||||||
import { AgentsService } from '../../domains/agent/agents.service';
|
import { AgentsService } from '../../domains/agent/agents.service';
|
||||||
@@ -27,9 +27,11 @@ import { CashbackService } from '../../domains/operations/cashback/cashback.serv
|
|||||||
import { I18nService } from '../../domains/operations/i18n/i18n.service';
|
import { I18nService } from '../../domains/operations/i18n/i18n.service';
|
||||||
import { AuditService } from '../../domains/operations/audit/audit.service';
|
import { AuditService } from '../../domains/operations/audit/audit.service';
|
||||||
import { BetsService } from '../../domains/betting/bets.service';
|
import { BetsService } from '../../domains/betting/bets.service';
|
||||||
|
import { BettingLimitsService } from '../../domains/betting/betting-limits.service';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
|
import { P } from './admin-permissions';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -341,17 +343,26 @@ function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ScoreDto {
|
class ScoreDto {
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
htHome!: number;
|
htHome?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
htAway!: number;
|
htAway?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
ftHome!: number;
|
ftHome?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
ftAway!: number;
|
ftAway?: number;
|
||||||
|
|
||||||
|
/** 冠军盘结算:获胜球队 ID */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
winnerTeamId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 智能比分推荐已关闭
|
/* 智能比分推荐已关闭
|
||||||
@@ -571,9 +582,67 @@ class CashbackPreviewDto {
|
|||||||
periodEnd!: string;
|
periodEnd!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ResettlePreviewDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
htHome?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
htAway?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
ftHome?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
ftAway?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
winnerTeamId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BettingLimitsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minStake?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxStakeSingle?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxStakeParlay?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxPayoutSingle?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxPayoutParlay?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
dailyStakeLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@ApiTags('Admin')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
@UseGuards(JwtAuthGuard, AdminGuard, PermissionsGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -592,21 +661,25 @@ export class AdminController {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private readonly dashboardService: AdminDashboardService,
|
private readonly dashboardService: AdminDashboardService,
|
||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
|
private bettingLimits: BettingLimitsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
|
@RequirePermissions(P.reports)
|
||||||
async getDashboard() {
|
async getDashboard() {
|
||||||
const overview = await this.dashboardService.getOverview();
|
const overview = await this.dashboardService.getOverview();
|
||||||
return jsonResponse(overview);
|
return jsonResponse(overview);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/settings/account')
|
@Get('users/settings/account')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
async getPlayerAccountSettings() {
|
async getPlayerAccountSettings() {
|
||||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||||
return jsonResponse(settings);
|
return jsonResponse(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/settings/account')
|
@Put('users/settings/account')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
async updatePlayerAccountSettings(
|
async updatePlayerAccountSettings(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Body() dto: PlayerAccountSettingsDto,
|
@Body() dto: PlayerAccountSettingsDto,
|
||||||
@@ -622,7 +695,32 @@ export class AdminController {
|
|||||||
return jsonResponse(settings);
|
return jsonResponse(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('settings/betting-limits')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
|
async getBettingLimits() {
|
||||||
|
const limits = await this.bettingLimits.getLimits();
|
||||||
|
return jsonResponse(limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('settings/betting-limits')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
|
async updateBettingLimits(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Body() dto: BettingLimitsDto,
|
||||||
|
) {
|
||||||
|
const limits = await this.bettingLimits.updateLimits(dto);
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'UPDATE_BETTING_LIMITS',
|
||||||
|
module: 'SETTINGS',
|
||||||
|
afterData: limits,
|
||||||
|
});
|
||||||
|
return jsonResponse(limits);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
|
@RequirePermissions(P.usersView)
|
||||||
async listUsers(
|
async listUsers(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@@ -643,12 +741,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/:id')
|
@Get('users/:id')
|
||||||
|
@RequirePermissions(P.usersView)
|
||||||
async getUserDetail(@Param('id') id: string) {
|
async getUserDetail(@Param('id') id: string) {
|
||||||
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
|
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
|
||||||
return jsonResponse(detail);
|
return jsonResponse(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id')
|
@Put('users/:id')
|
||||||
|
@RequirePermissions(P.usersCreate)
|
||||||
async updateUser(
|
async updateUser(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -666,6 +766,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('users')
|
@Post('users')
|
||||||
|
@RequirePermissions(P.usersCreate)
|
||||||
async createPlayer(
|
async createPlayer(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Body() dto: CreatePlayerAdminDto,
|
@Body() dto: CreatePlayerAdminDto,
|
||||||
@@ -700,6 +801,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/promotable-for-agent')
|
@Get('users/promotable-for-agent')
|
||||||
|
@RequirePermissions(P.usersView)
|
||||||
async listPromotableForAgent(@Query('keyword') keyword?: string) {
|
async listPromotableForAgent(@Query('keyword') keyword?: string) {
|
||||||
const rows = await this.agents.listPromotablePlayers(keyword);
|
const rows = await this.agents.listPromotablePlayers(keyword);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -716,6 +818,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('agents/options')
|
@Get('agents/options')
|
||||||
|
@RequirePermissions(P.agentsView)
|
||||||
async listAgentOptions() {
|
async listAgentOptions() {
|
||||||
const agents = await this.prisma.user.findMany({
|
const agents = await this.prisma.user.findMany({
|
||||||
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
|
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
|
||||||
@@ -728,6 +831,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('agents')
|
@Get('agents')
|
||||||
|
@RequirePermissions(P.agentsView)
|
||||||
async listAgents(
|
async listAgents(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@@ -742,12 +846,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('agents/:id')
|
@Get('agents/:id')
|
||||||
|
@RequirePermissions(P.agentsView)
|
||||||
async getAgentDetail(@Param('id') id: string) {
|
async getAgentDetail(@Param('id') id: string) {
|
||||||
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
|
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
|
||||||
return jsonResponse(detail);
|
return jsonResponse(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('agents/:id')
|
@Put('agents/:id')
|
||||||
|
@RequirePermissions(P.agentsCreate)
|
||||||
async updateAgent(
|
async updateAgent(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -765,6 +871,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('agents')
|
@Post('agents')
|
||||||
|
@RequirePermissions(P.agentsCreate)
|
||||||
async createAgent(
|
async createAgent(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Body() dto: CreateAgentAdminDto,
|
@Body() dto: CreateAgentAdminDto,
|
||||||
@@ -787,6 +894,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('agents/:id/credit')
|
@Post('agents/:id/credit')
|
||||||
|
@RequirePermissions(P.agentsCredit)
|
||||||
async adjustCredit(
|
async adjustCredit(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -803,6 +911,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('wallet/deposit')
|
@Post('wallet/deposit')
|
||||||
|
@RequirePermissions(P.walletDeposit)
|
||||||
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||||
const result = await this.wallet.deposit(
|
const result = await this.wallet.deposit(
|
||||||
BigInt(dto.userId),
|
BigInt(dto.userId),
|
||||||
@@ -815,6 +924,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('wallet/withdraw')
|
@Post('wallet/withdraw')
|
||||||
|
@RequirePermissions(P.walletWithdraw)
|
||||||
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||||
const result = await this.wallet.withdraw(
|
const result = await this.wallet.withdraw(
|
||||||
BigInt(dto.userId),
|
BigInt(dto.userId),
|
||||||
@@ -827,12 +937,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('wallet/transactions')
|
@Get('wallet/transactions')
|
||||||
|
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
|
||||||
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
|
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
|
||||||
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
|
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('leagues')
|
@Post('leagues')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async createLeague(
|
async createLeague(
|
||||||
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
|
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
@@ -853,6 +965,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('leagues')
|
@Get('leagues')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async listLeagues(
|
async listLeagues(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@@ -871,6 +984,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('leagues/:leagueId/matches')
|
@Get('leagues/:leagueId/matches')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async listLeagueMatches(
|
async listLeagueMatches(
|
||||||
@Param('leagueId') leagueId: string,
|
@Param('leagueId') leagueId: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@@ -886,12 +1000,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('teams')
|
@Post('teams')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||||
const team = await this.matches.createTeam(dto.code, dto.translations);
|
const team = await this.matches.createTeam(dto.code, dto.translations);
|
||||||
return jsonResponse(team);
|
return jsonResponse(team);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('matches')
|
@Get('matches')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async listMatches(
|
async listMatches(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@@ -928,12 +1044,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('matches/:id')
|
@Get('matches/:id')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async getMatch(@Param('id') id: string) {
|
async getMatch(@Param('id') id: string) {
|
||||||
const match = await this.matches.getAdminMatchDetail(BigInt(id));
|
const match = await this.matches.getAdminMatchDetail(BigInt(id));
|
||||||
return jsonResponse(match);
|
return jsonResponse(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('matches/:id')
|
@Put('matches/:id')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateMatch(
|
async updateMatch(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -964,12 +1082,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('matches/:id')
|
@Delete('matches/:id')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async deleteMatch(@Param('id') id: string) {
|
async deleteMatch(@Param('id') id: string) {
|
||||||
await this.matches.deleteMatch(BigInt(id));
|
await this.matches.deleteMatch(BigInt(id));
|
||||||
return jsonResponse({ deleted: true });
|
return jsonResponse({ deleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches')
|
@Post('matches')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
||||||
const match = await this.matches.createPlatformMatch({
|
const match = await this.matches.createPlatformMatch({
|
||||||
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
|
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
|
||||||
@@ -997,6 +1117,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches/import')
|
@Post('matches/import')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
|
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
|
||||||
if (!isZhiboBundlePayload(dto)) {
|
if (!isZhiboBundlePayload(dto)) {
|
||||||
throw new BadRequestException('Invalid import payload: matches[] required');
|
throw new BadRequestException('Invalid import payload: matches[] required');
|
||||||
@@ -1006,18 +1127,21 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches/:id/publish')
|
@Post('matches/:id/publish')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async publishMatch(@Param('id') id: string) {
|
async publishMatch(@Param('id') id: string) {
|
||||||
const match = await this.matches.publishMatch(BigInt(id));
|
const match = await this.matches.publishMatch(BigInt(id));
|
||||||
return jsonResponse(match);
|
return jsonResponse(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches/:id/close')
|
@Post('matches/:id/close')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async closeMatch(@Param('id') id: string) {
|
async closeMatch(@Param('id') id: string) {
|
||||||
const match = await this.matches.closeMatch(BigInt(id));
|
const match = await this.matches.closeMatch(BigInt(id));
|
||||||
return jsonResponse(match);
|
return jsonResponse(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches/:id/cancel')
|
@Post('matches/:id/cancel')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async cancelMatch(@Param('id') id: string) {
|
async cancelMatch(@Param('id') id: string) {
|
||||||
await this.matches.cancelMatch(BigInt(id));
|
await this.matches.cancelMatch(BigInt(id));
|
||||||
const voided = await this.settlement.voidMatchBets(BigInt(id));
|
const voided = await this.settlement.voidMatchBets(BigInt(id));
|
||||||
@@ -1025,12 +1149,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches/:id/markets/templates')
|
@Post('matches/:id/markets/templates')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
|
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
|
||||||
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
|
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
|
||||||
return jsonResponse(markets);
|
return jsonResponse(markets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('matches/:id/odds')
|
@Put('matches/:id/odds')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async batchUpdateMatchOdds(
|
async batchUpdateMatchOdds(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -1045,6 +1171,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('markets/:id')
|
@Patch('markets/:id')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
||||||
const market = await this.markets.updateMarket(BigInt(id), {
|
const market = await this.markets.updateMarket(BigInt(id), {
|
||||||
promoLabel: dto.promoLabel,
|
promoLabel: dto.promoLabel,
|
||||||
@@ -1055,6 +1182,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('selections/:id')
|
@Patch('selections/:id')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateSelection(
|
async updateSelection(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -1073,6 +1201,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('selections/:id/odds')
|
@Put('selections/:id/odds')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateOdds(
|
async updateOdds(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -1083,18 +1212,21 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('outrights')
|
@Get('outrights')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async listOutrights() {
|
async listOutrights() {
|
||||||
const data = await this.outright.listForAdmin();
|
const data = await this.outright.listForAdmin();
|
||||||
return jsonResponse(data);
|
return jsonResponse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('outrights/leagues')
|
@Get('outrights/leagues')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async listOutrightLeagues() {
|
async listOutrightLeagues() {
|
||||||
const data = await this.outright.listLeagueOptions();
|
const data = await this.outright.listLeagueOptions();
|
||||||
return jsonResponse(data);
|
return jsonResponse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('outrights')
|
@Post('outrights')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async createOutright(@Body() dto: CreateOutrightDto) {
|
async createOutright(@Body() dto: CreateOutrightDto) {
|
||||||
const data = await this.outright.createForAdmin({
|
const data = await this.outright.createForAdmin({
|
||||||
leagueId: BigInt(dto.leagueId),
|
leagueId: BigInt(dto.leagueId),
|
||||||
@@ -1107,6 +1239,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('outrights/import/wc2026')
|
@Post('outrights/import/wc2026')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async importWc2026Outright() {
|
async importWc2026Outright() {
|
||||||
const data = await this.outright.importWc2026Canonical();
|
const data = await this.outright.importWc2026Canonical();
|
||||||
return jsonResponse(data);
|
return jsonResponse(data);
|
||||||
@@ -1114,6 +1247,7 @@ export class AdminController {
|
|||||||
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
@Get('outrights/wc2026')
|
@Get('outrights/wc2026')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async getWc2026OutrightLegacy() {
|
async getWc2026OutrightLegacy() {
|
||||||
const list = await this.outright.listForAdmin();
|
const list = await this.outright.listForAdmin();
|
||||||
const wc = list.find((e) => e.leagueCode === 'WC2026');
|
const wc = list.find((e) => e.leagueCode === 'WC2026');
|
||||||
@@ -1123,6 +1257,7 @@ export class AdminController {
|
|||||||
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
@Put('outrights/wc2026/odds')
|
@Put('outrights/wc2026/odds')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateWc2026OutrightOddsLegacy(
|
async updateWc2026OutrightOddsLegacy(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Body() dto: BatchOutrightOddsDto,
|
@Body() dto: BatchOutrightOddsDto,
|
||||||
@@ -1137,17 +1272,20 @@ export class AdminController {
|
|||||||
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
@Post('outrights/wc2026/apply-canonical')
|
@Post('outrights/wc2026/apply-canonical')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async applyWc2026CanonicalLegacy() {
|
async applyWc2026CanonicalLegacy() {
|
||||||
return jsonResponse(await this.outright.importWc2026Canonical());
|
return jsonResponse(await this.outright.importWc2026Canonical());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('outrights/:matchId')
|
@Get('outrights/:matchId')
|
||||||
|
@RequirePermissions(P.matches, P.reports)
|
||||||
async getOutright(@Param('matchId') matchId: string) {
|
async getOutright(@Param('matchId') matchId: string) {
|
||||||
const data = await this.outright.getForAdmin(BigInt(matchId));
|
const data = await this.outright.getForAdmin(BigInt(matchId));
|
||||||
return jsonResponse(data);
|
return jsonResponse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('outrights/:matchId')
|
@Put('outrights/:matchId')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateOutright(
|
async updateOutright(
|
||||||
@Param('matchId') matchId: string,
|
@Param('matchId') matchId: string,
|
||||||
@Body() dto: UpdateOutrightDto,
|
@Body() dto: UpdateOutrightDto,
|
||||||
@@ -1157,6 +1295,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('outrights/:matchId/odds')
|
@Put('outrights/:matchId/odds')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateOutrightOdds(
|
async updateOutrightOdds(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('matchId') matchId: string,
|
@Param('matchId') matchId: string,
|
||||||
@@ -1171,6 +1310,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('outrights/:matchId/selections')
|
@Post('outrights/:matchId/selections')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async addOutrightSelection(
|
async addOutrightSelection(
|
||||||
@Param('matchId') matchId: string,
|
@Param('matchId') matchId: string,
|
||||||
@Body() dto: AddOutrightSelectionDto,
|
@Body() dto: AddOutrightSelectionDto,
|
||||||
@@ -1180,6 +1320,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('outrights/:matchId/selections/:selectionId')
|
@Patch('outrights/:matchId/selections/:selectionId')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async updateOutrightSelectionTeam(
|
async updateOutrightSelectionTeam(
|
||||||
@Param('matchId') matchId: string,
|
@Param('matchId') matchId: string,
|
||||||
@Param('selectionId') selectionId: string,
|
@Param('selectionId') selectionId: string,
|
||||||
@@ -1194,6 +1335,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('outrights/:matchId/selections/:selectionId')
|
@Delete('outrights/:matchId/selections/:selectionId')
|
||||||
|
@RequirePermissions(P.matches)
|
||||||
async removeOutrightSelection(
|
async removeOutrightSelection(
|
||||||
@Param('matchId') matchId: string,
|
@Param('matchId') matchId: string,
|
||||||
@Param('selectionId') selectionId: string,
|
@Param('selectionId') selectionId: string,
|
||||||
@@ -1206,8 +1348,16 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('matches/:id/settlement/stats')
|
@Get('matches/:id/settlement/stats')
|
||||||
async getMatchSettlementStats(@Param('id') id: string) {
|
@RequirePermissions(P.settlement, P.reports)
|
||||||
const data = await this.settlement.getMatchBetStats(BigInt(id));
|
async getMatchSettlementStats(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const data = await this.settlement.getMatchBetStats(BigInt(id), {
|
||||||
|
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
|
||||||
|
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
|
||||||
|
});
|
||||||
return jsonResponse(data);
|
return jsonResponse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,6 +1366,7 @@ export class AdminController {
|
|||||||
// async suggestSmartScore(...) { ... }
|
// async suggestSmartScore(...) { ... }
|
||||||
|
|
||||||
@Post('matches/:id/settlement/score')
|
@Post('matches/:id/settlement/score')
|
||||||
|
@RequirePermissions(P.settlement)
|
||||||
async recordScore(
|
async recordScore(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -1223,28 +1374,99 @@ export class AdminController {
|
|||||||
) {
|
) {
|
||||||
const result = await this.settlement.recordScore(
|
const result = await this.settlement.recordScore(
|
||||||
BigInt(id),
|
BigInt(id),
|
||||||
dto.htHome,
|
dto.htHome ?? 0,
|
||||||
dto.htAway,
|
dto.htAway ?? 0,
|
||||||
dto.ftHome,
|
dto.ftHome ?? 0,
|
||||||
dto.ftAway,
|
dto.ftAway ?? 0,
|
||||||
operatorId,
|
operatorId,
|
||||||
|
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||||
);
|
);
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('matches/:id/settlement/preview')
|
@Post('matches/:id/settlement/preview')
|
||||||
async settlementPreview(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
|
@RequirePermissions(P.settlement)
|
||||||
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId);
|
async settlementPreview(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto?: { page?: number; pageSize?: number },
|
||||||
|
) {
|
||||||
|
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId, {
|
||||||
|
page: dto?.page ? Math.max(1, dto.page) : 1,
|
||||||
|
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
|
||||||
|
});
|
||||||
return jsonResponse(preview);
|
return jsonResponse(preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('settlement/:batchId/preview-items')
|
||||||
|
@RequirePermissions(P.settlement)
|
||||||
|
async getSettlementPreviewItems(
|
||||||
|
@Param('batchId') batchId: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const data = await this.settlement.getPreviewSettlementItems(BigInt(batchId), {
|
||||||
|
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
|
||||||
|
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
|
||||||
|
});
|
||||||
|
return jsonResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('settlement/:batchId/confirm')
|
@Post('settlement/:batchId/confirm')
|
||||||
|
@RequirePermissions(P.settlement)
|
||||||
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||||
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
|
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'CONFIRM_SETTLEMENT',
|
||||||
|
module: 'SETTLEMENT',
|
||||||
|
targetId: batchId,
|
||||||
|
});
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('matches/:id/resettle/preview')
|
||||||
|
@RequirePermissions(P.resettle)
|
||||||
|
async resettlePreview(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: ResettlePreviewDto,
|
||||||
|
) {
|
||||||
|
const preview = await this.settlement.previewResettlement(
|
||||||
|
BigInt(id),
|
||||||
|
{
|
||||||
|
htHome: dto.htHome ?? 0,
|
||||||
|
htAway: dto.htAway ?? 0,
|
||||||
|
ftHome: dto.ftHome ?? 0,
|
||||||
|
ftAway: dto.ftAway ?? 0,
|
||||||
|
},
|
||||||
|
operatorId,
|
||||||
|
dto.reason,
|
||||||
|
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||||
|
);
|
||||||
|
return jsonResponse(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('resettle/:batchId/confirm')
|
||||||
|
@RequirePermissions(P.resettle)
|
||||||
|
async confirmResettlement(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('batchId') batchId: string,
|
||||||
|
) {
|
||||||
|
const result = await this.settlement.confirmResettlement(BigInt(batchId), operatorId);
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'CONFIRM_RESETTLE',
|
||||||
|
module: 'SETTLEMENT',
|
||||||
|
targetId: batchId,
|
||||||
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bets')
|
@Get('bets')
|
||||||
|
@RequirePermissions(P.bets)
|
||||||
async listBets(
|
async listBets(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@@ -1267,12 +1489,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bets/:id')
|
@Get('bets/:id')
|
||||||
|
@RequirePermissions(P.bets)
|
||||||
async getBet(@Param('id') id: string) {
|
async getBet(@Param('id') id: string) {
|
||||||
const detail = await this.bets.getBetAdminDetail(BigInt(id));
|
const detail = await this.bets.getBetAdminDetail(BigInt(id));
|
||||||
return jsonResponse(detail);
|
return jsonResponse(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('cashbacks/preview')
|
@Post('cashbacks/preview')
|
||||||
|
@RequirePermissions(P.cashback, P.reports)
|
||||||
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
|
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
|
||||||
const preview = await this.cashback.previewBatch(
|
const preview = await this.cashback.previewBatch(
|
||||||
new Date(dto.periodStart),
|
new Date(dto.periodStart),
|
||||||
@@ -1282,12 +1506,21 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('cashbacks/:batchId/confirm')
|
@Post('cashbacks/:batchId/confirm')
|
||||||
|
@RequirePermissions(P.cashback)
|
||||||
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||||
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
|
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'CONFIRM_CASHBACK',
|
||||||
|
module: 'CASHBACK',
|
||||||
|
targetId: batchId,
|
||||||
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('contents')
|
@Get('contents')
|
||||||
|
@RequirePermissions(P.content, P.reports)
|
||||||
async listContents(
|
async listContents(
|
||||||
@Query('type') type?: string,
|
@Query('type') type?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@@ -1297,24 +1530,28 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('contents/:id')
|
@Get('contents/:id')
|
||||||
|
@RequirePermissions(P.content, P.reports)
|
||||||
async getContent(@Param('id') id: string) {
|
async getContent(@Param('id') id: string) {
|
||||||
const item = await this.content.getForAdmin(BigInt(id));
|
const item = await this.content.getForAdmin(BigInt(id));
|
||||||
return jsonResponse(item);
|
return jsonResponse(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('contents')
|
@Post('contents')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
async createContent(@Body() dto: CreateContentDto) {
|
async createContent(@Body() dto: CreateContentDto) {
|
||||||
const item = await this.content.create(dto);
|
const item = await this.content.create(dto);
|
||||||
return jsonResponse(item);
|
return jsonResponse(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('contents/:id')
|
@Put('contents/:id')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
|
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
|
||||||
const item = await this.content.update(BigInt(id), dto);
|
const item = await this.content.update(BigInt(id), dto);
|
||||||
return jsonResponse(item);
|
return jsonResponse(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('contents/:id/status')
|
@Patch('contents/:id/status')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
async updateContentStatus(
|
async updateContentStatus(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: ContentStatusDto,
|
@Body() dto: ContentStatusDto,
|
||||||
@@ -1324,18 +1561,21 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('contents/:id')
|
@Delete('contents/:id')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
async deleteContent(@Param('id') id: string) {
|
async deleteContent(@Param('id') id: string) {
|
||||||
const result = await this.content.remove(BigInt(id));
|
const result = await this.content.remove(BigInt(id));
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('i18n/messages')
|
@Get('i18n/messages')
|
||||||
|
@RequirePermissions(P.settings, P.reports)
|
||||||
async getMessages(@Query('locale') locale = 'en-US') {
|
async getMessages(@Query('locale') locale = 'en-US') {
|
||||||
const messages = await this.i18n.getMessages(locale);
|
const messages = await this.i18n.getMessages(locale);
|
||||||
return jsonResponse(messages);
|
return jsonResponse(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('audit-logs')
|
@Get('audit-logs')
|
||||||
|
@RequirePermissions(P.audit)
|
||||||
async auditLogs(
|
async auditLogs(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
|
import { PermissionsGuard } from '../../domains/identity/guards';
|
||||||
import { UsersModule } from '../../domains/identity/users.module';
|
import { UsersModule } from '../../domains/identity/users.module';
|
||||||
import { AgentsModule } from '../../domains/agent/agents.module';
|
import { AgentsModule } from '../../domains/agent/agents.module';
|
||||||
import { WalletModule } from '../../domains/ledger/wallet.module';
|
import { WalletModule } from '../../domains/ledger/wallet.module';
|
||||||
@@ -26,6 +27,6 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
|||||||
BetsModule,
|
BetsModule,
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminDashboardService],
|
providers: [AdminDashboardService, PermissionsGuard],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BetsService } from './bets.service';
|
import { BetsService } from './bets.service';
|
||||||
|
import { BettingLimitsService } from './betting-limits.service';
|
||||||
import { WalletModule } from '../ledger/wallet.module';
|
import { WalletModule } from '../ledger/wallet.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WalletModule],
|
imports: [WalletModule],
|
||||||
providers: [BetsService],
|
providers: [BetsService, BettingLimitsService],
|
||||||
exports: [BetsService],
|
exports: [BetsService, BettingLimitsService],
|
||||||
})
|
})
|
||||||
export class BetsModule {}
|
export class BetsModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, BadRequestException, ConflictException, NotFoundException }
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { WalletService } from '../ledger/wallet.service';
|
import { WalletService } from '../ledger/wallet.service';
|
||||||
|
import { BettingLimitsService } from './betting-limits.service';
|
||||||
import { Decimal } from '@prisma/client/runtime/library';
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
import { generateBetNo } from '../../shared/common/decorators';
|
import { generateBetNo } from '../../shared/common/decorators';
|
||||||
import {
|
import {
|
||||||
@@ -10,8 +11,25 @@ import {
|
|||||||
canSelectForParlay,
|
canSelectForParlay,
|
||||||
isPreMatchKickoff,
|
isPreMatchKickoff,
|
||||||
isSupportedSport,
|
isSupportedSport,
|
||||||
|
resolveTranslationFallback,
|
||||||
} from '@thebet365/shared';
|
} from '@thebet365/shared';
|
||||||
|
|
||||||
|
type MatchContext = {
|
||||||
|
matchLabel: string;
|
||||||
|
leagueName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BetSelectionRow = {
|
||||||
|
matchId: bigint | null;
|
||||||
|
marketType: string;
|
||||||
|
period: string | null;
|
||||||
|
selectionNameSnapshot: string;
|
||||||
|
handicapLine: Decimal | null;
|
||||||
|
totalLine: Decimal | null;
|
||||||
|
odds: Decimal;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface BetSelectionInput {
|
interface BetSelectionInput {
|
||||||
selectionId: bigint;
|
selectionId: bigint;
|
||||||
oddsVersion: bigint;
|
oddsVersion: bigint;
|
||||||
@@ -23,6 +41,7 @@ export class BetsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private wallet: WalletService,
|
private wallet: WalletService,
|
||||||
|
private bettingLimits: BettingLimitsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async validateSelection(
|
private async validateSelection(
|
||||||
@@ -92,6 +111,12 @@ export class BetsService {
|
|||||||
const odds = new Decimal(selection.odds.toString());
|
const odds = new Decimal(selection.odds.toString());
|
||||||
const stakeDec = new Decimal(stake);
|
const stakeDec = new Decimal(stake);
|
||||||
const potentialReturn = stakeDec.mul(odds);
|
const potentialReturn = stakeDec.mul(odds);
|
||||||
|
await this.bettingLimits.validateBet({
|
||||||
|
userId,
|
||||||
|
betType: 'SINGLE',
|
||||||
|
stake,
|
||||||
|
potentialReturn,
|
||||||
|
});
|
||||||
const betNo = generateBetNo();
|
const betNo = generateBetNo();
|
||||||
|
|
||||||
const bet = await this.prisma.$transaction(async (tx) => {
|
const bet = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -148,16 +173,9 @@ export class BetsService {
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||||
const matchIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const leg of legs) {
|
for (const leg of legs) {
|
||||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
||||||
|
|
||||||
const matchKey = sel.market.matchId.toString();
|
|
||||||
if (matchIds.has(matchKey)) {
|
|
||||||
throw new BadRequestException('Same match cannot be in parlay');
|
|
||||||
}
|
|
||||||
matchIds.add(matchKey);
|
|
||||||
selections.push(sel);
|
selections.push(sel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +186,12 @@ export class BetsService {
|
|||||||
|
|
||||||
const stakeDec = new Decimal(stake);
|
const stakeDec = new Decimal(stake);
|
||||||
const potentialReturn = stakeDec.mul(totalOdds);
|
const potentialReturn = stakeDec.mul(totalOdds);
|
||||||
|
await this.bettingLimits.validateBet({
|
||||||
|
userId,
|
||||||
|
betType: 'PARLAY',
|
||||||
|
stake,
|
||||||
|
potentialReturn,
|
||||||
|
});
|
||||||
const betNo = generateBetNo();
|
const betNo = generateBetNo();
|
||||||
|
|
||||||
const bet = await this.prisma.$transaction(async (tx) => {
|
const bet = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -234,6 +258,105 @@ export class BetsService {
|
|||||||
return v?.toString() ?? '0';
|
return v?.toString() ?? '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveEntityName(
|
||||||
|
translations: Array<{ entityType: string; entityId: bigint; locale: string; value: string }>,
|
||||||
|
entityType: string,
|
||||||
|
entityId: bigint,
|
||||||
|
locale: string,
|
||||||
|
) {
|
||||||
|
const map = Object.fromEntries(
|
||||||
|
translations
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.entityType === entityType && t.entityId.toString() === entityId.toString(),
|
||||||
|
)
|
||||||
|
.map((t) => [t.locale, t.value]),
|
||||||
|
);
|
||||||
|
return resolveTranslationFallback(map, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadMatchContext(
|
||||||
|
matchIds: bigint[],
|
||||||
|
locale = 'zh-CN',
|
||||||
|
): Promise<Map<string, MatchContext>> {
|
||||||
|
const result = new Map<string, MatchContext>();
|
||||||
|
if (matchIds.length === 0) return result;
|
||||||
|
|
||||||
|
const matches = await this.prisma.match.findMany({
|
||||||
|
where: { id: { in: matchIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
matchName: true,
|
||||||
|
leagueId: true,
|
||||||
|
homeTeamId: true,
|
||||||
|
awayTeamId: true,
|
||||||
|
homeTeam: { select: { code: true } },
|
||||||
|
awayTeam: { select: { code: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entityIds = new Set<bigint>();
|
||||||
|
for (const m of matches) {
|
||||||
|
entityIds.add(m.leagueId);
|
||||||
|
entityIds.add(m.homeTeamId);
|
||||||
|
entityIds.add(m.awayTeamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations =
|
||||||
|
entityIds.size > 0
|
||||||
|
? await this.prisma.entityTranslation.findMany({
|
||||||
|
where: {
|
||||||
|
entityId: { in: Array.from(entityIds) },
|
||||||
|
entityType: { in: ['TEAM', 'LEAGUE'] },
|
||||||
|
fieldName: 'name',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
const leagueName =
|
||||||
|
this.resolveEntityName(translations, 'LEAGUE', m.leagueId, locale) || m.leagueId.toString();
|
||||||
|
const homeName =
|
||||||
|
this.resolveEntityName(translations, 'TEAM', m.homeTeamId, locale) || m.homeTeam.code;
|
||||||
|
const awayName =
|
||||||
|
this.resolveEntityName(translations, 'TEAM', m.awayTeamId, locale) || m.awayTeam.code;
|
||||||
|
const matchLabel = m.matchName?.trim() || `${homeName} vs ${awayName}`;
|
||||||
|
result.set(m.id.toString(), { matchLabel, leagueName });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatSelectionPreviews(
|
||||||
|
selections: BetSelectionRow[],
|
||||||
|
matchContext: Map<string, MatchContext>,
|
||||||
|
) {
|
||||||
|
return selections.map((s) => {
|
||||||
|
const ctx = s.matchId ? matchContext.get(s.matchId.toString()) : undefined;
|
||||||
|
return {
|
||||||
|
matchId: s.matchId?.toString() ?? null,
|
||||||
|
matchLabel: ctx?.matchLabel ?? '—',
|
||||||
|
leagueName: ctx?.leagueName ?? '',
|
||||||
|
marketType: s.marketType,
|
||||||
|
period: s.period,
|
||||||
|
selectionName: s.selectionNameSnapshot,
|
||||||
|
odds: this.dec(s.odds),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachSelectionPreviews<T extends Record<string, unknown>>(
|
||||||
|
row: T,
|
||||||
|
selections: BetSelectionRow[],
|
||||||
|
matchContext: Map<string, MatchContext>,
|
||||||
|
) {
|
||||||
|
const selectionPreviews = this.formatSelectionPreviews(selections, matchContext);
|
||||||
|
const selectionSummary = selectionPreviews
|
||||||
|
.map((p) => `${p.matchLabel} · ${p.selectionName}`)
|
||||||
|
.join(';');
|
||||||
|
return { ...row, selectionPreviews, selectionSummary };
|
||||||
|
}
|
||||||
|
|
||||||
private formatBetListRow(
|
private formatBetListRow(
|
||||||
b: {
|
b: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -326,7 +449,7 @@ export class BetsService {
|
|||||||
parent: { select: { username: true } },
|
parent: { select: { username: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
_count: { select: { selections: true } },
|
selections: { orderBy: { sortOrder: 'asc' } },
|
||||||
},
|
},
|
||||||
orderBy: { placedAt: 'desc' },
|
orderBy: { placedAt: 'desc' },
|
||||||
skip,
|
skip,
|
||||||
@@ -335,8 +458,26 @@ export class BetsService {
|
|||||||
this.prisma.bet.count({ where }),
|
this.prisma.bet.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const matchIds = [
|
||||||
|
...new Set(
|
||||||
|
items.flatMap((b) =>
|
||||||
|
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const matchContext = await this.loadMatchContext(matchIds);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map((b) => this.formatBetListRow(b)),
|
items: items.map((b) =>
|
||||||
|
this.attachSelectionPreviews(
|
||||||
|
this.formatBetListRow({
|
||||||
|
...b,
|
||||||
|
_count: { selections: b.selections.length },
|
||||||
|
}),
|
||||||
|
b.selections,
|
||||||
|
matchContext,
|
||||||
|
),
|
||||||
|
),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -359,27 +500,44 @@ export class BetsService {
|
|||||||
});
|
});
|
||||||
if (!bet) throw new NotFoundException('注单不存在');
|
if (!bet) throw new NotFoundException('注单不存在');
|
||||||
|
|
||||||
return {
|
const matchIds = bet.selections
|
||||||
...this.formatBetListRow({
|
.map((s) => s.matchId)
|
||||||
|
.filter((id): id is bigint => id != null);
|
||||||
|
const matchContext = await this.loadMatchContext(matchIds);
|
||||||
|
const selectionPreviews = this.formatSelectionPreviews(bet.selections, matchContext);
|
||||||
|
|
||||||
|
const base = this.attachSelectionPreviews(
|
||||||
|
this.formatBetListRow({
|
||||||
...bet,
|
...bet,
|
||||||
_count: { selections: bet.selections.length },
|
_count: { selections: bet.selections.length },
|
||||||
}),
|
}),
|
||||||
|
bet.selections,
|
||||||
|
matchContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
requestId: bet.requestId,
|
requestId: bet.requestId,
|
||||||
createdAt: bet.createdAt,
|
createdAt: bet.createdAt,
|
||||||
updatedAt: bet.updatedAt,
|
updatedAt: bet.updatedAt,
|
||||||
selections: bet.selections.map((s) => ({
|
selections: bet.selections.map((s, index) => {
|
||||||
id: s.id.toString(),
|
const preview = selectionPreviews[index];
|
||||||
matchId: s.matchId?.toString() ?? null,
|
return {
|
||||||
marketType: s.marketType,
|
id: s.id.toString(),
|
||||||
period: s.period,
|
matchId: s.matchId?.toString() ?? null,
|
||||||
selectionName: s.selectionNameSnapshot,
|
matchLabel: preview?.matchLabel ?? '—',
|
||||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
leagueName: preview?.leagueName ?? '',
|
||||||
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
marketType: s.marketType,
|
||||||
odds: this.dec(s.odds),
|
period: s.period,
|
||||||
resultStatus: s.resultStatus,
|
selectionName: s.selectionNameSnapshot,
|
||||||
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||||
sortOrder: s.sortOrder,
|
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
||||||
})),
|
odds: this.dec(s.odds),
|
||||||
|
resultStatus: s.resultStatus,
|
||||||
|
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/api/src/domains/betting/betting-limits.service.spec.ts
Normal file
54
apps/api/src/domains/betting/betting-limits.service.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { BettingLimitsService } from './betting-limits.service';
|
||||||
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
|
describe('BettingLimitsService', () => {
|
||||||
|
const prisma = {
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
bet: {
|
||||||
|
aggregate: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new BettingLimitsService(prisma as never);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
prisma.systemConfig.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.bet.aggregate.mockResolvedValue({ _sum: { stake: new Decimal(0) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stake below minimum', async () => {
|
||||||
|
await expect(
|
||||||
|
service.validateBet({
|
||||||
|
userId: BigInt(1),
|
||||||
|
betType: 'SINGLE',
|
||||||
|
stake: 0.5,
|
||||||
|
potentialReturn: new Decimal(1),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Minimum stake is 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stake above single max', async () => {
|
||||||
|
await expect(
|
||||||
|
service.validateBet({
|
||||||
|
userId: BigInt(1),
|
||||||
|
betType: 'SINGLE',
|
||||||
|
stake: 60000,
|
||||||
|
potentialReturn: new Decimal(70000),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Maximum stake is 50000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects potential return above payout cap', async () => {
|
||||||
|
await expect(
|
||||||
|
service.validateBet({
|
||||||
|
userId: BigInt(1),
|
||||||
|
betType: 'SINGLE',
|
||||||
|
stake: 100,
|
||||||
|
potentialReturn: new Decimal(600000),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Potential return exceeds limit');
|
||||||
|
});
|
||||||
|
});
|
||||||
126
apps/api/src/domains/betting/betting-limits.service.ts
Normal file
126
apps/api/src/domains/betting/betting-limits.service.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
|
|
||||||
|
export type BettingLimits = {
|
||||||
|
minStake: number;
|
||||||
|
maxStakeSingle: number;
|
||||||
|
maxStakeParlay: number;
|
||||||
|
maxPayoutSingle: number;
|
||||||
|
maxPayoutParlay: number;
|
||||||
|
dailyStakeLimit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BET_LIMIT_KEYS = {
|
||||||
|
minStake: 'bet.min_stake',
|
||||||
|
maxStakeSingle: 'bet.max_stake_single',
|
||||||
|
maxStakeParlay: 'bet.max_stake_parlay',
|
||||||
|
maxPayoutSingle: 'bet.max_payout_single',
|
||||||
|
maxPayoutParlay: 'bet.max_payout_parlay',
|
||||||
|
dailyStakeLimit: 'bet.daily_stake_limit',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DEFAULTS: BettingLimits = {
|
||||||
|
minStake: 1,
|
||||||
|
maxStakeSingle: 50000,
|
||||||
|
maxStakeParlay: 20000,
|
||||||
|
maxPayoutSingle: 500000,
|
||||||
|
maxPayoutParlay: 1000000,
|
||||||
|
dailyStakeLimit: 200000,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BettingLimitsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private async getNumber(key: string, fallback: number): Promise<number> {
|
||||||
|
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||||
|
if (!row) return fallback;
|
||||||
|
const n = Number(row.configValue);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setNumber(key: string, value: number, description: string) {
|
||||||
|
await this.prisma.systemConfig.upsert({
|
||||||
|
where: { configKey: key },
|
||||||
|
create: { configKey: key, configValue: String(value), description },
|
||||||
|
update: { configValue: String(value) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLimits(): Promise<BettingLimits> {
|
||||||
|
return {
|
||||||
|
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
|
||||||
|
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
|
||||||
|
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
|
||||||
|
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
|
||||||
|
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
|
||||||
|
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLimits(data: Partial<BettingLimits>): Promise<BettingLimits> {
|
||||||
|
const desc: Record<keyof BettingLimits, string> = {
|
||||||
|
minStake: '最小单注金额',
|
||||||
|
maxStakeSingle: '单关最大投注额',
|
||||||
|
maxStakeParlay: '串关最大投注额',
|
||||||
|
maxPayoutSingle: '单关最高派彩',
|
||||||
|
maxPayoutParlay: '串关最高派彩',
|
||||||
|
dailyStakeLimit: '玩家每日投注上限',
|
||||||
|
};
|
||||||
|
for (const [field, key] of Object.entries(BET_LIMIT_KEYS) as Array<
|
||||||
|
[keyof BettingLimits, string]
|
||||||
|
>) {
|
||||||
|
const val = data[field];
|
||||||
|
if (val !== undefined) {
|
||||||
|
await this.setNumber(key, val, desc[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.getLimits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateBet(params: {
|
||||||
|
userId: bigint;
|
||||||
|
betType: 'SINGLE' | 'PARLAY';
|
||||||
|
stake: number;
|
||||||
|
potentialReturn: Decimal;
|
||||||
|
}) {
|
||||||
|
const limits = await this.getLimits();
|
||||||
|
const stake = params.stake;
|
||||||
|
|
||||||
|
if (stake < limits.minStake) {
|
||||||
|
throw new BadRequestException(`Minimum stake is ${limits.minStake}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle;
|
||||||
|
if (stake > maxStake) {
|
||||||
|
throw new BadRequestException(`Maximum stake is ${maxStake}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPayout =
|
||||||
|
params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle;
|
||||||
|
if (params.potentialReturn.gt(maxPayout)) {
|
||||||
|
throw new BadRequestException(`Potential return exceeds limit of ${maxPayout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limits.dailyStakeLimit > 0) {
|
||||||
|
const startOfDay = new Date();
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const endOfDay = new Date();
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const agg = await this.prisma.bet.aggregate({
|
||||||
|
where: {
|
||||||
|
userId: params.userId,
|
||||||
|
placedAt: { gte: startOfDay, lte: endOfDay },
|
||||||
|
status: { notIn: ['VOID', 'CANCELLED'] },
|
||||||
|
},
|
||||||
|
_sum: { stake: true },
|
||||||
|
});
|
||||||
|
const todayStake = new Decimal(agg._sum.stake ?? 0);
|
||||||
|
if (todayStake.add(stake).gt(limits.dailyStakeLimit)) {
|
||||||
|
throw new BadRequestException(`Daily stake limit of ${limits.dailyStakeLimit} exceeded`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,14 +33,20 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (!required?.length) return true;
|
|
||||||
|
|
||||||
const { user } = context.switchToHttp().getRequest();
|
const { user } = context.switchToHttp().getRequest();
|
||||||
const userPerms: string[] = user?.permissions ?? [];
|
if (!user || user.userType !== 'ADMIN') {
|
||||||
if (user?.role === 'SUPER_ADMIN') return true;
|
throw new ForbiddenException('Admin access required');
|
||||||
|
}
|
||||||
|
if (user.role === 'SUPER_ADMIN') return true;
|
||||||
|
|
||||||
const hasAll = required.every((p) => userPerms.includes(p));
|
if (!required?.length) {
|
||||||
if (!hasAll) throw new ForbiddenException('Insufficient permissions');
|
throw new ForbiddenException('Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPerms: string[] = user.permissions ?? [];
|
||||||
|
const hasAccess = required.some((p) => userPerms.includes(p));
|
||||||
|
if (!hasAccess) throw new ForbiddenException('Insufficient permissions');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
const permissions =
|
const permissions =
|
||||||
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
|
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
|
||||||
|
const roleCode = user.adminRole?.role?.code ?? payload.role;
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -47,7 +48,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
parentId: user.parentId,
|
parentId: user.parentId,
|
||||||
agentLevel: user.agentLevel,
|
agentLevel: user.agentLevel,
|
||||||
locale: user.locale,
|
locale: user.locale,
|
||||||
role: payload.role,
|
role: roleCode,
|
||||||
permissions,
|
permissions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { Decimal } from '@prisma/client/runtime/library';
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
import { generateTransactionId } from '../../shared/common/decorators';
|
import { generateTransactionId } from '../../shared/common/decorators';
|
||||||
|
|
||||||
|
type TxClient = Prisma.TransactionClient;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WalletService {
|
export class WalletService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -19,7 +22,7 @@ export class WalletService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async lockWallet(tx: Parameters<Parameters<PrismaService['$transaction']>[0]>[0], userId: bigint) {
|
private async lockWallet(tx: TxClient, userId: bigint) {
|
||||||
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
|
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
|
||||||
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
|
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
|
||||||
`;
|
`;
|
||||||
@@ -163,24 +166,25 @@ export class WalletService {
|
|||||||
payout: Decimal,
|
payout: Decimal,
|
||||||
betId: string,
|
betId: string,
|
||||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||||
|
tx?: TxClient,
|
||||||
) {
|
) {
|
||||||
const txTypeMap: Record<string, string> = {
|
const run = async (client: TxClient) => {
|
||||||
WIN: 'BET_SETTLE_WIN',
|
const txTypeMap: Record<string, string> = {
|
||||||
LOSE: 'BET_SETTLE_LOSE',
|
WIN: 'BET_SETTLE_WIN',
|
||||||
PUSH: 'BET_SETTLE_PUSH',
|
LOSE: 'BET_SETTLE_LOSE',
|
||||||
VOID: 'BET_VOID_REFUND',
|
PUSH: 'BET_SETTLE_PUSH',
|
||||||
HALF_WIN: 'BET_SETTLE_WIN',
|
VOID: 'BET_VOID_REFUND',
|
||||||
HALF_LOSE: 'BET_SETTLE_LOSE',
|
HALF_WIN: 'BET_SETTLE_WIN',
|
||||||
};
|
HALF_LOSE: 'BET_SETTLE_LOSE',
|
||||||
|
};
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
const w = await this.lockWallet(client, userId);
|
||||||
const w = await this.lockWallet(tx, userId);
|
|
||||||
const avail = new Decimal(w.available_balance);
|
const avail = new Decimal(w.available_balance);
|
||||||
const frozen = new Decimal(w.frozen_balance);
|
const frozen = new Decimal(w.frozen_balance);
|
||||||
const frozenAfter = frozen.sub(stake);
|
const frozenAfter = frozen.sub(stake);
|
||||||
const balanceAfter = avail.add(payout);
|
const balanceAfter = avail.add(payout);
|
||||||
|
|
||||||
await tx.wallet.update({
|
await client.wallet.update({
|
||||||
where: { id: w.id },
|
where: { id: w.id },
|
||||||
data: {
|
data: {
|
||||||
availableBalance: balanceAfter,
|
availableBalance: balanceAfter,
|
||||||
@@ -189,7 +193,7 @@ export class WalletService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.walletTransaction.create({
|
await client.walletTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
transactionId: generateTransactionId(),
|
transactionId: generateTransactionId(),
|
||||||
userId,
|
userId,
|
||||||
@@ -204,7 +208,55 @@ export class WalletService {
|
|||||||
referenceId: betId,
|
referenceId: betId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (tx) return run(tx);
|
||||||
|
return this.prisma.$transaction(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重结算差额调整:delta = 新派彩 - 原派彩,允许扣回导致负余额 */
|
||||||
|
async applyResettleDelta(
|
||||||
|
userId: bigint,
|
||||||
|
delta: Decimal,
|
||||||
|
betNo: string,
|
||||||
|
tx?: TxClient,
|
||||||
|
) {
|
||||||
|
if (delta.eq(0)) return;
|
||||||
|
|
||||||
|
const run = async (client: TxClient) => {
|
||||||
|
const w = await this.lockWallet(client, userId);
|
||||||
|
const avail = new Decimal(w.available_balance);
|
||||||
|
const balanceAfter = avail.add(delta);
|
||||||
|
const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE';
|
||||||
|
|
||||||
|
await client.wallet.update({
|
||||||
|
where: { id: w.id },
|
||||||
|
data: {
|
||||||
|
availableBalance: balanceAfter,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.walletTransaction.create({
|
||||||
|
data: {
|
||||||
|
transactionId: generateTransactionId(),
|
||||||
|
userId,
|
||||||
|
walletId: w.id,
|
||||||
|
transactionType: txType,
|
||||||
|
amount: delta,
|
||||||
|
balanceBefore: avail,
|
||||||
|
balanceAfter,
|
||||||
|
frozenBefore: w.frozen_balance,
|
||||||
|
frozenAfter: w.frozen_balance,
|
||||||
|
referenceType: 'BET',
|
||||||
|
referenceId: betNo,
|
||||||
|
remark: 'Resettlement adjustment',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tx) return run(tx);
|
||||||
|
return this.prisma.$transaction(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
|
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { resolveCashbackRateForBet } from './cashback-rate.resolver';
|
||||||
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
|
describe('resolveCashbackRateForBet', () => {
|
||||||
|
const userId = BigInt(100);
|
||||||
|
const agentId = BigInt(200);
|
||||||
|
|
||||||
|
it('uses agent default when no rules', () => {
|
||||||
|
const rate = resolveCashbackRateForBet({
|
||||||
|
userId,
|
||||||
|
agentId,
|
||||||
|
marketTypes: ['FT_1X2'],
|
||||||
|
agentDefaultRate: new Decimal('0.02'),
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
expect(rate.toString()).toBe('0.02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers USER rule over agent default', () => {
|
||||||
|
const rate = resolveCashbackRateForBet({
|
||||||
|
userId,
|
||||||
|
agentId,
|
||||||
|
marketTypes: ['FT_1X2'],
|
||||||
|
agentDefaultRate: new Decimal('0.01'),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
targetType: 'USER',
|
||||||
|
targetId: userId,
|
||||||
|
rate: new Decimal('0.03'),
|
||||||
|
marketType: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(rate.toString()).toBe('0.03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies market-specific rule when market matches', () => {
|
||||||
|
const rate = resolveCashbackRateForBet({
|
||||||
|
userId,
|
||||||
|
agentId,
|
||||||
|
marketTypes: ['FT_HANDICAP'],
|
||||||
|
agentDefaultRate: new Decimal('0.01'),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
targetType: 'GLOBAL',
|
||||||
|
targetId: null,
|
||||||
|
rate: new Decimal('0.005'),
|
||||||
|
marketType: 'FT_HANDICAP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(rate.toString()).toBe('0.005');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips market-specific rule when market does not match', () => {
|
||||||
|
const rate = resolveCashbackRateForBet({
|
||||||
|
userId,
|
||||||
|
agentId,
|
||||||
|
marketTypes: ['FT_1X2'],
|
||||||
|
agentDefaultRate: new Decimal('0.01'),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
targetType: 'GLOBAL',
|
||||||
|
targetId: null,
|
||||||
|
rate: new Decimal('0.005'),
|
||||||
|
marketType: 'FT_HANDICAP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(rate.toString()).toBe('0.01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
|
export type CashbackRuleRow = {
|
||||||
|
targetType: string;
|
||||||
|
targetId: bigint | null;
|
||||||
|
rate: Decimal;
|
||||||
|
marketType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TARGET_PRIORITY: Record<string, number> = {
|
||||||
|
USER: 3,
|
||||||
|
AGENT: 2,
|
||||||
|
GLOBAL: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveCashbackRateForBet(params: {
|
||||||
|
userId: bigint;
|
||||||
|
agentId: bigint | null;
|
||||||
|
marketTypes: string[];
|
||||||
|
agentDefaultRate: Decimal;
|
||||||
|
rules: CashbackRuleRow[];
|
||||||
|
}): Decimal {
|
||||||
|
const { userId, agentId, marketTypes, agentDefaultRate, rules } = params;
|
||||||
|
let best: { priority: number; rate: Decimal } | null = null;
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.marketType && !marketTypes.includes(rule.marketType)) continue;
|
||||||
|
|
||||||
|
let priority = 0;
|
||||||
|
if (rule.targetType === 'USER' && rule.targetId?.toString() === userId.toString()) {
|
||||||
|
priority = TARGET_PRIORITY.USER;
|
||||||
|
} else if (
|
||||||
|
rule.targetType === 'AGENT' &&
|
||||||
|
agentId != null &&
|
||||||
|
rule.targetId?.toString() === agentId.toString()
|
||||||
|
) {
|
||||||
|
priority = TARGET_PRIORITY.AGENT;
|
||||||
|
} else if (rule.targetType === 'GLOBAL') {
|
||||||
|
priority = TARGET_PRIORITY.GLOBAL;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best || priority > best.priority) {
|
||||||
|
best = { priority, rate: rule.rate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best?.rate ?? agentDefaultRate;
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import { PrismaService } from '../../../shared/prisma/prisma.service';
|
|||||||
import { WalletService } from '../../ledger/wallet.service';
|
import { WalletService } from '../../ledger/wallet.service';
|
||||||
import { Decimal } from '@prisma/client/runtime/library';
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
import { generateBatchNo } from '../../../shared/common/decorators';
|
import { generateBatchNo } from '../../../shared/common/decorators';
|
||||||
|
import {
|
||||||
|
resolveCashbackRateForBet,
|
||||||
|
type CashbackRuleRow,
|
||||||
|
} from './cashback-rate.resolver';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CashbackService {
|
export class CashbackService {
|
||||||
@@ -12,37 +16,98 @@ export class CashbackService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||||
const settledBets = await this.prisma.bet.findMany({
|
const [settledBets, rules, agentProfiles] = await Promise.all([
|
||||||
where: {
|
this.prisma.bet.findMany({
|
||||||
status: { in: ['WON', 'LOST', 'SETTLED'] },
|
where: {
|
||||||
settledAt: { gte: periodStart, lte: periodEnd },
|
status: { in: ['WON', 'LOST'] },
|
||||||
},
|
settledAt: { gte: periodStart, lte: periodEnd },
|
||||||
include: { user: { include: { agentProfile: true } } },
|
},
|
||||||
});
|
include: {
|
||||||
|
user: { select: { id: true, parentId: true } },
|
||||||
|
selections: { select: { marketType: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.cashbackRule.findMany({ where: { isActive: true } }),
|
||||||
|
this.prisma.agentProfile.findMany({
|
||||||
|
select: { userId: true, cashbackRate: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const playerStakes = new Map<string, { userId: bigint; stake: Decimal; rate: Decimal }>();
|
const agentRateById = new Map(
|
||||||
|
agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]),
|
||||||
for (const bet of settledBets) {
|
);
|
||||||
if (bet.status === 'PUSH' || bet.status === 'VOID') continue;
|
const ruleRows: CashbackRuleRow[] = rules.map((r) => ({
|
||||||
|
targetType: r.targetType,
|
||||||
const key = bet.userId.toString();
|
targetId: r.targetId,
|
||||||
const existing = playerStakes.get(key) ?? {
|
rate: new Decimal(r.rate),
|
||||||
userId: bet.userId,
|
marketType: r.marketType,
|
||||||
stake: new Decimal(0),
|
|
||||||
rate: new Decimal(0.01),
|
|
||||||
};
|
|
||||||
existing.stake = existing.stake.add(bet.stake);
|
|
||||||
playerStakes.set(key, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = Array.from(playerStakes.values()).map((p) => ({
|
|
||||||
userId: p.userId,
|
|
||||||
effectiveStake: p.stake,
|
|
||||||
rate: p.rate,
|
|
||||||
amount: p.stake.mul(p.rate),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
const playerAgg = new Map<
|
||||||
|
string,
|
||||||
|
{ userId: bigint; stake: Decimal; amount: Decimal }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const bet of settledBets) {
|
||||||
|
const agentId = bet.user.parentId;
|
||||||
|
const agentDefaultRate = agentId
|
||||||
|
? agentRateById.get(agentId.toString()) ?? new Decimal(0)
|
||||||
|
: new Decimal(0);
|
||||||
|
const marketTypes = bet.selections.map((s) => s.marketType);
|
||||||
|
const rate = resolveCashbackRateForBet({
|
||||||
|
userId: bet.userId,
|
||||||
|
agentId,
|
||||||
|
marketTypes,
|
||||||
|
agentDefaultRate,
|
||||||
|
rules: ruleRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rate.lte(0)) continue;
|
||||||
|
|
||||||
|
const key = bet.userId.toString();
|
||||||
|
const existing = playerAgg.get(key) ?? {
|
||||||
|
userId: bet.userId,
|
||||||
|
stake: new Decimal(0),
|
||||||
|
amount: new Decimal(0),
|
||||||
|
};
|
||||||
|
existing.stake = existing.stake.add(bet.stake);
|
||||||
|
existing.amount = existing.amount.add(bet.stake.mul(rate));
|
||||||
|
playerAgg.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(playerAgg.values())
|
||||||
|
.map((p) => ({
|
||||||
|
userId: p.userId,
|
||||||
|
effectiveStake: p.stake,
|
||||||
|
rate: p.stake.gt(0) ? p.amount.div(p.stake) : new Decimal(0),
|
||||||
|
amount: p.amount,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.amount.gt(a.amount) ? 1 : a.amount.gt(b.amount) ? -1 : 0));
|
||||||
|
|
||||||
|
const userIds = items.map((i) => i.userId);
|
||||||
|
const users =
|
||||||
|
userIds.length > 0
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
parent: { select: { username: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const userById = new Map(users.map((u) => [u.id.toString(), u]));
|
||||||
|
|
||||||
|
const enrichedItems = items.map((item) => {
|
||||||
|
const user = userById.get(item.userId.toString());
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
username: user?.username ?? '',
|
||||||
|
agentUsername: user?.parent?.username ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||||
|
|
||||||
const batch = await this.prisma.cashbackBatch.create({
|
const batch = await this.prisma.cashbackBatch.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -55,7 +120,7 @@ export class CashbackService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of enrichedItems) {
|
||||||
await this.prisma.cashbackItem.create({
|
await this.prisma.cashbackItem.create({
|
||||||
data: {
|
data: {
|
||||||
batchId: batch.id,
|
batchId: batch.id,
|
||||||
@@ -67,7 +132,7 @@ export class CashbackService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { batch, items, totalAmount };
|
return { batch, items: enrichedItems, totalAmount };
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||||
|
|||||||
@@ -110,9 +110,40 @@ describe('SettlementCalculator', () => {
|
|||||||
});
|
});
|
||||||
expect(r).toBe('HALF_LOSE');
|
expect(r).toBe('HALF_LOSE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('0-0: home -0.5 loses', () => {
|
||||||
|
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||||
|
expect(
|
||||||
|
settleSelection({
|
||||||
|
marketType: 'FT_HANDICAP',
|
||||||
|
selectionCode: 'HOME',
|
||||||
|
handicapLine: -0.5,
|
||||||
|
score: s,
|
||||||
|
}),
|
||||||
|
).toBe('LOSE');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Over/Under', () => {
|
describe('Over/Under', () => {
|
||||||
|
it('0-0: under 2.5 wins, over 2.5 loses', () => {
|
||||||
|
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||||
|
const under = settleSelection({
|
||||||
|
marketType: 'FT_OVER_UNDER',
|
||||||
|
selectionCode: 'UNDER',
|
||||||
|
totalLine: 2.5,
|
||||||
|
score: s,
|
||||||
|
});
|
||||||
|
const over = settleSelection({
|
||||||
|
marketType: 'FT_OVER_UNDER',
|
||||||
|
selectionCode: 'OVER',
|
||||||
|
totalLine: 2.5,
|
||||||
|
score: s,
|
||||||
|
});
|
||||||
|
expect(under).toBe('WIN');
|
||||||
|
expect(over).toBe('LOSE');
|
||||||
|
expect(calculatePayout(100, 1.95, under).toNumber()).toBe(195);
|
||||||
|
});
|
||||||
|
|
||||||
it('S013: over 2.5 wins with 3 goals', () => {
|
it('S013: over 2.5 wins with 3 goals', () => {
|
||||||
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
|
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
|
||||||
expect(
|
expect(
|
||||||
@@ -177,6 +208,27 @@ describe('SettlementCalculator', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OUTRIGHT_WINNER', () => {
|
||||||
|
it('wins when selection matches winner team code', () => {
|
||||||
|
expect(
|
||||||
|
settleSelection({
|
||||||
|
marketType: 'OUTRIGHT_WINNER',
|
||||||
|
selectionCode: 'BRA',
|
||||||
|
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
|
||||||
|
winnerTeamCode: 'BRA',
|
||||||
|
}),
|
||||||
|
).toBe('WIN');
|
||||||
|
expect(
|
||||||
|
settleSelection({
|
||||||
|
marketType: 'OUTRIGHT_WINNER',
|
||||||
|
selectionCode: 'ARG',
|
||||||
|
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
|
||||||
|
winnerTeamCode: 'BRA',
|
||||||
|
}),
|
||||||
|
).toBe('LOSE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Quarter line detection', () => {
|
describe('Quarter line detection', () => {
|
||||||
it('detects quarter lines', () => {
|
it('detects quarter lines', () => {
|
||||||
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
|
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface SettlementInput {
|
|||||||
totalLine?: number | null;
|
totalLine?: number | null;
|
||||||
score: ScoreInput;
|
score: ScoreInput;
|
||||||
templateScores?: string[];
|
templateScores?: string[];
|
||||||
|
/** 冠军盘:获胜球队 code,如 FRA、BRA */
|
||||||
|
winnerTeamCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShScore(score: ScoreInput): { home: number; away: number } {
|
export function getShScore(score: ScoreInput): { home: number; away: number } {
|
||||||
@@ -196,7 +198,8 @@ export function settleSelection(input: SettlementInput): SelectionResult {
|
|||||||
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
|
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
|
||||||
}
|
}
|
||||||
case 'OUTRIGHT_WINNER':
|
case 'OUTRIGHT_WINNER':
|
||||||
return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE';
|
if (!input.winnerTeamCode) return 'LOSE';
|
||||||
|
return selectionCode === input.winnerTeamCode ? 'WIN' : 'LOSE';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'LOSE';
|
return 'LOSE';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { resolveSelectionCode } from './settlement-helpers';
|
||||||
|
|
||||||
|
describe('resolveSelectionCode', () => {
|
||||||
|
it('prefers database selection code', () => {
|
||||||
|
expect(resolveSelectionCode('UNDER', '小 2.5')).toBe('UNDER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Chinese snapshot names when code is missing', () => {
|
||||||
|
expect(resolveSelectionCode(null, '主胜')).toBe('HOME');
|
||||||
|
expect(resolveSelectionCode('', '小 2.5')).toBe('UNDER');
|
||||||
|
expect(resolveSelectionCode(undefined, '大 2.5')).toBe('OVER');
|
||||||
|
expect(resolveSelectionCode(null, '主 -0.5')).toBe('HOME');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
apps/api/src/domains/settlement/domain/settlement-helpers.ts
Normal file
37
apps/api/src/domains/settlement/domain/settlement-helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
FT_CORRECT_SCORE_TEMPLATE,
|
||||||
|
HT_CORRECT_SCORE_TEMPLATE,
|
||||||
|
} from './settlement-calculator';
|
||||||
|
|
||||||
|
const SNAPSHOT_1X2: Record<string, string> = {
|
||||||
|
主胜: 'HOME',
|
||||||
|
客胜: 'AWAY',
|
||||||
|
和局: 'DRAW',
|
||||||
|
平局: 'DRAW',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 盘口 code 缺失时,从下单快照名推断标准 selectionCode */
|
||||||
|
export function resolveSelectionCode(
|
||||||
|
selectionCode: string | null | undefined,
|
||||||
|
nameSnapshot: string,
|
||||||
|
): string {
|
||||||
|
if (selectionCode?.trim()) return selectionCode.trim();
|
||||||
|
|
||||||
|
const name = nameSnapshot.trim();
|
||||||
|
if (SNAPSHOT_1X2[name]) return SNAPSHOT_1X2[name];
|
||||||
|
if (name.startsWith('大')) return 'OVER';
|
||||||
|
if (name.startsWith('小')) return 'UNDER';
|
||||||
|
if (name.startsWith('主')) return 'HOME';
|
||||||
|
if (name.startsWith('客')) return 'AWAY';
|
||||||
|
|
||||||
|
if (name.includes('-')) {
|
||||||
|
return `SCORE_${name.replace('-', '_')}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateScoresForMarket(marketType: string): string[] {
|
||||||
|
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||||
|
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -9,22 +9,25 @@ import {
|
|||||||
calculatePayout,
|
calculatePayout,
|
||||||
calculateParlayPayout,
|
calculateParlayPayout,
|
||||||
ScoreInput,
|
ScoreInput,
|
||||||
FT_CORRECT_SCORE_TEMPLATE,
|
SelectionResult,
|
||||||
HT_CORRECT_SCORE_TEMPLATE,
|
|
||||||
} from './domain/settlement-calculator';
|
} from './domain/settlement-calculator';
|
||||||
// 智能比分推荐已关闭
|
import {
|
||||||
// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver';
|
resolveSelectionCode,
|
||||||
|
templateScoresForMarket,
|
||||||
|
} from './domain/settlement-helpers';
|
||||||
|
|
||||||
function resolveSelectionCodeFromLeg(
|
type BetSelectionLeg = {
|
||||||
selectionCode: string | null | undefined,
|
id: bigint;
|
||||||
nameSnapshot: string,
|
matchId: bigint | null;
|
||||||
): string {
|
marketType: string;
|
||||||
if (selectionCode?.trim()) return selectionCode.trim();
|
selectionId: bigint;
|
||||||
if (nameSnapshot.includes('-')) {
|
selectionNameSnapshot: string;
|
||||||
return `SCORE_${nameSnapshot.replace('-', '_')}`;
|
handicapLine: Decimal | null;
|
||||||
}
|
totalLine: Decimal | null;
|
||||||
return nameSnapshot;
|
odds: Decimal;
|
||||||
}
|
resultStatus: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettlementService {
|
export class SettlementService {
|
||||||
@@ -34,6 +37,52 @@ export class SettlementService {
|
|||||||
private agents: AgentsService,
|
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(
|
async recordScore(
|
||||||
matchId: bigint,
|
matchId: bigint,
|
||||||
htHome: number,
|
htHome: number,
|
||||||
@@ -41,11 +90,49 @@ export class SettlementService {
|
|||||||
ftHome: number,
|
ftHome: number,
|
||||||
ftAway: number,
|
ftAway: number,
|
||||||
operatorId: bigint,
|
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({
|
await this.prisma.matchScore.upsert({
|
||||||
where: { matchId },
|
where: { matchId },
|
||||||
create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
|
create: {
|
||||||
update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
|
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({
|
await this.prisma.match.update({
|
||||||
@@ -53,75 +140,18 @@ export class SettlementService {
|
|||||||
data: { status: 'PENDING_SETTLEMENT' },
|
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 } });
|
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||||
if (!score) throw new BadRequestException('Score not recorded');
|
if (!score) throw new BadRequestException('Score not recorded');
|
||||||
|
|
||||||
const scoreInput: ScoreInput = {
|
const computation = await this.computePreviewComputation(matchId);
|
||||||
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 batch = await this.prisma.settlementBatch.create({
|
const batch = await this.prisma.settlementBatch.create({
|
||||||
data: {
|
data: {
|
||||||
matchId,
|
matchId,
|
||||||
@@ -131,24 +161,324 @@ export class SettlementService {
|
|||||||
ftHomeScore: score.ftHomeScore,
|
ftHomeScore: score.ftHomeScore,
|
||||||
ftAwayScore: score.ftAwayScore,
|
ftAwayScore: score.ftAwayScore,
|
||||||
status: 'PREVIEW',
|
status: 'PREVIEW',
|
||||||
totalBets: pendingBets.length,
|
totalBets: computation.pendingBets.length,
|
||||||
totalPayout,
|
totalPayout: computation.totalPayout,
|
||||||
totalRefund,
|
totalRefund: computation.totalRefund,
|
||||||
operatorId,
|
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 {
|
return {
|
||||||
batch,
|
batch,
|
||||||
score: scoreInput,
|
score: computation.scoreInput,
|
||||||
|
winnerTeamCode: computation.winnerTeamCode,
|
||||||
|
pendingBetCount: pendingBets.length,
|
||||||
singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').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,
|
items,
|
||||||
totalPayout,
|
totalPayout,
|
||||||
totalRefund,
|
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) {
|
async confirmSettlement(batchId: bigint, operatorId: bigint) {
|
||||||
const batch = await this.prisma.settlementBatch.findUnique({
|
const batch = await this.prisma.settlementBatch.findUnique({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
@@ -168,40 +498,30 @@ export class SettlementService {
|
|||||||
ftHome: score.ftHomeScore ?? 0,
|
ftHome: score.ftHomeScore ?? 0,
|
||||||
ftAway: score.ftAwayScore ?? 0,
|
ftAway: score.ftAwayScore ?? 0,
|
||||||
};
|
};
|
||||||
|
const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
|
||||||
|
|
||||||
const pendingBets = await this.prisma.bet.findMany({
|
const pendingBets = await this.prisma.bet.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
selections: { some: { matchId: batch.matchId } },
|
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>();
|
const agentIds = new Set<bigint>();
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
for (const bet of pendingBets) {
|
for (const bet of pendingBets) {
|
||||||
if (bet.betType === 'SINGLE') {
|
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
|
||||||
const sel = bet.selections[0];
|
const sel = bet.selections[0];
|
||||||
const selection = await tx.marketSelection.findUnique({
|
const code = resolveSelectionCode(
|
||||||
where: { id: sel.selectionId },
|
selectionCodes.get(sel.selectionId.toString()),
|
||||||
});
|
sel.selectionNameSnapshot,
|
||||||
|
);
|
||||||
const result = settleSelection({
|
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||||
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 payout = calculatePayout(bet.stake, sel.odds, result);
|
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||||
const betStatus =
|
const betStatus = this.betStatusFromSelection(result);
|
||||||
result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON';
|
|
||||||
|
|
||||||
await tx.bet.update({
|
await tx.bet.update({
|
||||||
where: { id: bet.id },
|
where: { id: bet.id },
|
||||||
@@ -222,7 +542,8 @@ export class SettlementService {
|
|||||||
bet.stake,
|
bet.stake,
|
||||||
payout,
|
payout,
|
||||||
bet.betNo,
|
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);
|
if (bet.agentId) agentIds.add(bet.agentId);
|
||||||
@@ -236,20 +557,69 @@ export class SettlementService {
|
|||||||
payout,
|
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 {
|
} else {
|
||||||
// Parlay: update this leg's result, check if all legs settled
|
|
||||||
for (const sel of bet.selections) {
|
for (const sel of bet.selections) {
|
||||||
if (sel.matchId?.toString() === batch.matchId.toString()) {
|
if (sel.matchId?.toString() === batch.matchId.toString()) {
|
||||||
const selection = await tx.marketSelection.findUnique({
|
const code = resolveSelectionCode(
|
||||||
where: { id: sel.selectionId },
|
selectionCodes.get(sel.selectionId.toString()),
|
||||||
});
|
sel.selectionNameSnapshot,
|
||||||
const result = settleSelection({
|
);
|
||||||
marketType: sel.marketType,
|
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||||
selectionCode: selection?.selectionCode ?? '',
|
|
||||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
|
||||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
|
||||||
score: scoreInput,
|
|
||||||
});
|
|
||||||
await tx.betSelection.update({
|
await tx.betSelection.update({
|
||||||
where: { id: sel.id },
|
where: { id: sel.id },
|
||||||
data: { resultStatus: result },
|
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);
|
const allHaveResult = updated.every((s) => s.resultStatus != null);
|
||||||
|
|
||||||
if (allHaveResult) {
|
if (allHaveResult) {
|
||||||
const legResults = updated.map((s) => ({
|
const legResults = updated.map((s) => ({
|
||||||
odds: s.odds,
|
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 parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||||
|
const betStatus =
|
||||||
|
parlayResult.betResult === 'LOST'
|
||||||
|
? 'LOST'
|
||||||
|
: parlayResult.betResult === 'PUSH'
|
||||||
|
? 'PUSH'
|
||||||
|
: 'WON';
|
||||||
|
|
||||||
await tx.bet.update({
|
await tx.bet.update({
|
||||||
where: { id: bet.id },
|
where: { id: bet.id },
|
||||||
data: {
|
data: {
|
||||||
status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON',
|
status: betStatus,
|
||||||
actualReturn: parlayResult.payout,
|
actualReturn: parlayResult.payout,
|
||||||
settledAt: new Date(),
|
settledAt: new Date(),
|
||||||
},
|
},
|
||||||
@@ -281,10 +660,25 @@ export class SettlementService {
|
|||||||
bet.stake,
|
bet.stake,
|
||||||
parlayResult.payout,
|
parlayResult.payout,
|
||||||
bet.betNo,
|
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);
|
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() };
|
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({
|
const match = await this.prisma.match.findFirst({
|
||||||
where: { id: matchId, deletedAt: null },
|
where: { id: matchId, deletedAt: null },
|
||||||
});
|
});
|
||||||
@@ -405,28 +802,50 @@ export class SettlementService {
|
|||||||
return a.selectionName.localeCompare(b.selectionName);
|
return a.selectionName.localeCompare(b.selectionName);
|
||||||
});
|
});
|
||||||
|
|
||||||
const bets = Array.from(legs)
|
const betsById = new Map<
|
||||||
.map((leg) => ({
|
string,
|
||||||
id: leg.bet.id.toString(),
|
{
|
||||||
betNo: leg.bet.betNo,
|
bet: (typeof legs)[0]['bet'];
|
||||||
username: leg.bet.user.username,
|
matchLegs: (typeof legs);
|
||||||
betType: leg.bet.betType,
|
}
|
||||||
status: leg.bet.status,
|
>();
|
||||||
settlementStatus: leg.bet.settlementStatus,
|
for (const leg of legs) {
|
||||||
stake: leg.bet.stake.toString(),
|
const key = leg.betId.toString();
|
||||||
potentialReturn: leg.bet.potentialReturn?.toString() ?? null,
|
const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
|
||||||
actualReturn: leg.bet.actualReturn.toString(),
|
row.matchLegs.push(leg);
|
||||||
placedAt: leg.bet.placedAt.toISOString(),
|
betsById.set(key, row);
|
||||||
marketType: leg.marketType,
|
}
|
||||||
period: leg.period,
|
|
||||||
selectionName: leg.selectionNameSnapshot,
|
const allBets = Array.from(betsById.values())
|
||||||
odds: leg.odds.toString(),
|
.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(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
|
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 {
|
return {
|
||||||
summary: {
|
summary: {
|
||||||
totalBets: betById.size,
|
totalBets: betById.size,
|
||||||
@@ -438,13 +857,283 @@ export class SettlementService {
|
|||||||
legCount: legs.length,
|
legCount: legs.length,
|
||||||
},
|
},
|
||||||
bySelection,
|
bySelection,
|
||||||
bets,
|
bets: {
|
||||||
|
items: allBets.slice(start, start + pageSize),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import
|
private async computeBetOutcome(
|
||||||
async suggestSmartScores(...) { ... }
|
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) {
|
async voidMatchBets(matchId: bigint) {
|
||||||
const bets = await this.prisma.bet.findMany({
|
const bets = await this.prisma.bet.findMany({
|
||||||
|
|||||||
@@ -72,10 +72,13 @@ describe('Bet Validation Rules (B001-B010)', () => {
|
|||||||
expect(submitted === current).toBe(false);
|
expect(submitted === current).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('B007: same match in parlay rejected', () => {
|
it('B007: same match legs allowed in parlay (2–5 legs)', () => {
|
||||||
const matchIds = ['1', '1', '2'];
|
const legs = [
|
||||||
const unique = new Set(matchIds);
|
{ matchId: '1', selectionId: 'a' },
|
||||||
expect(unique.size !== matchIds.length).toBe(true);
|
{ matchId: '1', selectionId: 'b' },
|
||||||
|
];
|
||||||
|
expect(legs.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(legs.length).toBeLessThanOrEqual(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('B008: quarter line in parlay rejected', () => {
|
it('B008: quarter line in parlay rejected', () => {
|
||||||
|
|||||||
9
apps/api/tsconfig.build.json
Normal file
9
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "src",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -21,5 +22,5 @@
|
|||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "prisma/seed.ts"]
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,11 @@ async function placeBet() {
|
|||||||
success.value = '';
|
success.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (slip.mode === 'parlay' && slip.items.length >= PARLAY_MIN_LEGS) {
|
if (slip.canPlaceParlay) {
|
||||||
if (slip.hasSameMatch) {
|
|
||||||
error.value = t('bet.parlay_same_match');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (slip.items.length > PARLAY_MAX_LEGS) {
|
if (slip.items.length > PARLAY_MAX_LEGS) {
|
||||||
error.value = t('bet.parlay_max_legs');
|
error.value = t('bet.parlay_max_legs');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!slip.canPlaceParlay) {
|
|
||||||
error.value = t('bet.parlay_need_more');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await api.post('/player/bets/parlay', {
|
await api.post('/player/bets/parlay', {
|
||||||
legs: slip.items.map((i) => ({
|
legs: slip.items.map((i) => ({
|
||||||
selectionId: i.selectionId,
|
selectionId: i.selectionId,
|
||||||
@@ -51,15 +43,17 @@ async function placeBet() {
|
|||||||
stake: slip.stake,
|
stake: slip.stake,
|
||||||
requestId: genId(),
|
requestId: genId(),
|
||||||
});
|
});
|
||||||
} else if (slip.items.length === 1) {
|
} else if (slip.canPlaceBatchSingles) {
|
||||||
const item = slip.items[0];
|
for (const item of slip.items) {
|
||||||
await api.post('/player/bets/single', {
|
await api.post('/player/bets/single', {
|
||||||
selectionId: item.selectionId,
|
selectionId: item.selectionId,
|
||||||
oddsVersion: item.oddsVersion,
|
oddsVersion: item.oddsVersion,
|
||||||
stake: slip.stake,
|
stake: slip.stake,
|
||||||
requestId: genId(),
|
requestId: genId(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
error.value = t('bet.parlay_need_more');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
success.value = t('bet.place_success');
|
success.value = t('bet.place_success');
|
||||||
@@ -100,12 +94,19 @@ async function placeBet() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="slip.isParlay" class="mode-hint mode-hint--parlay">
|
<p v-if="slip.canPlaceParlay" class="mode-hint mode-hint--parlay">
|
||||||
{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}
|
{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="slip.canPlaceBatchSingles && slip.count > 1" class="mode-hint">
|
||||||
|
{{ t('bet.slip_singles_hint', { n: slip.count }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-if="slip.items.length" class="stake-area">
|
<div v-if="slip.items.length" class="stake-area">
|
||||||
<label>{{ t('bet.stake') }}</label>
|
<label>{{
|
||||||
|
slip.canPlaceBatchSingles && slip.count > 1
|
||||||
|
? t('bet.slip_stake_per_bet')
|
||||||
|
: t('bet.stake')
|
||||||
|
}}</label>
|
||||||
<input v-model.number="slip.stake" type="number" min="1" />
|
<input v-model.number="slip.stake" type="number" min="1" />
|
||||||
<div class="return">
|
<div class="return">
|
||||||
{{ t('bet.slip_est_return') }}:
|
{{ t('bet.slip_est_return') }}:
|
||||||
@@ -119,7 +120,7 @@ async function placeBet() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="loading || !slip.items.length || (slip.mode === 'parlay' && !slip.canPlaceParlay)"
|
:disabled="loading || !slip.canSubmit"
|
||||||
@click="placeBet"
|
@click="placeBet"
|
||||||
>
|
>
|
||||||
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
|
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { teamFlagUrl } from '../utils/teamFlag';
|
import TeamEmblem from './TeamEmblem.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
match: {
|
match: {
|
||||||
@@ -31,12 +31,6 @@ const kickoff = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const homeFlag = computed(() =>
|
|
||||||
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
|
|
||||||
);
|
|
||||||
const awayFlag = computed(() =>
|
|
||||||
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,12 +38,22 @@ const awayFlag = computed(() =>
|
|||||||
<div class="kickoff">{{ kickoff }}</div>
|
<div class="kickoff">{{ kickoff }}</div>
|
||||||
<div class="teams-stack">
|
<div class="teams-stack">
|
||||||
<div class="side">
|
<div class="side">
|
||||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="flag" />
|
<TeamEmblem
|
||||||
|
size="sm"
|
||||||
|
:team-code="match.homeTeamCode"
|
||||||
|
:team-name="match.homeTeamName"
|
||||||
|
:logo-url="match.homeTeamLogoUrl"
|
||||||
|
/>
|
||||||
<span class="name">{{ match.homeTeamName }}</span>
|
<span class="name">{{ match.homeTeamName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="vs">VS</span>
|
<span class="vs">VS</span>
|
||||||
<div class="side">
|
<div class="side">
|
||||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="flag" />
|
<TeamEmblem
|
||||||
|
size="sm"
|
||||||
|
:team-code="match.awayTeamCode"
|
||||||
|
:team-name="match.awayTeamName"
|
||||||
|
:logo-url="match.awayTeamLogoUrl"
|
||||||
|
/>
|
||||||
<span class="name">{{ match.awayTeamName }}</span>
|
<span class="name">{{ match.awayTeamName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,14 +102,6 @@ const awayFlag = computed(() =>
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flag {
|
|
||||||
width: 22px;
|
|
||||||
height: 15px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
121
apps/player/src/components/TeamEmblem.vue
Normal file
121
apps/player/src/components/TeamEmblem.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { teamFlagUrl } from '../utils/teamFlag';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
teamCode?: string;
|
||||||
|
teamName?: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}>(),
|
||||||
|
{ size: 'md' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const useCustomLogo = computed(() => Boolean(props.logoUrl?.trim()));
|
||||||
|
const src = computed(() =>
|
||||||
|
teamFlagUrl(props.teamCode, props.teamName, props.logoUrl),
|
||||||
|
);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
failed.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.teamCode, props.teamName, props.logoUrl] as const,
|
||||||
|
() => {
|
||||||
|
failed.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img
|
||||||
|
v-if="src && !failed"
|
||||||
|
:src="src"
|
||||||
|
alt=""
|
||||||
|
class="team-emblem"
|
||||||
|
:class="[`team-emblem--${size}`, { 'team-emblem--logo': useCustomLogo }]"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="team-emblem team-emblem--placeholder"
|
||||||
|
:class="`team-emblem--${size}`"
|
||||||
|
aria-hidden="true"
|
||||||
|
>⚽</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.team-emblem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--sm {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--md {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--lg {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 国旗:横向比例 + 铺满 */
|
||||||
|
.team-emblem:not(.team-emblem--logo) {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--sm:not(.team-emblem--logo) {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--md:not(.team-emblem--logo) {
|
||||||
|
width: 40px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--lg:not(.team-emblem--logo) {
|
||||||
|
width: 54px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 队徽:正方形容器 + 完整显示 */
|
||||||
|
.team-emblem--logo {
|
||||||
|
object-fit: contain;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.55;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--sm.team-emblem--placeholder {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--md.team-emblem--placeholder {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-emblem--lg.team-emblem--placeholder {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -159,6 +159,10 @@ function openSlip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||||
|
|
||||||
|
const footConfirmLabel = computed(() =>
|
||||||
|
slip.canPlaceParlay ? t('bet.parlay_confirm_parlay') : t('bet.cs_confirm_cell'),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -247,7 +251,7 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
|||||||
:disabled="!slip.canPlaceParlay"
|
:disabled="!slip.canPlaceParlay"
|
||||||
@click="openSlip"
|
@click="openSlip"
|
||||||
>
|
>
|
||||||
{{ t('bet.cs_confirm_cell') }}
|
{{ footConfirmLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -290,10 +294,15 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
|||||||
|
|
||||||
.foot-hint--warn {
|
.foot-hint--warn {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.foot-hint--info {
|
||||||
|
color: var(--primary-light);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.foot-meta {
|
.foot-meta {
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const i18n = createI18n({
|
|||||||
parlay_guide_help: '查看串关说明',
|
parlay_guide_help: '查看串关说明',
|
||||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||||
parlay_guide_2: '须选 2–5 场不同赛事,不可同场串关;冠军盘与四分盘让球/大小不可选',
|
parlay_guide_2: '须选 2–5 项(可同场多项);冠军盘与四分盘让球/大小不可选',
|
||||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||||
parlay_max_legs: '串关最多 5 项',
|
parlay_max_legs: '串关最多 5 项',
|
||||||
parlay_block_outright: '冠军盘不可串关',
|
parlay_block_outright: '冠军盘不可串关',
|
||||||
@@ -114,6 +114,9 @@ const i18n = createI18n({
|
|||||||
parlay_filter_all: '全部',
|
parlay_filter_all: '全部',
|
||||||
parlay_empty: '暂无可用串关赛事',
|
parlay_empty: '暂无可用串关赛事',
|
||||||
parlay_same_match: '同一场比赛不能串关',
|
parlay_same_match: '同一场比赛不能串关',
|
||||||
|
parlay_same_match_singles: '已选 {n} 项,将分 {n} 笔单关下单',
|
||||||
|
parlay_confirm_singles: '确认下单({n}笔单关)',
|
||||||
|
parlay_confirm_parlay: '确认串关下单',
|
||||||
parlay_need_more: '请至少选择 2 项进行串关',
|
parlay_need_more: '请至少选择 2 项进行串关',
|
||||||
back: '返回',
|
back: '返回',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
@@ -229,7 +232,7 @@ const i18n = createI18n({
|
|||||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||||
rules_title: '投注规则',
|
rules_title: '投注规则',
|
||||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||||
rules_p2: '串关为 2 串 1 至 5 串 1,不可同场串关;冠军盘、四分盘让球/大小不可进入串关。',
|
rules_p2: '串关为 2 串 1 至 5 串 1,同场可多选;冠军盘、四分盘让球/大小不可进入串关。',
|
||||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||||
@@ -330,7 +333,7 @@ const i18n = createI18n({
|
|||||||
parlay_guide_help: 'Parlay help',
|
parlay_guide_help: 'Parlay help',
|
||||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||||
parlay_guide_2: 'Pick 2–5 different matches only. No same match, outright, or quarter-ball HDP/O-U',
|
parlay_guide_2: 'Pick 2–5 legs (same match allowed). No outright or quarter-ball HDP/O-U',
|
||||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||||
parlay_block_outright: 'Outright cannot be parlayed',
|
parlay_block_outright: 'Outright cannot be parlayed',
|
||||||
@@ -339,6 +342,9 @@ const i18n = createI18n({
|
|||||||
parlay_filter_all: 'All',
|
parlay_filter_all: 'All',
|
||||||
parlay_empty: 'No matches available for parlay betting',
|
parlay_empty: 'No matches available for parlay betting',
|
||||||
parlay_same_match: 'Cannot parlay selections from the same match',
|
parlay_same_match: 'Cannot parlay selections from the same match',
|
||||||
|
parlay_same_match_singles: '{n} selection(s) → {n} separate single bet(s)',
|
||||||
|
parlay_confirm_singles: 'Place {n} single bet(s)',
|
||||||
|
parlay_confirm_parlay: 'Place parlay',
|
||||||
parlay_need_more: 'Select at least 2 legs for parlay',
|
parlay_need_more: 'Select at least 2 legs for parlay',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
@@ -454,7 +460,7 @@ const i18n = createI18n({
|
|||||||
password_disabled: 'Password change is disabled for this account; contact support',
|
password_disabled: 'Password change is disabled for this account; contact support',
|
||||||
rules_title: 'Betting Rules',
|
rules_title: 'Betting Rules',
|
||||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||||
rules_p2: 'Parlays: 2–5 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.',
|
rules_p2: 'Parlays: 2–5 legs, same-match multi-select allowed. Outright and quarter-ball HDP/O-U are excluded.',
|
||||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||||
@@ -561,7 +567,7 @@ const i18n = createI18n({
|
|||||||
parlay_guide_help: 'Bantuan parlay',
|
parlay_guide_help: 'Bantuan parlay',
|
||||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||||
parlay_guide_2: 'Pilih 2–5 perlawanan berbeza. Tiada perlawanan sama, outright atau suku bola HDP/O-U',
|
parlay_guide_2: 'Pilih 2–5 pilihan (boleh perlawanan sama). Tiada outright atau suku bola HDP/O-U',
|
||||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||||
@@ -570,6 +576,9 @@ const i18n = createI18n({
|
|||||||
parlay_filter_all: 'Semua',
|
parlay_filter_all: 'Semua',
|
||||||
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
||||||
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
||||||
|
parlay_same_match_singles: '{n} pilihan → {n} pertaruhan tunggal berasingan',
|
||||||
|
parlay_confirm_singles: 'Sahkan {n} pertaruhan tunggal',
|
||||||
|
parlay_confirm_parlay: 'Sahkan parlay',
|
||||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||||
back: 'Kembali',
|
back: 'Kembali',
|
||||||
refresh: 'Muat semula',
|
refresh: 'Muat semula',
|
||||||
@@ -685,7 +694,7 @@ const i18n = createI18n({
|
|||||||
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
|
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
|
||||||
rules_title: 'Peraturan Pertaruhan',
|
rules_title: 'Peraturan Pertaruhan',
|
||||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
||||||
rules_p2: 'Parlay 2–5 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
rules_p2: 'Parlay 2–5 pilihan, boleh pilih berbilang dari perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||||
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
||||||
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
||||||
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 球赛/详情页:仅单关,且投注单同时只能有 1 项 */
|
/** 球赛/详情页:同场可多选,分笔单关;再点同一项可取消 */
|
||||||
function addItem(item: SlipItem) {
|
function addItem(item: SlipItem) {
|
||||||
if (mode.value === 'parlay') items.value = [];
|
if (mode.value === 'parlay') items.value = [];
|
||||||
mode.value = 'single';
|
mode.value = 'single';
|
||||||
@@ -44,13 +44,13 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
(i) => i.selectionId === item.selectionId,
|
(i) => i.selectionId === item.selectionId,
|
||||||
);
|
);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
items.value = [];
|
items.value.splice(existing, 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.value = [item];
|
items.value.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 串关页专用:跨场组合,同场只保留一个选项 */
|
/** 串关页:同场/跨场均可多选,合成一张串关单 */
|
||||||
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
|
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
|
||||||
if (mode.value === 'single') items.value = [];
|
if (mode.value === 'single') items.value = [];
|
||||||
mode.value = 'parlay';
|
mode.value = 'parlay';
|
||||||
@@ -77,7 +77,6 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
return 'MAX_LEGS';
|
return 'MAX_LEGS';
|
||||||
}
|
}
|
||||||
|
|
||||||
items.value = items.value.filter((i) => i.matchId !== item.matchId);
|
|
||||||
items.value.push(item);
|
items.value.push(item);
|
||||||
lastParlayError.value = null;
|
lastParlayError.value = null;
|
||||||
return null;
|
return null;
|
||||||
@@ -101,10 +100,10 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
|
|
||||||
const potentialReturn = computed(() => {
|
const potentialReturn = computed(() => {
|
||||||
if (!items.value.length) return 0;
|
if (!items.value.length) return 0;
|
||||||
if (mode.value === 'parlay' && items.value.length >= PARLAY_MIN_LEGS) {
|
if (mode.value === 'parlay' && canPlaceParlay.value) {
|
||||||
return stake.value * totalOdds.value;
|
return stake.value * totalOdds.value;
|
||||||
}
|
}
|
||||||
return stake.value * items.value[0].odds;
|
return items.value.reduce((acc, i) => acc + stake.value * i.odds, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSameMatch = computed(() => {
|
const hasSameMatch = computed(() => {
|
||||||
@@ -116,8 +115,16 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
() =>
|
() =>
|
||||||
mode.value === 'parlay' &&
|
mode.value === 'parlay' &&
|
||||||
items.value.length >= PARLAY_MIN_LEGS &&
|
items.value.length >= PARLAY_MIN_LEGS &&
|
||||||
items.value.length <= PARLAY_MAX_LEGS &&
|
items.value.length <= PARLAY_MAX_LEGS,
|
||||||
!hasSameMatch.value,
|
);
|
||||||
|
|
||||||
|
/** 详情页等同场多笔单关(串关模式不走此路径) */
|
||||||
|
const canPlaceBatchSingles = computed(
|
||||||
|
() => mode.value === 'single' && items.value.length >= 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => canPlaceParlay.value || canPlaceBatchSingles.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const drawerOpen = ref(false);
|
const drawerOpen = ref(false);
|
||||||
@@ -140,6 +147,8 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
potentialReturn,
|
potentialReturn,
|
||||||
hasSameMatch,
|
hasSameMatch,
|
||||||
canPlaceParlay,
|
canPlaceParlay,
|
||||||
|
canPlaceBatchSingles,
|
||||||
|
canSubmit,
|
||||||
lastParlayError,
|
lastParlayError,
|
||||||
drawerOpen,
|
drawerOpen,
|
||||||
addItem,
|
addItem,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import vsImg from '../assets/images/vs.png';
|
|||||||
import cardBg from '../assets/images/卡片.png';
|
import cardBg from '../assets/images/卡片.png';
|
||||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||||
import { teamFlagUrl } from '../utils/teamFlag';
|
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||||
|
|
||||||
const matchCardBg = `url(${cardBg})`;
|
const matchCardBg = `url(${cardBg})`;
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
@@ -28,13 +28,6 @@ function formatKickoff(startTime: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function homeFlag(match: (typeof hotMatches.value)[number]) {
|
|
||||||
return teamFlagUrl(match.homeTeamCode, match.homeTeamName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|
||||||
return teamFlagUrl(match.awayTeamCode, match.awayTeamName);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -53,8 +46,12 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
<div class="match-time">{{ formatKickoff(match.startTime) }}</div>
|
<div class="match-time">{{ formatKickoff(match.startTime) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="match-flags" aria-hidden="true">
|
<div class="match-flags" aria-hidden="true">
|
||||||
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
|
<TeamEmblem
|
||||||
<span v-else class="flag-ph">⚽</span>
|
size="md"
|
||||||
|
:team-code="match.homeTeamCode"
|
||||||
|
:team-name="match.homeTeamName"
|
||||||
|
:logo-url="match.homeTeamLogoUrl"
|
||||||
|
/>
|
||||||
<div class="vs-arena">
|
<div class="vs-arena">
|
||||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
@@ -80,8 +77,12 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
<span class="hz-beam" aria-hidden="true" />
|
<span class="hz-beam" aria-hidden="true" />
|
||||||
<img :src="vsImg" alt="" class="vs-img" />
|
<img :src="vsImg" alt="" class="vs-img" />
|
||||||
</div>
|
</div>
|
||||||
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
|
<TeamEmblem
|
||||||
<span v-else class="flag-ph">⚽</span>
|
size="md"
|
||||||
|
:team-code="match.awayTeamCode"
|
||||||
|
:team-name="match.awayTeamName"
|
||||||
|
:logo-url="match.awayTeamLogoUrl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,32 +156,15 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
max-width: 46%;
|
||||||
|
|
||||||
.flag {
|
|
||||||
width: 40px;
|
|
||||||
height: 28px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-ph {
|
|
||||||
width: 40px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vs-arena {
|
.vs-arena {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 72px;
|
width: 64px;
|
||||||
height: 58px;
|
height: 52px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -240,7 +224,7 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
.vs-img {
|
.vs-img {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
width: 58px;
|
width: 48px;
|
||||||
height: auto;
|
height: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
animation: vs-glow 2.4s ease-in-out infinite;
|
animation: vs-glow 2.4s ease-in-out infinite;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useBetSlipStore } from '../stores/betSlip';
|
import { useBetSlipStore } from '../stores/betSlip';
|
||||||
import { teamFlagUrl } from '../utils/teamFlag';
|
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||||
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
||||||
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
|
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
|
||||||
import MarketTypeTile from '../components/match-detail/MarketTypeTile.vue';
|
import MarketTypeTile from '../components/match-detail/MarketTypeTile.vue';
|
||||||
@@ -75,13 +75,6 @@ const marketsByType = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const homeFlag = computed(() =>
|
|
||||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName, match.value?.homeTeamLogoUrl),
|
|
||||||
);
|
|
||||||
const awayFlag = computed(() =>
|
|
||||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName, match.value?.awayTeamLogoUrl),
|
|
||||||
);
|
|
||||||
|
|
||||||
function marketPromoLabel(marketType: string) {
|
function marketPromoLabel(marketType: string) {
|
||||||
const m = marketsByType.value.get(marketType);
|
const m = marketsByType.value.get(marketType);
|
||||||
return m?.promoLabel?.trim() || '';
|
return m?.promoLabel?.trim() || '';
|
||||||
@@ -249,11 +242,12 @@ function openBetSlipDrawer() {
|
|||||||
slip.openDrawer();
|
slip.openDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前玩法是否已选入投注单(单关、仅一项) */
|
/** 当前玩法是否已有选项加入投注单 */
|
||||||
function hasSlipPickForMarket(marketType: string) {
|
function hasSlipPickForMarket(marketType: string) {
|
||||||
if (!match.value || slip.mode !== 'single' || slip.items.length !== 1) return false;
|
if (!match.value || slip.mode !== 'single') return false;
|
||||||
const item = slip.items[0];
|
return slip.items.some(
|
||||||
return item.matchId === match.value.id && item.marketType === marketType;
|
(item) => item.matchId === match.value!.id && item.marketType === marketType,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -284,8 +278,12 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
<div class="hero-teams">
|
<div class="hero-teams">
|
||||||
<!-- home -->
|
<!-- home -->
|
||||||
<div class="hero-team">
|
<div class="hero-team">
|
||||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="hero-flag" />
|
<TeamEmblem
|
||||||
<span v-else class="hero-flag-ph">⚽</span>
|
size="lg"
|
||||||
|
:team-code="match.homeTeamCode"
|
||||||
|
:team-name="match.homeTeamName"
|
||||||
|
:logo-url="match.homeTeamLogoUrl"
|
||||||
|
/>
|
||||||
<span class="hero-name">{{ match.homeTeamName }}</span>
|
<span class="hero-name">{{ match.homeTeamName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -318,8 +316,12 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
|
|
||||||
<!-- away -->
|
<!-- away -->
|
||||||
<div class="hero-team">
|
<div class="hero-team">
|
||||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="hero-flag" />
|
<TeamEmblem
|
||||||
<span v-else class="hero-flag-ph">⚽</span>
|
size="lg"
|
||||||
|
:team-code="match.awayTeamCode"
|
||||||
|
:team-name="match.awayTeamName"
|
||||||
|
:logo-url="match.awayTeamLogoUrl"
|
||||||
|
/>
|
||||||
<span class="hero-name">{{ match.awayTeamName }}</span>
|
<span class="hero-name">{{ match.awayTeamName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,24 +489,6 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-flag {
|
|
||||||
width: 54px;
|
|
||||||
height: 36px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-flag-ph {
|
|
||||||
width: 54px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 22px;
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-name {
|
.hero-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "足球投注平台 MVP - Monorepo",
|
"description": "足球投注平台 MVP - Monorepo",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel -r run dev",
|
"dev": "pnpm --filter @thebet365/shared build && pnpm --parallel -r run dev",
|
||||||
"dev:api": "pnpm --filter @thebet365/api dev",
|
"dev:api": "pnpm --filter @thebet365/api dev",
|
||||||
"dev:player": "pnpm --filter @thebet365/player dev",
|
"dev:player": "pnpm --filter @thebet365/player dev",
|
||||||
"dev:admin": "pnpm --filter @thebet365/admin dev",
|
"dev:admin": "pnpm --filter @thebet365/admin dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user