""" Enhanced file management service Handles file closure, status workflows, transfers, and archival """ from typing import List, Dict, Any, Optional, Tuple from datetime import datetime, date, timezone from decimal import Decimal from sqlalchemy.orm import Session, joinedload from sqlalchemy import and_, func, or_, desc from app.models import ( File, Ledger, FileStatus, FileType, Rolodex, Employee, BillingStatement, Timer, TimeEntry, User, FileStatusHistory, FileTransferHistory, FileArchiveInfo, FileClosureChecklist, FileAlert, FileRelationship ) from app.utils.logging import app_logger logger = app_logger class FileManagementError(Exception): """Exception raised when file management operations fail""" pass class FileStatusWorkflow: """Define valid file status transitions and business rules""" # Define valid status transitions VALID_TRANSITIONS = { "NEW": ["ACTIVE", "INACTIVE", "FOLLOW_UP"], "ACTIVE": ["INACTIVE", "FOLLOW_UP", "PENDING_CLOSURE", "ARCHIVED"], "INACTIVE": ["ACTIVE", "FOLLOW_UP", "PENDING_CLOSURE", "ARCHIVED"], "FOLLOW_UP": ["ACTIVE", "INACTIVE", "PENDING_CLOSURE", "ARCHIVED"], "PENDING_CLOSURE": ["ACTIVE", "INACTIVE", "CLOSED"], "CLOSED": ["ARCHIVED", "ACTIVE"], # Allow reopening "ARCHIVED": [] # Final state - no transitions } # Statuses that require special validation CLOSURE_STATUSES = {"PENDING_CLOSURE", "CLOSED"} FINAL_STATUSES = {"ARCHIVED"} ACTIVE_STATUSES = {"NEW", "ACTIVE", "FOLLOW_UP", "PENDING_CLOSURE"} @classmethod def can_transition(cls, from_status: str, to_status: str) -> bool: """Check if status transition is valid""" return to_status in cls.VALID_TRANSITIONS.get(from_status, []) @classmethod def get_valid_transitions(cls, from_status: str) -> List[str]: """Get list of valid status transitions from current status""" return cls.VALID_TRANSITIONS.get(from_status, []) @classmethod def requires_closure_validation(cls, status: str) -> bool: """Check if status requires closure validation""" return status in cls.CLOSURE_STATUSES @classmethod def is_active_status(cls, status: str) -> bool: """Check if status indicates an active file""" return status in cls.ACTIVE_STATUSES class FileManagementService: """Service for advanced file management operations""" def __init__(self, db: Session): self.db = db self.workflow = FileStatusWorkflow() def change_file_status( self, file_no: str, new_status: str, user_id: int, notes: Optional[str] = None, validate_transition: bool = True ) -> File: """Change file status with workflow validation""" file_obj = self.db.query(File).filter(File.file_no == file_no).first() if not file_obj: raise FileManagementError(f"File {file_no} not found") current_status = file_obj.status # Validate status transition if validate_transition and not self.workflow.can_transition(current_status, new_status): valid_transitions = self.workflow.get_valid_transitions(current_status) raise FileManagementError( f"Invalid status transition from '{current_status}' to '{new_status}'. " f"Valid transitions: {valid_transitions}" ) # Special validation for closure statuses if self.workflow.requires_closure_validation(new_status): self._validate_file_closure(file_obj) # Update file status old_status = file_obj.status file_obj.status = new_status # Set closure date if closing if new_status == "CLOSED" and not file_obj.closed: file_obj.closed = date.today() elif new_status != "CLOSED" and file_obj.closed: # Clear closure date if reopening file_obj.closed = None # Create status history record self._create_status_history(file_no, old_status, new_status, user_id, notes) self.db.commit() self.db.refresh(file_obj) logger.info(f"Changed file {file_no} status from '{old_status}' to '{new_status}' by user {user_id}") return file_obj def close_file( self, file_no: str, user_id: int, force_close: bool = False, final_payment_amount: Optional[float] = None, closing_notes: Optional[str] = None ) -> Dict[str, Any]: """Close a file with automated closure process""" file_obj = self.db.query(File).filter(File.file_no == file_no).first() if not file_obj: raise FileManagementError(f"File {file_no} not found") if file_obj.status == "CLOSED": raise FileManagementError("File is already closed") closure_summary = { "file_no": file_no, "closure_date": date.today(), "actions_taken": [], "warnings": [], "final_balance": 0.0, "trust_balance": file_obj.trust_bal or 0.0 } try: # Step 1: Validate closure readiness validation_result = self._validate_file_closure(file_obj, force_close) closure_summary["warnings"].extend(validation_result.get("warnings", [])) if validation_result.get("blocking_issues") and not force_close: raise FileManagementError( f"Cannot close file: {'; '.join(validation_result['blocking_issues'])}" ) # Step 2: Handle outstanding balances outstanding_balance = self._calculate_outstanding_balance(file_obj) closure_summary["final_balance"] = outstanding_balance if outstanding_balance > 0 and final_payment_amount: # Create payment entry to close outstanding balance payment_entry = self._create_final_payment_entry( file_obj, final_payment_amount, user_id ) closure_summary["actions_taken"].append( f"Created final payment entry: ${final_payment_amount:.2f}" ) outstanding_balance -= final_payment_amount # Step 3: Stop any active timers active_timers = self._stop_active_timers(file_no, user_id) if active_timers: closure_summary["actions_taken"].append( f"Stopped {len(active_timers)} active timer(s)" ) # Step 4: Mark unbilled time entries as non-billable if any unbilled_entries = self._handle_unbilled_time_entries(file_no, user_id) if unbilled_entries: closure_summary["actions_taken"].append( f"Marked {len(unbilled_entries)} time entries as non-billable" ) # Step 5: Update file status file_obj.status = "CLOSED" file_obj.closed = date.today() # Step 6: Create closure history record self._create_status_history( file_no, file_obj.status, "CLOSED", user_id, closing_notes or "File closed via automated closure process" ) self.db.commit() closure_summary["actions_taken"].append("File status updated to CLOSED") logger.info(f"Successfully closed file {file_no} by user {user_id}") return closure_summary except Exception as e: self.db.rollback() logger.error(f"Failed to close file {file_no}: {str(e)}") raise FileManagementError(f"File closure failed: {str(e)}") def reopen_file( self, file_no: str, user_id: int, new_status: str = "ACTIVE", notes: Optional[str] = None ) -> File: """Reopen a closed file""" file_obj = self.db.query(File).filter(File.file_no == file_no).first() if not file_obj: raise FileManagementError(f"File {file_no} not found") if file_obj.status != "CLOSED": raise FileManagementError("Only closed files can be reopened") if file_obj.status == "ARCHIVED": raise FileManagementError("Archived files cannot be reopened") # Validate new status if not self.workflow.can_transition("CLOSED", new_status): valid_transitions = self.workflow.get_valid_transitions("CLOSED") raise FileManagementError( f"Invalid reopening status '{new_status}'. Valid options: {valid_transitions}" ) # Update file old_status = file_obj.status file_obj.status = new_status file_obj.closed = None # Clear closure date # Create status history self._create_status_history( file_no, old_status, new_status, user_id, notes or "File reopened" ) self.db.commit() self.db.refresh(file_obj) logger.info(f"Reopened file {file_no} to status '{new_status}' by user {user_id}") return file_obj def transfer_file( self, file_no: str, new_attorney_id: str, user_id: int, transfer_reason: Optional[str] = None ) -> File: """Transfer file to a different attorney""" file_obj = self.db.query(File).filter(File.file_no == file_no).first() if not file_obj: raise FileManagementError(f"File {file_no} not found") # Validate new attorney exists new_attorney = self.db.query(Employee).filter(Employee.empl_num == new_attorney_id).first() if not new_attorney: raise FileManagementError(f"Attorney {new_attorney_id} not found") if not new_attorney.active: raise FileManagementError(f"Attorney {new_attorney_id} is not active") old_attorney = file_obj.empl_num if old_attorney == new_attorney_id: raise FileManagementError("File is already assigned to this attorney") # Update file assignment file_obj.empl_num = new_attorney_id # Update hourly rate if attorney has a default rate if new_attorney.rate_per_hour and new_attorney.rate_per_hour > 0: file_obj.rate_per_hour = new_attorney.rate_per_hour # Create transfer history record self._create_transfer_history( file_no, old_attorney, new_attorney_id, user_id, transfer_reason ) self.db.commit() self.db.refresh(file_obj) logger.info(f"Transferred file {file_no} from {old_attorney} to {new_attorney_id} by user {user_id}") return file_obj def archive_file( self, file_no: str, user_id: int, archive_location: Optional[str] = None, notes: Optional[str] = None ) -> File: """Archive a closed file""" file_obj = self.db.query(File).filter(File.file_no == file_no).first() if not file_obj: raise FileManagementError(f"File {file_no} not found") if file_obj.status != "CLOSED": raise FileManagementError("Only closed files can be archived") # Check for any recent activity (within last 30 days) recent_activity = self._check_recent_activity(file_no, days=30) if recent_activity: raise FileManagementError( f"File has recent activity and cannot be archived: {recent_activity}" ) # Update file status old_status = file_obj.status file_obj.status = "ARCHIVED" # Create archive history record archive_notes = notes or f"File archived to location: {archive_location or 'Standard archive'}" self._create_status_history(file_no, old_status, "ARCHIVED", user_id, archive_notes) self.db.commit() self.db.refresh(file_obj) logger.info(f"Archived file {file_no} by user {user_id}") return file_obj def get_file_status_history(self, file_no: str) -> List[Dict[str, Any]]: """Get status change history for a file""" history = self.db.query(FileStatusHistory).filter( FileStatusHistory.file_no == file_no ).options( joinedload(FileStatusHistory.changed_by) ).order_by(FileStatusHistory.change_date.desc()).all() return [ { "id": h.id, "old_status": h.old_status, "new_status": h.new_status, "change_date": h.change_date, "changed_by": h.changed_by_name or (h.changed_by.username if h.changed_by else "System"), "notes": h.notes, "system_generated": h.system_generated } for h in history ] def get_files_by_status( self, status: str, attorney_id: Optional[str] = None, limit: int = 100 ) -> List[File]: """Get files by status with optional attorney filter""" query = self.db.query(File).filter(File.status == status) if attorney_id: query = query.filter(File.empl_num == attorney_id) return query.options( joinedload(File.owner) ).order_by(File.opened.desc()).limit(limit).all() def get_closure_candidates(self, days_inactive: int = 90) -> List[Dict[str, Any]]: """Get files that are candidates for closure""" cutoff_date = datetime.now(timezone.utc).date() - datetime.timedelta(days=days_inactive) # Files with no recent ledger activity files_query = self.db.query(File).filter( File.status.in_(["ACTIVE", "FOLLOW_UP"]), File.opened < cutoff_date ) candidates = [] for file_obj in files_query.all(): # Check for recent ledger activity recent_activity = self.db.query(Ledger).filter( Ledger.file_no == file_obj.file_no, Ledger.date >= cutoff_date ).first() if not recent_activity: outstanding_balance = self._calculate_outstanding_balance(file_obj) candidates.append({ "file_no": file_obj.file_no, "client_name": f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip() if file_obj.owner else "Unknown", "attorney": file_obj.empl_num, "opened_date": file_obj.opened, "last_activity": None, # Could be enhanced to find actual last activity "outstanding_balance": outstanding_balance, "status": file_obj.status }) return sorted(candidates, key=lambda x: x["opened_date"]) def bulk_status_update( self, file_numbers: List[str], new_status: str, user_id: int, notes: Optional[str] = None ) -> Dict[str, Any]: """Update status for multiple files""" results = { "successful": [], "failed": [], "total": len(file_numbers) } for file_no in file_numbers: try: self.change_file_status(file_no, new_status, user_id, notes) results["successful"].append(file_no) except Exception as e: results["failed"].append({ "file_no": file_no, "error": str(e) }) logger.info(f"Bulk status update: {len(results['successful'])} successful, {len(results['failed'])} failed") return results # Checklist management def get_closure_checklist(self, file_no: str) -> List[Dict[str, Any]]: """Return the closure checklist items for a file.""" items = self.db.query(FileClosureChecklist).filter( FileClosureChecklist.file_no == file_no ).order_by(FileClosureChecklist.sort_order.asc(), FileClosureChecklist.id.asc()).all() return [ { "id": i.id, "file_no": i.file_no, "item_name": i.item_name, "item_description": i.item_description, "is_required": bool(i.is_required), "is_completed": bool(i.is_completed), "completed_date": i.completed_date, "completed_by_name": i.completed_by_name, "notes": i.notes, "sort_order": i.sort_order, } for i in items ] def add_checklist_item( self, *, file_no: str, item_name: str, item_description: Optional[str] = None, is_required: bool = True, sort_order: int = 0, ) -> FileClosureChecklist: """Add a checklist item to a file.""" # Ensure file exists if not self.db.query(File).filter(File.file_no == file_no).first(): raise FileManagementError(f"File {file_no} not found") item = FileClosureChecklist( file_no=file_no, item_name=item_name, item_description=item_description, is_required=is_required, sort_order=sort_order, ) self.db.add(item) self.db.commit() self.db.refresh(item) logger.info(f"Added checklist item '{item_name}' to file {file_no}") return item def update_checklist_item( self, *, item_id: int, item_name: Optional[str] = None, item_description: Optional[str] = None, is_required: Optional[bool] = None, is_completed: Optional[bool] = None, sort_order: Optional[int] = None, user_id: Optional[int] = None, notes: Optional[str] = None, ) -> FileClosureChecklist: """Update attributes of a checklist item; optionally mark complete/incomplete.""" item = self.db.query(FileClosureChecklist).filter(FileClosureChecklist.id == item_id).first() if not item: raise FileManagementError("Checklist item not found") if item_name is not None: item.item_name = item_name if item_description is not None: item.item_description = item_description if is_required is not None: item.is_required = bool(is_required) if sort_order is not None: item.sort_order = int(sort_order) if is_completed is not None: item.is_completed = bool(is_completed) if item.is_completed: item.completed_date = datetime.now(timezone.utc) if user_id: user = self.db.query(User).filter(User.id == user_id).first() item.completed_by_user_id = user_id item.completed_by_name = user.username if user else f"user_{user_id}" else: item.completed_date = None item.completed_by_user_id = None item.completed_by_name = None if notes is not None: item.notes = notes self.db.commit() self.db.refresh(item) logger.info(f"Updated checklist item {item_id}") return item def delete_checklist_item(self, *, item_id: int) -> None: item = self.db.query(FileClosureChecklist).filter(FileClosureChecklist.id == item_id).first() if not item: raise FileManagementError("Checklist item not found") self.db.delete(item) self.db.commit() logger.info(f"Deleted checklist item {item_id}") # Alerts management def create_alert( self, *, file_no: str, alert_type: str, title: str, message: str, alert_date: date, notify_attorney: bool = True, notify_admin: bool = False, notification_days_advance: int = 7, ) -> FileAlert: if not self.db.query(File).filter(File.file_no == file_no).first(): raise FileManagementError(f"File {file_no} not found") alert = FileAlert( file_no=file_no, alert_type=alert_type, title=title, message=message, alert_date=alert_date, notify_attorney=notify_attorney, notify_admin=notify_admin, notification_days_advance=notification_days_advance, ) self.db.add(alert) self.db.commit() self.db.refresh(alert) logger.info(f"Created alert {alert.id} for file {file_no} on {alert_date}") return alert def get_alerts( self, *, file_no: str, active_only: bool = True, upcoming_only: bool = False, limit: int = 100, ) -> List[FileAlert]: query = self.db.query(FileAlert).filter(FileAlert.file_no == file_no) if active_only: query = query.filter(FileAlert.is_active == True) if upcoming_only: today = datetime.now(timezone.utc).date() query = query.filter(FileAlert.alert_date >= today) return query.order_by(FileAlert.alert_date.asc(), FileAlert.id.asc()).limit(limit).all() def acknowledge_alert(self, *, alert_id: int, user_id: int) -> FileAlert: alert = self.db.query(FileAlert).filter(FileAlert.id == alert_id).first() if not alert: raise FileManagementError("Alert not found") if not alert.is_active: return alert alert.is_acknowledged = True alert.acknowledged_at = datetime.now(timezone.utc) alert.acknowledged_by_user_id = user_id self.db.commit() self.db.refresh(alert) logger.info(f"Acknowledged alert {alert_id} by user {user_id}") return alert def update_alert( self, *, alert_id: int, title: Optional[str] = None, message: Optional[str] = None, alert_date: Optional[date] = None, is_active: Optional[bool] = None, ) -> FileAlert: alert = self.db.query(FileAlert).filter(FileAlert.id == alert_id).first() if not alert: raise FileManagementError("Alert not found") if title is not None: alert.title = title if message is not None: alert.message = message if alert_date is not None: alert.alert_date = alert_date if is_active is not None: alert.is_active = bool(is_active) self.db.commit() self.db.refresh(alert) logger.info(f"Updated alert {alert_id}") return alert def delete_alert(self, *, alert_id: int) -> None: alert = self.db.query(FileAlert).filter(FileAlert.id == alert_id).first() if not alert: raise FileManagementError("Alert not found") self.db.delete(alert) self.db.commit() logger.info(f"Deleted alert {alert_id}") # Relationship management def create_relationship( self, *, source_file_no: str, target_file_no: str, relationship_type: str, user_id: Optional[int] = None, notes: Optional[str] = None, ) -> FileRelationship: if source_file_no == target_file_no: raise FileManagementError("Source and target file cannot be the same") source = self.db.query(File).filter(File.file_no == source_file_no).first() target = self.db.query(File).filter(File.file_no == target_file_no).first() if not source: raise FileManagementError(f"File {source_file_no} not found") if not target: raise FileManagementError(f"File {target_file_no} not found") user_name: Optional[str] = None if user_id is not None: user = self.db.query(User).filter(User.id == user_id).first() user_name = user.username if user else f"user_{user_id}" # Prevent duplicate exact relationship existing = self.db.query(FileRelationship).filter( FileRelationship.source_file_no == source_file_no, FileRelationship.target_file_no == target_file_no, FileRelationship.relationship_type == relationship_type, ).first() if existing: return existing rel = FileRelationship( source_file_no=source_file_no, target_file_no=target_file_no, relationship_type=relationship_type, notes=notes, created_by_user_id=user_id, created_by_name=user_name, ) self.db.add(rel) self.db.commit() self.db.refresh(rel) logger.info( f"Created relationship {relationship_type}: {source_file_no} -> {target_file_no}" ) return rel def get_relationships(self, *, file_no: str) -> List[Dict[str, Any]]: """Return relationships where the given file is source or target.""" rels = self.db.query(FileRelationship).filter( (FileRelationship.source_file_no == file_no) | (FileRelationship.target_file_no == file_no) ).order_by(FileRelationship.id.desc()).all() results: List[Dict[str, Any]] = [] for r in rels: direction = "outbound" if r.source_file_no == file_no else "inbound" other_file_no = r.target_file_no if direction == "outbound" else r.source_file_no results.append( { "id": r.id, "direction": direction, "relationship_type": r.relationship_type, "notes": r.notes, "source_file_no": r.source_file_no, "target_file_no": r.target_file_no, "other_file_no": other_file_no, "created_by_name": r.created_by_name, "created_at": getattr(r, "created_at", None), } ) return results def delete_relationship(self, *, relationship_id: int) -> None: rel = self.db.query(FileRelationship).filter(FileRelationship.id == relationship_id).first() if not rel: raise FileManagementError("Relationship not found") self.db.delete(rel) self.db.commit() logger.info(f"Deleted relationship {relationship_id}") # Private helper methods def _validate_file_closure(self, file_obj: File, force: bool = False) -> Dict[str, Any]: """Validate if file is ready for closure""" validation_result = { "can_close": True, "blocking_issues": [], "warnings": [] } # Check for active timers active_timers = self.db.query(Timer).filter( Timer.file_no == file_obj.file_no, Timer.status.in_(["running", "paused"]) ).count() if active_timers > 0: validation_result["warnings"].append(f"{active_timers} active timer(s) will be stopped") # Check for unbilled time entries unbilled_entries = self.db.query(TimeEntry).filter( TimeEntry.file_no == file_obj.file_no, TimeEntry.billed == False, TimeEntry.is_billable == True ).count() if unbilled_entries > 0: validation_result["warnings"].append(f"{unbilled_entries} unbilled time entries exist") # Check for outstanding balance outstanding_balance = self._calculate_outstanding_balance(file_obj) if outstanding_balance > 0: validation_result["warnings"].append(f"Outstanding balance: ${outstanding_balance:.2f}") # Check for pending billing statements pending_statements = self.db.query(BillingStatement).filter( BillingStatement.file_no == file_obj.file_no, BillingStatement.status.in_(["draft", "pending_approval"]) ).count() if pending_statements > 0: validation_result["blocking_issues"].append(f"{pending_statements} pending billing statement(s)") validation_result["can_close"] = False return validation_result def _calculate_outstanding_balance(self, file_obj: File) -> float: """Calculate outstanding balance for a file""" # Sum all charges minus payments charges = self.db.query(func.sum(Ledger.amount)).filter( Ledger.file_no == file_obj.file_no, Ledger.t_type.in_(["1", "2", "3", "4"]) # Fee and cost types ).scalar() or 0.0 payments = self.db.query(func.sum(Ledger.amount)).filter( Ledger.file_no == file_obj.file_no, Ledger.t_type == "5" # Payment type ).scalar() or 0.0 return max(0.0, charges - payments) def _stop_active_timers(self, file_no: str, user_id: int) -> List[Timer]: """Stop all active timers for a file""" from app.services.timers import TimerService active_timers = self.db.query(Timer).filter( Timer.file_no == file_no, Timer.status.in_(["running", "paused"]) ).all() timer_service = TimerService(self.db) stopped_timers = [] for timer in active_timers: try: timer_service.stop_timer(timer.id, timer.user_id) stopped_timers.append(timer) except Exception as e: logger.warning(f"Failed to stop timer {timer.id}: {str(e)}") return stopped_timers def _handle_unbilled_time_entries(self, file_no: str, user_id: int) -> List[TimeEntry]: """Handle unbilled time entries during file closure""" unbilled_entries = self.db.query(TimeEntry).filter( TimeEntry.file_no == file_no, TimeEntry.billed == False, TimeEntry.is_billable == True ).all() # Mark as non-billable to prevent future billing for entry in unbilled_entries: entry.is_billable = False entry.notes = (entry.notes or "") + " [Marked non-billable during file closure]" return unbilled_entries def _create_final_payment_entry( self, file_obj: File, amount: float, user_id: int ) -> Ledger: """Create a final payment entry to close outstanding balance""" # Get next item number max_item = self.db.query(func.max(Ledger.item_no)).filter( Ledger.file_no == file_obj.file_no ).scalar() or 0 payment_entry = Ledger( file_no=file_obj.file_no, item_no=max_item + 1, date=date.today(), t_code="FINAL", t_type="5", # Payment type empl_num=f"user_{user_id}", amount=amount, billed="Y", # Mark as billed note="Final payment - file closure" ) self.db.add(payment_entry) return payment_entry def _check_recent_activity(self, file_no: str, days: int = 30) -> Optional[str]: """Check for recent activity that would prevent archival""" cutoff_date = datetime.now(timezone.utc).date() - datetime.timedelta(days=days) # Check for recent ledger entries recent_ledger = self.db.query(Ledger).filter( Ledger.file_no == file_no, Ledger.date >= cutoff_date ).first() if recent_ledger: return f"Recent ledger activity on {recent_ledger.date}" # Check for recent billing statements recent_statement = self.db.query(BillingStatement).filter( BillingStatement.file_no == file_no, BillingStatement.created_at >= cutoff_date ).first() if recent_statement: return f"Recent billing statement on {recent_statement.created_at.date()}" return None def _create_status_history( self, file_no: str, old_status: str, new_status: str, user_id: int, notes: Optional[str] = None ): """Create a status history record""" # Get user info for caching user = self.db.query(User).filter(User.id == user_id).first() user_name = user.username if user else f"user_{user_id}" history_record = FileStatusHistory( file_no=file_no, old_status=old_status, new_status=new_status, changed_by_user_id=user_id, changed_by_name=user_name, notes=notes, system_generated=False ) self.db.add(history_record) logger.info( f"File {file_no} status changed: {old_status} -> {new_status} by {user_name}" + (f" - {notes}" if notes else "") ) def _create_transfer_history( self, file_no: str, old_attorney: str, new_attorney: str, user_id: int, reason: Optional[str] = None ): """Create a transfer history record""" # Get user info for caching user = self.db.query(User).filter(User.id == user_id).first() user_name = user.username if user else f"user_{user_id}" # Get current file for rate info file_obj = self.db.query(File).filter(File.file_no == file_no).first() old_rate = file_obj.rate_per_hour if file_obj else None # Get new attorney rate new_attorney_obj = self.db.query(Employee).filter(Employee.empl_num == new_attorney).first() new_rate = new_attorney_obj.rate_per_hour if new_attorney_obj else None transfer_record = FileTransferHistory( file_no=file_no, old_attorney_id=old_attorney, new_attorney_id=new_attorney, authorized_by_user_id=user_id, authorized_by_name=user_name, reason=reason, old_hourly_rate=old_rate, new_hourly_rate=new_rate ) self.db.add(transfer_record) logger.info( f"File {file_no} transferred: {old_attorney} -> {new_attorney} by {user_name}" + (f" - {reason}" if reason else "") )