This commit is contained in:
HotSwapp
2025-08-16 10:05:42 -05:00
parent 0347284556
commit ae4484381f
15 changed files with 3966 additions and 77 deletions

View File

@@ -17,7 +17,17 @@ from .pensions import (
SeparationAgreement, LifeTable, NumberTable, PensionResult
)
from .templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
from .billing import BillingBatch, BillingBatchFile
from .billing import (
BillingBatch, BillingBatchFile, StatementTemplate, BillingStatement,
BillingStatementItem, StatementPayment, StatementStatus
)
from .timers import (
Timer, TimeEntry, TimerSession, TimerTemplate, TimerStatus, TimerType
)
from .file_management import (
FileStatusHistory, FileTransferHistory, FileArchiveInfo,
FileClosureChecklist, FileAlert
)
from .lookups import (
Employee, FileType, FileStatus, TransactionType, TransactionCode,
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
@@ -34,5 +44,9 @@ __all__ = [
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
"PrinterSetup", "SystemSetup", "FormKeyword", "TemplateKeyword",
"BillingBatch", "BillingBatchFile"
"BillingBatch", "BillingBatchFile", "StatementTemplate", "BillingStatement",
"BillingStatementItem", "StatementPayment", "StatementStatus",
"Timer", "TimeEntry", "TimerSession", "TimerTemplate", "TimerStatus", "TimerType",
"FileStatusHistory", "FileTransferHistory", "FileArchiveInfo",
"FileClosureChecklist", "FileAlert"
]

View File

@@ -1,5 +1,8 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Index
from sqlalchemy import Column, Integer, String, DateTime, Date, Float, Boolean, Text, Index, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
import enum
class BillingBatch(BaseModel):
@@ -45,3 +48,174 @@ class BillingBatchFile(BaseModel):
)
class StatementStatus(str, enum.Enum):
"""Statement status enumeration"""
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
SENT = "sent"
PAID = "paid"
CANCELLED = "cancelled"
class StatementTemplate(BaseModel):
"""
Templates for billing statement generation
Allows customization of statement format, footer text, etc.
"""
__tablename__ = "statement_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text)
# Template content
header_template = Column(Text) # HTML/Jinja2 template for header
footer_template = Column(Text) # HTML/Jinja2 template for footer
css_styles = Column(Text) # Custom CSS for statement styling
# Template settings
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
# Metadata
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
created_by = Column(String(50))
# Relationships
statements = relationship("BillingStatement", back_populates="template")
def __repr__(self):
return f"<StatementTemplate(name='{self.name}', default={self.is_default})>"
class BillingStatement(BaseModel):
"""
Generated billing statements for files/clients
Tracks statement metadata, status, and generation details
"""
__tablename__ = "billing_statements"
id = Column(Integer, primary_key=True, autoincrement=True)
statement_number = Column(String(50), unique=True, nullable=False) # Unique statement identifier
# File/Client reference
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
customer_id = Column(String(45), ForeignKey("rolodex.id")) # Optional direct customer reference
# Statement period
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
statement_date = Column(Date, nullable=False, default=func.current_date())
due_date = Column(Date)
# Financial totals
previous_balance = Column(Float, default=0.0)
current_charges = Column(Float, default=0.0)
payments_credits = Column(Float, default=0.0)
total_due = Column(Float, nullable=False)
# Trust account information
trust_balance = Column(Float, default=0.0)
trust_applied = Column(Float, default=0.0)
# Statement details
status = Column(Enum(StatementStatus), default=StatementStatus.DRAFT)
template_id = Column(Integer, ForeignKey("statement_templates.id"))
# Generated content
html_content = Column(Text) # Generated HTML content
pdf_path = Column(String(500)) # Path to generated PDF file
# Billing metadata
billed_transaction_count = Column(Integer, default=0)
approved_by = Column(String(50)) # User who approved the statement
approved_at = Column(DateTime)
sent_by = Column(String(50)) # User who sent the statement
sent_at = Column(DateTime)
# Metadata
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
created_by = Column(String(50))
# Notes and customization
custom_footer = Column(Text) # Override template footer for this statement
internal_notes = Column(Text) # Internal notes not shown to client
# Relationships
file = relationship("File", back_populates="billing_statements")
customer = relationship("Rolodex", back_populates="billing_statements")
template = relationship("StatementTemplate", back_populates="statements")
statement_items = relationship("BillingStatementItem", back_populates="statement", cascade="all, delete-orphan")
def __repr__(self):
return f"<BillingStatement(number='{self.statement_number}', file_no='{self.file_no}', total={self.total_due})>"
class BillingStatementItem(BaseModel):
"""
Individual line items on a billing statement
Links to ledger entries that were included in the statement
"""
__tablename__ = "billing_statement_items"
id = Column(Integer, primary_key=True, autoincrement=True)
statement_id = Column(Integer, ForeignKey("billing_statements.id"), nullable=False)
ledger_id = Column(Integer, ForeignKey("ledger.id"), nullable=False)
# Item display details (cached from ledger for statement consistency)
date = Column(Date, nullable=False)
description = Column(Text)
quantity = Column(Float, default=0.0)
rate = Column(Float, default=0.0)
amount = Column(Float, nullable=False)
# Item categorization
item_category = Column(String(50)) # fees, costs, payments, adjustments
# Metadata
created_at = Column(DateTime, default=func.now())
# Relationships
statement = relationship("BillingStatement", back_populates="statement_items")
ledger_entry = relationship("Ledger")
def __repr__(self):
return f"<BillingStatementItem(statement_id={self.statement_id}, amount={self.amount})>"
class StatementPayment(BaseModel):
"""
Payments applied to billing statements
Tracks payment history and application
"""
__tablename__ = "statement_payments"
id = Column(Integer, primary_key=True, autoincrement=True)
statement_id = Column(Integer, ForeignKey("billing_statements.id"), nullable=False)
# Payment details
payment_date = Column(Date, nullable=False)
payment_amount = Column(Float, nullable=False)
payment_method = Column(String(50)) # check, credit_card, trust_transfer, etc.
reference_number = Column(String(100)) # check number, transaction ID, etc.
# Application details
applied_to_fees = Column(Float, default=0.0)
applied_to_costs = Column(Float, default=0.0)
applied_to_trust = Column(Float, default=0.0)
# Metadata
created_at = Column(DateTime, default=func.now())
created_by = Column(String(50))
notes = Column(Text)
# Relationships
statement = relationship("BillingStatement")
def __repr__(self):
return f"<StatementPayment(statement_id={self.statement_id}, amount={self.payment_amount})>"

