Commit 488d84c7 authored by Your Name's avatar Your Name

Fix duplicate WIN2 payout in extraction and bet resync after settlement

- BET CALC: deduplicate ExtractionAssociation outcomes per result before
  summing payout so WIN2 appearing twice no longer doubles the expected
  payout (14720→7360) fed into the extraction decision algorithm
- _update_bet_results: add updated_at to all bulk losing-bet UPDATE calls
  so BetDetailModel.updated_at is set correctly on bulk paths
- _update_bet_results: bump BetModel.updated_at on all parent bet records
  after settling, so the incremental sync (which gates on
  BetModel.updated_at > last_synced_at) will re-send previously-pending
  bets with their final win/lost result
- Migration 071: delete duplicate (outcome_name, extraction_result) rows
  from extraction_associations keeping the lowest-id survivor, then
  recreate the table with UNIQUE(outcome_name, extraction_result) to
  prevent recurrence
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 70074569
......@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent
from .message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..database.manager import DatabaseManager
from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, PersistentRedistributionAdjustmentModel, MatchTemplateModel, MatchOutcomeTemplateModel
from ..database.models import MatchModel, MatchStatus, BetModel, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, PersistentRedistributionAdjustmentModel, MatchTemplateModel, MatchOutcomeTemplateModel
from ..utils.timezone_utils import get_today_venue_date
logger = logging.getLogger(__name__)
......@@ -1875,11 +1875,16 @@ class GamesThread(ThreadedComponent):
total_payout = 0.0
outcome_list = []
seen_outcomes = set()
logger.info(f"💰 [BET CALC] Result '{result_name}': {len(associations)} associated outcomes")
for association in associations:
outcome_name = association.outcome_name
if outcome_name in seen_outcomes:
logger.warning(f"💰 [BET CALC] Outcome '{outcome_name}': DUPLICATE skipped")
continue
seen_outcomes.add(outcome_name)
outcome_list.append(outcome_name)
# Get coefficient for this outcome
......@@ -4078,7 +4083,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel.match_id == match_id,
BetDetailModel.outcome == other_under_over,
BetDetailModel.result == 'pending'
).update({'result': 'lost'})
).update({'result': 'lost', 'updated_at': datetime.utcnow()})
logger.info(f"DEBUG _update_bet_results: Set {losing_count} {other_under_over} bets to lost")
else:
# No UNDER/OVER result selected, all UNDER/OVER bets lose
......@@ -4086,7 +4091,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel.match_id == match_id,
BetDetailModel.outcome.in_(['UNDER', 'OVER']),
BetDetailModel.result == 'pending'
).update({'result': 'lost'})
).update({'result': 'lost', 'updated_at': datetime.utcnow()})
logger.info(f"DEBUG _update_bet_results: Set {losing_count} UNDER/OVER bets to lost (no UNDER/OVER result)")
# Update bets for the selected result to 'win' (if not UNDER/OVER)
......@@ -4140,7 +4145,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel.match_id == match_id,
BetDetailModel.result == 'pending',
~BetDetailModel.outcome.in_(losing_outcomes)
).update({'result': 'lost'})
).update({'result': 'lost', 'updated_at': datetime.utcnow()})
logger.info(f"DEBUG _update_bet_results: Set {losing_count} other bets to lost")
# Update the match result in the matches table with winning outcomes in separate fields
......@@ -4215,6 +4220,22 @@ class GamesThread(ThreadedComponent):
else:
logger.error(f"DEBUG _update_bet_results: Match {match_id} not found for result update!")
# Bump updated_at on all parent BetModel records for this match so the
# incremental sync picks them up again with their final result.
now = datetime.utcnow()
affected_bet_ids = [
row[0] for row in
session.query(BetDetailModel.bet_id)
.filter(BetDetailModel.match_id == match_id)
.distinct()
.all()
]
if affected_bet_ids:
bumped = session.query(BetModel).filter(
BetModel.uuid.in_(affected_bet_ids)
).update({'updated_at': now}, synchronize_session=False)
logger.info(f"DEBUG _update_bet_results: Bumped updated_at on {bumped} parent BetModel records")
session.commit()
logger.info(f"Updated bet results for match {match_id}: winner={selected_result}, coefficient={win_coefficient}")
......
......@@ -7731,6 +7731,108 @@ class Migration_070_SeedSmtpEmailSettings(DatabaseMigration):
return False
class Migration_071_DeduplicateExtractionAssociations(DatabaseMigration):
"""Remove duplicate (outcome_name, extraction_result) rows and enforce uniqueness."""
def __init__(self):
super().__init__("071", "Deduplicate extraction_associations and restore unique constraint")
def up(self, db_manager) -> bool:
try:
pk_sql = get_primary_key_sql(db_manager)
with db_manager.engine.connect() as conn:
# Step 1: keep only the lowest-id row per unique pair
conn.execute(text("""
DELETE FROM extraction_associations
WHERE id NOT IN (
SELECT MIN(id)
FROM extraction_associations
GROUP BY outcome_name, extraction_result
)
"""))
# Step 2: recreate table with UNIQUE(outcome_name, extraction_result)
conn.execute(text(f"""
CREATE TABLE extraction_associations_new (
{pk_sql},
outcome_name VARCHAR(255) NOT NULL,
extraction_result VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(outcome_name, extraction_result)
)
"""))
conn.execute(text("""
INSERT INTO extraction_associations_new
(id, outcome_name, extraction_result, is_default, created_at, updated_at)
SELECT id, outcome_name, extraction_result, is_default, created_at, updated_at
FROM extraction_associations
"""))
conn.execute(text("DROP TABLE extraction_associations"))
conn.execute(text("ALTER TABLE extraction_associations_new RENAME TO extraction_associations"))
for index_sql in [
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_outcome_name ON extraction_associations(outcome_name)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_extraction_result ON extraction_associations(extraction_result)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_composite ON extraction_associations(outcome_name, extraction_result)",
]:
conn.execute(text(index_sql))
conn.commit()
logger.info("Deduplicated extraction_associations and restored unique constraint on (outcome_name, extraction_result)")
return True
except Exception as e:
logger.error(f"Failed to deduplicate extraction_associations: {e}")
return False
def down(self, db_manager) -> bool:
"""Removes the unique constraint (mirrors migration_018 up)."""
try:
pk_sql = get_primary_key_sql(db_manager)
with db_manager.engine.connect() as conn:
conn.execute(text(f"""
CREATE TABLE extraction_associations_new (
{pk_sql},
outcome_name VARCHAR(255) NOT NULL,
extraction_result VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
INSERT INTO extraction_associations_new
(id, outcome_name, extraction_result, is_default, created_at, updated_at)
SELECT id, outcome_name, extraction_result, is_default, created_at, updated_at
FROM extraction_associations
"""))
conn.execute(text("DROP TABLE extraction_associations"))
conn.execute(text("ALTER TABLE extraction_associations_new RENAME TO extraction_associations"))
for index_sql in [
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_outcome_name ON extraction_associations(outcome_name)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_extraction_result ON extraction_associations(extraction_result)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_composite ON extraction_associations(outcome_name, extraction_result)",
]:
conn.execute(text(index_sql))
conn.commit()
logger.info("Removed unique constraint from extraction_associations")
return True
except Exception as e:
logger.error(f"Failed to remove unique constraint from extraction_associations: {e}")
return False
MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(),
Migration_002_AddIndexes(),
......@@ -7802,6 +7904,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_068_ResetRedistributionBalance(),
Migration_069_RepairExtractionStatsFinancials(),
Migration_070_SeedSmtpEmailSettings(),
Migration_071_DeduplicateExtractionAssociations(),
]
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment