- Add sWAR cap validation to TransactionBuilder.validate_transaction() - Use team-specific salary_cap from Team.salary_cap field - Fall back to config.swar_cap_limit (32.0) if team has no custom cap - Add major_league_swar_cap field to RosterValidationResult - Update major_league_swar_status to show ✅/❌ with cap limit - Add 4 new tests for sWAR cap validation This fixes a bug where IL moves could put a team over their sWAR cap because the validation only checked roster counts, not sWAR limits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
619 lines
27 KiB
Python
619 lines
27 KiB
Python
"""
|
||
Transaction Builder Service
|
||
|
||
Handles the complex logic for building multi-move transactions interactively.
|
||
"""
|
||
import logging
|
||
from typing import Dict, List, Optional, Tuple, Set
|
||
from enum import Enum
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
|
||
from config import get_config
|
||
from models.transaction import Transaction
|
||
from models.team import Team
|
||
from models.player import Player
|
||
from models.roster import TeamRoster
|
||
from services.player_service import player_service
|
||
from services.team_service import team_service
|
||
from services.roster_service import roster_service
|
||
from services.transaction_service import transaction_service
|
||
from services.league_service import league_service
|
||
from models.team import RosterType
|
||
|
||
logger = logging.getLogger(f'{__name__}.TransactionBuilder')
|
||
|
||
|
||
# Removed MoveAction enum - using simple from/to roster locations instead
|
||
|
||
|
||
@dataclass
|
||
class TransactionMove:
|
||
"""Individual move within a transaction."""
|
||
player: Player
|
||
from_roster: RosterType
|
||
to_roster: RosterType
|
||
from_team: Optional[Team] = None
|
||
to_team: Optional[Team] = None
|
||
|
||
@property
|
||
def description(self) -> str:
|
||
"""Human readable move description."""
|
||
# Determine emoji and format based on from/to locations
|
||
if self.from_roster == RosterType.FREE_AGENCY and self.to_roster != RosterType.FREE_AGENCY:
|
||
# Add from Free Agency
|
||
emoji = "➕"
|
||
return f"{emoji} {self.player.name}: FA → {self.to_team.abbrev} ({self.to_roster.value.upper()})"
|
||
elif self.from_roster != RosterType.FREE_AGENCY and self.to_roster == RosterType.FREE_AGENCY:
|
||
# Drop to Free Agency
|
||
emoji = "➖"
|
||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} ({self.from_roster.value.upper()}) → FA"
|
||
elif self.from_roster == RosterType.MINOR_LEAGUE and self.to_roster == RosterType.MAJOR_LEAGUE:
|
||
# Recall from MiL to ML
|
||
emoji = "⬆️"
|
||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} (MiL) → {self.to_team.abbrev} (ML)"
|
||
elif self.from_roster == RosterType.MAJOR_LEAGUE and self.to_roster == RosterType.MINOR_LEAGUE:
|
||
# Demote from ML to MiL
|
||
emoji = "⬇️"
|
||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} (ML) → {self.to_team.abbrev} (MiL)"
|
||
elif self.to_roster == RosterType.INJURED_LIST:
|
||
# Move to Injured List
|
||
emoji = "🏥"
|
||
from_desc = "FA" if self.from_roster == RosterType.FREE_AGENCY else f"{self.from_team.abbrev} ({self.from_roster.value.upper()})"
|
||
return f"{emoji} {self.player.name}: {from_desc} → {self.to_team.abbrev} (IL)"
|
||
elif self.from_roster == RosterType.INJURED_LIST:
|
||
# Return from Injured List
|
||
emoji = "💊"
|
||
to_desc = "FA" if self.to_roster == RosterType.FREE_AGENCY else f"{self.to_team.abbrev} ({self.to_roster.value.upper()})"
|
||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} (IL) → {to_desc}"
|
||
else:
|
||
# Generic move
|
||
emoji = "🔄"
|
||
from_desc = "FA" if self.from_roster == RosterType.FREE_AGENCY else f"{self.from_team.abbrev} ({self.from_roster.value.upper()})"
|
||
to_desc = "FA" if self.to_roster == RosterType.FREE_AGENCY else f"{self.to_team.abbrev} ({self.to_roster.value.upper()})"
|
||
return f"{emoji} {self.player.name}: {from_desc} → {to_desc}"
|
||
|
||
|
||
@dataclass
|
||
class RosterValidationResult:
|
||
"""Results of roster validation."""
|
||
is_legal: bool
|
||
major_league_count: int
|
||
minor_league_count: int
|
||
warnings: List[str]
|
||
errors: List[str]
|
||
suggestions: List[str]
|
||
major_league_limit: int = 26
|
||
minor_league_limit: int = 6
|
||
major_league_swar: float = 0.0
|
||
minor_league_swar: float = 0.0
|
||
major_league_swar_cap: float = 32.0 # Team's sWAR cap limit
|
||
pre_existing_ml_swar_change: float = 0.0
|
||
pre_existing_mil_swar_change: float = 0.0
|
||
pre_existing_transaction_count: int = 0
|
||
|
||
@property
|
||
def major_league_status(self) -> str:
|
||
"""Status string for major league roster."""
|
||
if self.major_league_count > self.major_league_limit:
|
||
return f"❌ Major League: {self.major_league_count}/{self.major_league_limit} (Over limit!)"
|
||
else:
|
||
return f"✅ Major League: {self.major_league_count}/{self.major_league_limit} (Legal)"
|
||
|
||
@property
|
||
def minor_league_status(self) -> str:
|
||
"""Status string for minor league roster."""
|
||
if self.minor_league_count > self.minor_league_limit:
|
||
return f"❌ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Over limit!)"
|
||
else:
|
||
return f"✅ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Legal)"
|
||
|
||
@property
|
||
def major_league_swar_status(self) -> str:
|
||
"""Status string for major league sWAR."""
|
||
if self.major_league_swar > self.major_league_swar_cap:
|
||
return f"❌ Major League sWAR: {self.major_league_swar:.2f}/{self.major_league_swar_cap:.1f} (Over cap!)"
|
||
else:
|
||
return f"✅ Major League sWAR: {self.major_league_swar:.2f}/{self.major_league_swar_cap:.1f}"
|
||
|
||
@property
|
||
def minor_league_swar_status(self) -> str:
|
||
"""Status string for minor league sWAR."""
|
||
return f"📊 Minor League sWAR: {self.minor_league_swar:.2f}"
|
||
|
||
@property
|
||
def pre_existing_transactions_note(self) -> str:
|
||
"""Note about pre-existing transactions affecting calculations."""
|
||
if self.pre_existing_transaction_count == 0:
|
||
return ""
|
||
|
||
total_swar_change = self.pre_existing_ml_swar_change + self.pre_existing_mil_swar_change
|
||
|
||
if total_swar_change == 0:
|
||
return f"ℹ️ **Pre-existing Moves**: {self.pre_existing_transaction_count} scheduled moves (no sWAR impact)"
|
||
elif total_swar_change > 0:
|
||
return f"ℹ️ **Pre-existing Moves**: {self.pre_existing_transaction_count} scheduled moves (+{total_swar_change:.2f} sWAR)"
|
||
else:
|
||
return f"ℹ️ **Pre-existing Moves**: {self.pre_existing_transaction_count} scheduled moves ({total_swar_change:.2f} sWAR)"
|
||
|
||
|
||
class TransactionBuilder:
|
||
"""Interactive transaction builder for complex multi-move transactions."""
|
||
|
||
def __init__(self, team: Team, user_id: int, season: int = get_config().sba_season):
|
||
"""
|
||
Initialize transaction builder.
|
||
|
||
Args:
|
||
team: Team making the transaction
|
||
user_id: Discord user ID of the GM
|
||
season: Season number
|
||
"""
|
||
self.team = team
|
||
self.user_id = user_id
|
||
self.season = season
|
||
self.moves: List[TransactionMove] = []
|
||
self.created_at = datetime.now(timezone.utc)
|
||
|
||
# Cache for roster data
|
||
self._current_roster: Optional[TeamRoster] = None
|
||
self._roster_loaded = False
|
||
|
||
# Cache for pre-existing transactions
|
||
self._existing_transactions: Optional[List[Transaction]] = None
|
||
self._existing_transactions_loaded = False
|
||
|
||
logger.info(f"TransactionBuilder initialized for {team.abbrev} by user {user_id}")
|
||
|
||
async def load_roster_data(self) -> None:
|
||
"""Load current roster data for the team."""
|
||
if self._roster_loaded:
|
||
return
|
||
|
||
try:
|
||
self._current_roster = await roster_service.get_current_roster(self.team.id)
|
||
self._roster_loaded = True
|
||
logger.debug(f"Loaded roster data for team {self.team.abbrev}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to load roster data: {e}")
|
||
self._current_roster = None
|
||
self._roster_loaded = True
|
||
|
||
async def load_existing_transactions(self, next_week: int) -> None:
|
||
"""Load pre-existing transactions for next week."""
|
||
if self._existing_transactions_loaded:
|
||
return
|
||
|
||
try:
|
||
self._existing_transactions = await transaction_service.get_team_transactions(
|
||
team_abbrev=self.team.abbrev,
|
||
season=self.season,
|
||
cancelled=False,
|
||
week_start=next_week
|
||
)
|
||
self._existing_transactions_loaded = True
|
||
logger.debug(f"Loaded {len(self._existing_transactions or [])} existing transactions for {self.team.abbrev} week {next_week}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to load existing transactions: {e}")
|
||
self._existing_transactions = []
|
||
self._existing_transactions_loaded = True
|
||
|
||
async def add_move(
|
||
self,
|
||
move: TransactionMove,
|
||
next_week: Optional[int] = None,
|
||
check_pending_transactions: bool = True
|
||
) -> tuple[bool, str]:
|
||
"""
|
||
Add a move to the transaction.
|
||
|
||
Args:
|
||
move: TransactionMove to add
|
||
next_week: Week number for pending transaction check (optional, will be fetched if not provided)
|
||
check_pending_transactions: Whether to check if player is in another team's pending
|
||
transaction. Set to True for /dropadd (scheduled moves), False for /ilmove (immediate).
|
||
|
||
Returns:
|
||
Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
|
||
"""
|
||
# Check if player is already in a move in this transaction builder
|
||
existing_move = self.get_move_for_player(move.player.id)
|
||
if existing_move:
|
||
error_msg = f"Player {move.player.name} already has a move in this transaction"
|
||
logger.warning(error_msg)
|
||
return False, error_msg
|
||
|
||
# Check if from_team and to_team are the same AND from_roster and to_roster are the same
|
||
# (when both teams are not None) - this would be a meaningless move
|
||
if (move.from_team is not None and move.to_team is not None and
|
||
move.from_team.id == move.to_team.id and move.from_roster == move.to_roster):
|
||
error_msg = f"Cannot move {move.player.name} from {move.from_team.abbrev} ({move.from_roster.value.upper()}) to {move.to_team.abbrev} ({move.to_roster.value.upper()}) - player is already in that location"
|
||
logger.warning(error_msg)
|
||
return False, error_msg
|
||
|
||
# Check if player is already in another team's pending transaction for next week
|
||
# This prevents duplicate claims that would need to be resolved at freeze time
|
||
# Only applies to /dropadd (scheduled moves), not /ilmove (immediate moves)
|
||
if check_pending_transactions:
|
||
if next_week is None:
|
||
try:
|
||
current_state = await league_service.get_current_state()
|
||
next_week = (current_state.week + 1) if current_state else 1
|
||
except Exception as e:
|
||
logger.warning(f"Could not get current week for pending transaction check: {e}")
|
||
next_week = 1
|
||
|
||
is_pending, claiming_team = await transaction_service.is_player_in_pending_transaction(
|
||
player_id=move.player.id,
|
||
week=next_week,
|
||
season=self.season
|
||
)
|
||
|
||
if is_pending:
|
||
error_msg = f"{move.player.name} is already in a pending transaction for week {next_week} (claimed by {claiming_team})"
|
||
logger.warning(error_msg)
|
||
return False, error_msg
|
||
|
||
self.moves.append(move)
|
||
logger.info(f"Added move: {move.description}")
|
||
return True, ""
|
||
|
||
def remove_move(self, player_id: int) -> bool:
|
||
"""
|
||
Remove a move for a specific player.
|
||
|
||
Args:
|
||
player_id: ID of player to remove move for
|
||
|
||
Returns:
|
||
True if move was removed
|
||
"""
|
||
original_count = len(self.moves)
|
||
self.moves = [move for move in self.moves if move.player.id != player_id]
|
||
|
||
removed = len(self.moves) < original_count
|
||
if removed:
|
||
logger.info(f"Removed move for player {player_id}")
|
||
|
||
return removed
|
||
|
||
def get_move_for_player(self, player_id: int) -> Optional[TransactionMove]:
|
||
"""Get the move for a specific player if it exists."""
|
||
for move in self.moves:
|
||
if move.player.id == player_id:
|
||
return move
|
||
return None
|
||
|
||
async def validate_transaction(self, next_week: Optional[int] = None) -> RosterValidationResult:
|
||
"""
|
||
Validate the current transaction and return detailed results.
|
||
|
||
Args:
|
||
next_week: Week to check for existing transactions (optional)
|
||
|
||
Returns:
|
||
RosterValidationResult with validation details
|
||
"""
|
||
await self.load_roster_data()
|
||
|
||
# Load existing transactions if next_week is provided
|
||
if next_week is not None:
|
||
await self.load_existing_transactions(next_week)
|
||
|
||
if not self._current_roster:
|
||
return RosterValidationResult(
|
||
is_legal=False,
|
||
major_league_count=0,
|
||
minor_league_count=0,
|
||
warnings=[],
|
||
errors=["Could not load current roster data"],
|
||
suggestions=[]
|
||
)
|
||
|
||
# Calculate roster changes from moves
|
||
ml_changes = 0
|
||
mil_changes = 0
|
||
errors = []
|
||
warnings = []
|
||
suggestions = []
|
||
|
||
# Calculate current sWAR for each roster
|
||
current_ml_swar = sum(player.wara for player in self._current_roster.active_players)
|
||
current_mil_swar = sum(player.wara for player in self._current_roster.minor_league_players)
|
||
|
||
# Track sWAR changes from moves
|
||
ml_swar_changes = 0.0
|
||
mil_swar_changes = 0.0
|
||
|
||
# Track pre-existing transaction changes separately
|
||
pre_existing_ml_swar_change = 0.0
|
||
pre_existing_mil_swar_change = 0.0
|
||
pre_existing_count = 0
|
||
|
||
# Process existing transactions first
|
||
if self._existing_transactions:
|
||
for transaction in self._existing_transactions:
|
||
# Skip if this transaction was already processed or cancelled
|
||
if transaction.cancelled:
|
||
continue
|
||
|
||
pre_existing_count += 1
|
||
|
||
# Determine roster changes from existing transaction
|
||
# Use Team.is_same_organization() to check if transaction affects our organization
|
||
|
||
# Leaving our organization (from any roster)
|
||
if transaction.oldteam.is_same_organization(self.team):
|
||
# Player leaving our organization - determine which roster they're leaving from
|
||
from_roster_type = transaction.oldteam.roster_type()
|
||
|
||
if from_roster_type == RosterType.MAJOR_LEAGUE:
|
||
ml_changes -= 1
|
||
ml_swar_changes -= transaction.player.wara
|
||
pre_existing_ml_swar_change -= transaction.player.wara
|
||
elif from_roster_type == RosterType.MINOR_LEAGUE:
|
||
mil_changes -= 1
|
||
mil_swar_changes -= transaction.player.wara
|
||
pre_existing_mil_swar_change -= transaction.player.wara
|
||
# Note: IL players don't count toward roster limits, so no changes needed
|
||
|
||
# Joining our organization (to any roster)
|
||
if transaction.newteam.is_same_organization(self.team):
|
||
# Player joining our organization - determine which roster they're joining
|
||
to_roster_type = transaction.newteam.roster_type()
|
||
|
||
if to_roster_type == RosterType.MAJOR_LEAGUE:
|
||
ml_changes += 1
|
||
ml_swar_changes += transaction.player.wara
|
||
pre_existing_ml_swar_change += transaction.player.wara
|
||
elif to_roster_type == RosterType.MINOR_LEAGUE:
|
||
mil_changes += 1
|
||
mil_swar_changes += transaction.player.wara
|
||
pre_existing_mil_swar_change += transaction.player.wara
|
||
# Note: IL players don't count toward roster limits, so no changes needed
|
||
|
||
for move in self.moves:
|
||
# Log move being processed for diagnostics
|
||
logger.debug(f"🔍 VALIDATION: Processing move - {move.player.name} (ID={move.player.id})")
|
||
logger.debug(f"🔍 VALIDATION: from_roster={move.from_roster.value}, to_roster={move.to_roster.value}")
|
||
|
||
# Calculate roster changes based on from/to locations
|
||
if move.from_roster == RosterType.MAJOR_LEAGUE:
|
||
ml_changes -= 1
|
||
ml_swar_changes -= move.player.wara
|
||
logger.debug(f"🔍 VALIDATION: ML decrement - ml_changes now {ml_changes}")
|
||
elif move.from_roster == RosterType.MINOR_LEAGUE:
|
||
mil_changes -= 1
|
||
mil_swar_changes -= move.player.wara
|
||
logger.debug(f"🔍 VALIDATION: MiL decrement - mil_changes now {mil_changes}")
|
||
# Note: INJURED_LIST and FREE_AGENCY don't count toward ML roster limit
|
||
|
||
if move.to_roster == RosterType.MAJOR_LEAGUE:
|
||
ml_changes += 1
|
||
ml_swar_changes += move.player.wara
|
||
logger.debug(f"🔍 VALIDATION: ML increment - ml_changes now {ml_changes}")
|
||
elif move.to_roster == RosterType.MINOR_LEAGUE:
|
||
mil_changes += 1
|
||
mil_swar_changes += move.player.wara
|
||
logger.debug(f"🔍 VALIDATION: MiL increment - mil_changes now {mil_changes}")
|
||
# Note: INJURED_LIST and FREE_AGENCY don't count toward ML roster limit
|
||
|
||
# Calculate projected roster sizes and sWAR
|
||
# Only Major League players count toward ML roster limit (IL and MiL are separate)
|
||
current_ml_size = len(self._current_roster.active_players)
|
||
current_mil_size = len(self._current_roster.minor_league_players)
|
||
|
||
logger.debug(f"🔍 VALIDATION: Current roster - ML:{current_ml_size}, MiL:{current_mil_size}")
|
||
logger.debug(f"🔍 VALIDATION: Changes calculated - ml_changes:{ml_changes}, mil_changes:{mil_changes}")
|
||
|
||
projected_ml_size = current_ml_size + ml_changes
|
||
projected_mil_size = current_mil_size + mil_changes
|
||
projected_ml_swar = current_ml_swar + ml_swar_changes
|
||
projected_mil_swar = current_mil_swar + mil_swar_changes
|
||
|
||
logger.debug(f"🔍 VALIDATION: Projected roster - ML:{projected_ml_size}, MiL:{projected_mil_size}")
|
||
logger.debug(f"🔍 VALIDATION: Projected sWAR - ML:{projected_ml_swar:.2f}, MiL:{projected_mil_swar:.2f}")
|
||
|
||
# Get current week and config to determine roster limits
|
||
config = get_config()
|
||
try:
|
||
current_state = await league_service.get_current_state()
|
||
current_week = current_state.week if current_state else 1
|
||
except Exception as e:
|
||
logger.warning(f"Could not get current week, using default limits: {e}")
|
||
current_week = 1
|
||
|
||
# Determine roster limits based on week and offseason flag
|
||
# During offseason, limits are relaxed to allow roster building
|
||
if config.offseason_flag:
|
||
ml_limit = config.ml_roster_limit_offseason
|
||
mil_limit = config.mil_roster_limit_offseason
|
||
logger.debug("🔍 VALIDATION: Offseason mode - using relaxed roster limits")
|
||
elif current_week < config.expand_mil_week:
|
||
ml_limit = config.ml_roster_limit_early
|
||
mil_limit = config.mil_roster_limit_early
|
||
else:
|
||
ml_limit = config.ml_roster_limit_late
|
||
mil_limit = config.mil_roster_limit_late
|
||
|
||
# Validate roster limits
|
||
is_legal = True
|
||
if projected_ml_size > ml_limit:
|
||
is_legal = False
|
||
errors.append(f"Major League roster would have {projected_ml_size} players (limit: {ml_limit})")
|
||
suggestions.append(f"Drop {projected_ml_size - ml_limit} ML player(s) to make roster legal")
|
||
elif projected_ml_size < 0:
|
||
is_legal = False
|
||
errors.append("Cannot have negative players on Major League roster")
|
||
|
||
if projected_mil_size > mil_limit:
|
||
is_legal = False
|
||
errors.append(f"Minor League roster would have {projected_mil_size} players (limit: {mil_limit})")
|
||
suggestions.append(f"Drop {projected_mil_size - mil_limit} MiL player(s) to make roster legal")
|
||
elif projected_mil_size < 0:
|
||
is_legal = False
|
||
errors.append("Cannot have negative players on Minor League roster")
|
||
|
||
# Validate sWAR cap
|
||
# Use team-specific cap if set, otherwise fall back to config default
|
||
team_swar_cap = self.team.salary_cap if self.team.salary_cap is not None else config.swar_cap_limit
|
||
if projected_ml_swar > team_swar_cap:
|
||
is_legal = False
|
||
errors.append(f"Major League sWAR would be {projected_ml_swar:.2f} (cap: {team_swar_cap:.1f})")
|
||
over_cap = projected_ml_swar - team_swar_cap
|
||
suggestions.append(f"Remove {over_cap:.2f} sWAR from ML roster to be under cap")
|
||
|
||
# Add suggestions for empty transaction
|
||
if not self.moves:
|
||
suggestions.append("Add player moves to build your transaction")
|
||
|
||
return RosterValidationResult(
|
||
is_legal=is_legal,
|
||
major_league_count=projected_ml_size,
|
||
minor_league_count=projected_mil_size,
|
||
warnings=warnings,
|
||
errors=errors,
|
||
suggestions=suggestions,
|
||
major_league_limit=ml_limit,
|
||
minor_league_limit=mil_limit,
|
||
major_league_swar=projected_ml_swar,
|
||
minor_league_swar=projected_mil_swar,
|
||
major_league_swar_cap=team_swar_cap,
|
||
pre_existing_ml_swar_change=pre_existing_ml_swar_change,
|
||
pre_existing_mil_swar_change=pre_existing_mil_swar_change,
|
||
pre_existing_transaction_count=pre_existing_count
|
||
)
|
||
|
||
async def submit_transaction(self, week: int, check_existing_transactions: bool = True) -> List[Transaction]:
|
||
"""
|
||
Submit the transaction by creating individual Transaction models.
|
||
|
||
Args:
|
||
week: Week the transaction is effective for
|
||
check_existing_transactions: Whether to include pre-existing transactions in validation.
|
||
Set to True for /dropadd (scheduled moves - need to check against other scheduled moves).
|
||
Set to False for /ilmove (immediate moves - already in database, don't double-count).
|
||
|
||
Returns:
|
||
List of created Transaction objects
|
||
"""
|
||
if not self.moves:
|
||
raise ValueError("Cannot submit empty transaction")
|
||
|
||
# For immediate moves (/ilmove), don't check pre-existing transactions
|
||
if check_existing_transactions:
|
||
validation = await self.validate_transaction(next_week=week)
|
||
else:
|
||
validation = await self.validate_transaction()
|
||
|
||
if not validation.is_legal:
|
||
raise ValueError(f"Cannot submit illegal transaction: {', '.join(validation.errors)}")
|
||
|
||
transactions = []
|
||
move_id = f"Season-{self.season:03d}-Week-{week:02d}-{int(self.created_at.timestamp())}"
|
||
|
||
# Create FA team for drops using config value
|
||
config = get_config()
|
||
fa_team = Team(
|
||
id=config.free_agent_team_id, # Correct FA team ID from config (498)
|
||
abbrev="FA",
|
||
sname="Free Agents",
|
||
lname="Free Agency",
|
||
season=self.season
|
||
) # type: ignore
|
||
|
||
for move in self.moves:
|
||
# Determine old and new teams based on roster locations
|
||
# We need to map RosterType to the actual team (ML, MiL, or IL affiliate)
|
||
if move.from_roster == RosterType.FREE_AGENCY:
|
||
old_team = fa_team
|
||
else:
|
||
base_team = move.from_team or self.team
|
||
# Get the appropriate affiliate based on roster type
|
||
if move.from_roster == RosterType.MAJOR_LEAGUE:
|
||
old_team = base_team # Already ML team
|
||
elif move.from_roster == RosterType.MINOR_LEAGUE:
|
||
old_team = await base_team.minor_league_affiliate()
|
||
elif move.from_roster == RosterType.INJURED_LIST:
|
||
old_team = await base_team.injured_list_affiliate()
|
||
else:
|
||
old_team = base_team
|
||
|
||
if move.to_roster == RosterType.FREE_AGENCY:
|
||
new_team = fa_team
|
||
else:
|
||
base_team = move.to_team or self.team
|
||
# Get the appropriate affiliate based on roster type
|
||
if move.to_roster == RosterType.MAJOR_LEAGUE:
|
||
new_team = base_team # Already ML team
|
||
elif move.to_roster == RosterType.MINOR_LEAGUE:
|
||
new_team = await base_team.minor_league_affiliate()
|
||
elif move.to_roster == RosterType.INJURED_LIST:
|
||
new_team = await base_team.injured_list_affiliate()
|
||
else:
|
||
new_team = base_team
|
||
|
||
# For cases where we don't have specific teams, fall back to defaults
|
||
if not old_team:
|
||
continue
|
||
|
||
# Create transaction
|
||
transaction = Transaction(
|
||
id=0, # Will be set by API
|
||
week=week,
|
||
season=self.season,
|
||
moveid=move_id,
|
||
player=move.player,
|
||
oldteam=old_team,
|
||
newteam=new_team,
|
||
cancelled=False,
|
||
frozen=False
|
||
)
|
||
|
||
transactions.append(transaction)
|
||
|
||
logger.info(f"Created {len(transactions)} transactions for submission with move_id {move_id}")
|
||
return transactions
|
||
|
||
def clear_moves(self) -> None:
|
||
"""Clear all moves from the transaction builder."""
|
||
self.moves.clear()
|
||
logger.info("Cleared all moves from transaction builder")
|
||
|
||
@property
|
||
def is_empty(self) -> bool:
|
||
"""Check if transaction builder has no moves."""
|
||
return len(self.moves) == 0
|
||
|
||
@property
|
||
def move_count(self) -> int:
|
||
"""Get total number of moves in transaction."""
|
||
return len(self.moves)
|
||
|
||
|
||
# Global cache for active transaction builders
|
||
_active_builders: Dict[int, TransactionBuilder] = {}
|
||
|
||
|
||
def get_transaction_builder(user_id: int, team: Team) -> TransactionBuilder:
|
||
"""
|
||
Get or create a transaction builder for a user.
|
||
|
||
Args:
|
||
user_id: Discord user ID
|
||
team: Team object
|
||
|
||
Returns:
|
||
TransactionBuilder instance
|
||
"""
|
||
if user_id not in _active_builders:
|
||
_active_builders[user_id] = TransactionBuilder(team, user_id)
|
||
|
||
return _active_builders[user_id]
|
||
|
||
|
||
def clear_transaction_builder(user_id: int) -> None:
|
||
"""Clear transaction builder for a user."""
|
||
if user_id in _active_builders:
|
||
del _active_builders[user_id]
|
||
logger.info(f"Cleared transaction builder for user {user_id}") |