Files
delphi-database/app/services/file_management.py
HotSwapp ae4484381f progress
2025-08-16 10:05:42 -05:00

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 "")
)