View File

@@ -0,0 +1,191 @@
"""
File management models for enhanced file operations
"""
from sqlalchemy import Column, Integer, String, DateTime, Date, Float, Text, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
class FileStatusHistory(BaseModel):
"""
Track file status changes over time
Provides audit trail for file status transitions
"""
__tablename__ = "file_status_history"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Status change details
old_status = Column(String(45), nullable=False)
new_status = Column(String(45), nullable=False)
change_date = Column(DateTime(timezone=True), default=func.now(), nullable=False)
# Who made the change
changed_by_user_id = Column(Integer, ForeignKey("users.id"))
changed_by_name = Column(String(100)) # Cached name for reporting
# Additional context
notes = Column(Text) # Reason for status change
system_generated = Column(Boolean, default=False) # True if automated change
# Relationships
file = relationship("File")
changed_by = relationship("User")
def __repr__(self):
return f"<FileStatusHistory(file_no='{self.file_no}', {self.old_status} -> {self.new_status})>"
class FileTransferHistory(BaseModel):
"""
Track file transfers between attorneys
Maintains chain of custody for files
"""
__tablename__ = "file_transfer_history"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Transfer details
old_attorney_id = Column(String(10), ForeignKey("employees.empl_num"), nullable=False)
new_attorney_id = Column(String(10), ForeignKey("employees.empl_num"), nullable=False)
transfer_date = Column(DateTime(timezone=True), default=func.now(), nullable=False)
# Who authorized the transfer
authorized_by_user_id = Column(Integer, ForeignKey("users.id"))
authorized_by_name = Column(String(100))
# Transfer context
reason = Column(Text) # Reason for transfer
effective_date = Column(Date) # When transfer becomes effective (may be future)
# Rate changes
old_hourly_rate = Column(Float, nullable=True)
new_hourly_rate = Column(Float, nullable=True)
# Relationships
file = relationship("File")
old_attorney = relationship("Employee", foreign_keys=[old_attorney_id])
new_attorney = relationship("Employee", foreign_keys=[new_attorney_id])
authorized_by = relationship("User")
def __repr__(self):
return f"<FileTransferHistory(file_no='{self.file_no}', {self.old_attorney_id} -> {self.new_attorney_id})>"
class FileArchiveInfo(BaseModel):
"""
Archive information for files
Tracks where files are stored and retrieval information
"""
__tablename__ = "file_archive_info"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, unique=True, index=True)
# Archive details
archive_date = Column(Date, nullable=False, default=func.current_date())
archive_location = Column(String(200)) # Physical or digital location
box_number = Column(String(50)) # Physical box identifier
shelf_location = Column(String(100)) # Physical shelf/room location
# Digital archive info
digital_path = Column(String(500)) # Path to digital archive
backup_location = Column(String(500)) # Backup storage location
# Archive metadata
archived_by_user_id = Column(Integer, ForeignKey("users.id"))
archived_by_name = Column(String(100))
retrieval_instructions = Column(Text) # How to retrieve the file
# Retention information
retention_date = Column(Date) # When file can be destroyed
destruction_date = Column(Date) # When file was actually destroyed
destruction_authorized_by = Column(String(100))
# Archive status
is_retrievable = Column(Boolean, default=True)
last_verified = Column(Date) # Last time archive was verified to exist
# Relationships
file = relationship("File")
archived_by = relationship("User")
def __repr__(self):
return f"<FileArchiveInfo(file_no='{self.file_no}', location='{self.archive_location}')>"
class FileClosureChecklist(BaseModel):
"""
Checklist items for file closure process
Ensures all necessary steps are completed before closing
"""
__tablename__ = "file_closure_checklist"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Checklist item details
item_name = Column(String(200), nullable=False)
item_description = Column(Text)
is_required = Column(Boolean, default=True) # Must be completed to close file
# Completion tracking
is_completed = Column(Boolean, default=False)
completed_date = Column(DateTime(timezone=True))
completed_by_user_id = Column(Integer, ForeignKey("users.id"))
completed_by_name = Column(String(100))
# Additional info
notes = Column(Text) # Notes about completion
sort_order = Column(Integer, default=0) # Display order
# Relationships
file = relationship("File")
completed_by = relationship("User")
def __repr__(self):
status = "" if self.is_completed else ""
return f"<FileClosureChecklist({status} {self.item_name} - {self.file_no})>"
class FileAlert(BaseModel):
"""
Alerts and reminders for files
Automated notifications for important dates and events
"""
__tablename__ = "file_alerts"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
# Alert details
alert_type = Column(String(50), nullable=False) # deadline, follow_up, billing, etc.
title = Column(String(200), nullable=False)
message = Column(Text, nullable=False)
# Timing
alert_date = Column(Date, nullable=False) # When alert should trigger
created_date = Column(Date, default=func.current_date())
# Status
is_active = Column(Boolean, default=True)
is_acknowledged = Column(Boolean, default=False)
acknowledged_by_user_id = Column(Integer, ForeignKey("users.id"))
acknowledged_at = Column(DateTime(timezone=True))
# Notification settings
notify_attorney = Column(Boolean, default=True)
notify_admin = Column(Boolean, default=False)
notification_days_advance = Column(Integer, default=7) # Days before alert_date
# Relationships
file = relationship("File")
acknowledged_by = relationship("User")
def __repr__(self):
status = "🔔" if self.is_active and not self.is_acknowledged else ""
return f"<FileAlert({status} {self.alert_type} - {self.file_no} on {self.alert_date})>"

