major-domo-v2/services/transaction_builder.py
Cal Corum 36ecd1b3ff CLAUDE: Add /ilmove command for real-time roster moves with organizational affiliate support
Implemented comprehensive /ilmove command system for immediate roster changes (IL moves,
activations, etc.) that execute instantly for the current week, complementing the existing
/dropadd system which schedules moves for next week.

New Features:
- /ilmove command: Interactive transaction builder for THIS week (immediate execution)
- /clearilmove command: Clear current IL move transaction builder
- Dual-mode transaction system: Scheduled (/dropadd) vs Immediate (/ilmove)

Key Fixes:
- Organizational team matching: Minor League players (WVMiL) now correctly recognized as
  valid targets for their Major League organization (WV)
- Transaction POST format: Fixed to use correct batch API format with count/moves structure
- RosterType to team affiliate mapping: Moves to IL now correctly assign players to WVIL
  instead of WV, and moves from MiL correctly reference WVMiL as source team
- Player team updates: Added update_player_team() method for immediate team assignments

Technical Changes:
- commands/transactions/ilmove.py: New command with organizational validation
- commands/transactions/__init__.py: Register ILMoveCommands cog
- services/transaction_service.py: create_transaction_batch() with correct batch format
- services/player_service.py: update_player_team() for immediate updates
- services/transaction_builder.py: RosterType affiliate resolution with async team lookups
- views/transaction_embed.py: Dual-mode support with context-aware instructions

Code Reuse:
- 95% code sharing between /dropadd and /ilmove via shared TransactionBuilder
- Same validation, UI, and move tracking - only submission differs
- Context-aware command_name parameter for dynamic UI instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 20:15:12 -05:00

547 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
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."""
return f"📊 Major League sWAR: {self.major_league_swar:.2f}"
@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_current_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
def add_move(self, move: TransactionMove) -> tuple[bool, str]:
"""
Add a move to the transaction.
Args:
move: TransactionMove to add
Returns:
Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
"""
# Check if player is already in a move
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
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:
# Calculate roster changes based on from/to locations
if move.from_roster == RosterType.MAJOR_LEAGUE:
ml_changes -= 1
ml_swar_changes -= move.player.wara
elif move.from_roster == RosterType.MINOR_LEAGUE:
mil_changes -= 1
mil_swar_changes -= move.player.wara
# 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
elif move.to_roster == RosterType.MINOR_LEAGUE:
mil_changes += 1
mil_swar_changes += move.player.wara
# 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)
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
# Get current week to determine roster limits
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
# Major league: <=26 if week<=14, <=25 if week>14
# Minor league: <=6 if week<=14, <=14 if week>14
if current_week <= 14:
ml_limit = 26
mil_limit = 6
else:
ml_limit = 26
mil_limit = 14
# 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")
# 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,
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) -> List[Transaction]:
"""
Submit the transaction by creating individual Transaction models.
Args:
week: Week the transaction is effective for
Returns:
List of created Transaction objects
"""
if not self.moves:
raise ValueError("Cannot submit empty transaction")
validation = await self.validate_transaction(next_week=week)
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
fa_team = Team(
id=503, # Standard FA team ID
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}")