651 lines
24 KiB
Python
651 lines
24 KiB
Python
"""
|
|
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
|
|
)
|
|
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
|
|
|
|
# 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 "")
|
|
) |