View File

@@ -65,4 +65,7 @@ class File(BaseModel):
separation_agreements = relationship("SeparationAgreement", back_populates="file", cascade="all, delete-orphan")
payments = relationship("Payment", back_populates="file", cascade="all, delete-orphan")
notes = relationship("FileNote", back_populates="file", cascade="all, delete-orphan")
documents = relationship("Document", back_populates="file", cascade="all, delete-orphan")
documents = relationship("Document", back_populates="file", cascade="all, delete-orphan")
billing_statements = relationship("BillingStatement", back_populates="file", cascade="all, delete-orphan")
timers = relationship("Timer", back_populates="file", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="file", cascade="all, delete-orphan")

View File

@@ -45,6 +45,9 @@ class Rolodex(BaseModel):
phone_numbers = relationship("Phone", back_populates="rolodex_entry", cascade="all, delete-orphan")
files = relationship("File", back_populates="owner")
payments = relationship("Payment", back_populates="client")
billing_statements = relationship("BillingStatement", back_populates="customer")
timers = relationship("Timer", back_populates="customer")
time_entries = relationship("TimeEntry", back_populates="customer")
class Phone(BaseModel):

227
app/models/timers.py Normal file
View File

@@ -0,0 +1,227 @@
"""
Timer and time tracking models for integrated time tracking system
"""
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
import enum
from datetime import datetime, timezone
class TimerStatus(str, enum.Enum):
"""Timer status enumeration"""
STOPPED = "stopped"
RUNNING = "running"
PAUSED = "paused"
class TimerType(str, enum.Enum):
"""Timer type enumeration"""
BILLABLE = "billable"
NON_BILLABLE = "non_billable"
ADMINISTRATIVE = "administrative"
class Timer(BaseModel):
"""
Active timer sessions for time tracking
Represents a running, paused, or stopped timer
"""
__tablename__ = "timers"
id = Column(Integer, primary_key=True, autoincrement=True)
# User and assignment
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
file_no = Column(String(45), ForeignKey("files.file_no")) # Optional file assignment
customer_id = Column(String(45), ForeignKey("rolodex.id")) # Optional customer assignment
# Timer details
title = Column(String(200), nullable=False) # Brief description of task
description = Column(Text) # Detailed description of work
timer_type = Column(Enum(TimerType), default=TimerType.BILLABLE)
# Time tracking
status = Column(Enum(TimerStatus), default=TimerStatus.STOPPED)
total_seconds = Column(Integer, default=0) # Total accumulated time in seconds
# Session timing
started_at = Column(DateTime(timezone=True)) # When timer was first started
last_started_at = Column(DateTime(timezone=True)) # When current session started
last_paused_at = Column(DateTime(timezone=True)) # When timer was last paused
stopped_at = Column(DateTime(timezone=True)) # When timer was stopped
# Billing information
hourly_rate = Column(Float) # Override rate for this timer
is_billable = Column(Boolean, default=True)
# Metadata
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
# Task categorization
task_category = Column(String(50)) # research, drafting, client_call, court_appearance, etc.
notes = Column(Text) # Additional notes
# Relationships
user = relationship("User", back_populates="timers")
file = relationship("File", back_populates="timers")
customer = relationship("Rolodex", back_populates="timers")
time_entries = relationship("TimeEntry", back_populates="timer", cascade="all, delete-orphan")
def __repr__(self):
return f"<Timer(id={self.id}, title='{self.title}', status='{self.status}', total_seconds={self.total_seconds})>"
@property
def total_hours(self) -> float:
"""Get total time in hours"""
return self.total_seconds / 3600.0 if self.total_seconds else 0.0
@property
def is_active(self) -> bool:
"""Check if timer is currently running or paused"""
return self.status in [TimerStatus.RUNNING, TimerStatus.PAUSED]
def get_current_session_seconds(self) -> int:
"""Get seconds for current running session"""
if self.status == TimerStatus.RUNNING and self.last_started_at:
now = datetime.now(timezone.utc)
session_duration = (now - self.last_started_at).total_seconds()
return int(session_duration)
return 0
def get_total_current_seconds(self) -> int:
"""Get total seconds including current running session"""
return self.total_seconds + self.get_current_session_seconds()
class TimeEntry(BaseModel):
"""
Completed time entries that can be converted to billing transactions
Represents finalized time that has been logged
"""
__tablename__ = "time_entries"
id = Column(Integer, primary_key=True, autoincrement=True)
# Source timer (optional - entries can be created manually)
timer_id = Column(Integer, ForeignKey("timers.id"))
# User and assignment
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
file_no = Column(String(45), ForeignKey("files.file_no")) # Optional file assignment
customer_id = Column(String(45), ForeignKey("rolodex.id")) # Optional customer assignment
# Time entry details
title = Column(String(200), nullable=False)
description = Column(Text)
entry_type = Column(Enum(TimerType), default=TimerType.BILLABLE)
# Time information
hours = Column(Float, nullable=False) # Time in decimal hours (e.g., 1.5 = 1 hour 30 minutes)
entry_date = Column(DateTime(timezone=True), nullable=False) # Date/time when work was performed
# Billing information
hourly_rate = Column(Float) # Rate for this entry
is_billable = Column(Boolean, default=True)
billed = Column(Boolean, default=False) # Whether this has been converted to a ledger entry
# Related ledger entry (after billing)
ledger_id = Column(Integer, ForeignKey("ledger.id")) # Link to created billing transaction
# Metadata
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
created_by = Column(String(50)) # Who created this entry
# Task categorization
task_category = Column(String(50)) # research, drafting, client_call, court_appearance, etc.
notes = Column(Text) # Additional notes
# Approval workflow
approved = Column(Boolean, default=False)
approved_by = Column(String(50))
approved_at = Column(DateTime(timezone=True))
# Relationships
timer = relationship("Timer", back_populates="time_entries")
user = relationship("User", back_populates="time_entries")
file = relationship("File", back_populates="time_entries")
customer = relationship("Rolodex", back_populates="time_entries")
ledger_entry = relationship("Ledger")
def __repr__(self):
return f"<TimeEntry(id={self.id}, title='{self.title}', hours={self.hours}, billed={self.billed})>"
@property
def calculated_amount(self) -> float:
"""Calculate billable amount based on hours and rate"""
if not self.is_billable or not self.hourly_rate:
return 0.0
return self.hours * self.hourly_rate
class TimerSession(BaseModel):
"""
Individual timer sessions for detailed tracking
Each start/stop cycle creates a session record
"""
__tablename__ = "timer_sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
timer_id = Column(Integer, ForeignKey("timers.id"), nullable=False)
# Session timing
started_at = Column(DateTime(timezone=True), nullable=False)
ended_at = Column(DateTime(timezone=True))
duration_seconds = Column(Integer, default=0)
# Session notes
notes = Column(Text) # Notes for this specific session
# Pause tracking
pause_count = Column(Integer, default=0) # Number of times paused during session
total_pause_seconds = Column(Integer, default=0) # Total time spent paused
# Relationships
timer = relationship("Timer")
def __repr__(self):
return f"<TimerSession(id={self.id}, timer_id={self.timer_id}, duration_seconds={self.duration_seconds})>"
@property
def duration_hours(self) -> float:
"""Get session duration in hours"""
return self.duration_seconds / 3600.0 if self.duration_seconds else 0.0
class TimerTemplate(BaseModel):
"""
Predefined timer templates for common tasks
Allows quick creation of timers with pre-filled information
"""
__tablename__ = "timer_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
# Template details
name = Column(String(100), nullable=False)
title_template = Column(String(200), nullable=False) # Template for timer title
description_template = Column(Text) # Template for timer description
# Default settings
timer_type = Column(Enum(TimerType), default=TimerType.BILLABLE)
task_category = Column(String(50))
default_rate = Column(Float)
is_billable = Column(Boolean, default=True)
# Template metadata
created_by = Column(String(50))
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
is_active = Column(Boolean, default=True)
usage_count = Column(Integer, default=0) # Track how often template is used
def __repr__(self):
return f"<TimerTemplate(id={self.id}, name='{self.name}', usage_count={self.usage_count})>"

View File

@@ -37,6 +37,8 @@ class User(BaseModel):
# Relationships
audit_logs = relationship("AuditLog", back_populates="user")
submitted_tickets = relationship("SupportTicket", foreign_keys="SupportTicket.user_id", back_populates="submitter")
timers = relationship("Timer", back_populates="user", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}')>"