progress
This commit is contained in:
@@ -14,7 +14,7 @@ from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
|
||||
from fastapi import Path as PathParam
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
@@ -29,7 +29,11 @@ from app.auth.security import get_current_user, verify_token
|
||||
from app.utils.responses import BulkOperationResponse, ErrorDetail
|
||||
from app.utils.logging import StructuredLogger
|
||||
from app.services.cache import cache_get_json, cache_set_json
|
||||
from app.models.billing import BillingBatch, BillingBatchFile
|
||||
from app.models.billing import (
|
||||
BillingBatch, BillingBatchFile, BillingStatement, StatementTemplate,
|
||||
BillingStatementItem, StatementStatus
|
||||
)
|
||||
from app.services.billing import BillingStatementService, StatementGenerationError
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -1605,3 +1609,417 @@ async def download_latest_statement(
|
||||
media_type="text/html",
|
||||
filename=latest_path.name,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# NEW BILLING STATEMENT MANAGEMENT ENDPOINTS
|
||||
# =====================================================================
|
||||
|
||||
from pydantic import BaseModel as PydanticBaseModel, Field as PydanticField
|
||||
from typing import Union
|
||||
|
||||
class StatementTemplateResponse(PydanticBaseModel):
|
||||
"""Response model for statement templates"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class StatementTemplateCreate(PydanticBaseModel):
|
||||
"""Create statement template request"""
|
||||
name: str = PydanticField(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
header_template: Optional[str] = None
|
||||
footer_template: Optional[str] = None
|
||||
css_styles: Optional[str] = None
|
||||
is_default: bool = False
|
||||
|
||||
class StatementTemplateUpdate(PydanticBaseModel):
|
||||
"""Update statement template request"""
|
||||
name: Optional[str] = PydanticField(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
header_template: Optional[str] = None
|
||||
footer_template: Optional[str] = None
|
||||
css_styles: Optional[str] = None
|
||||
is_default: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class BillingStatementResponse(PydanticBaseModel):
|
||||
"""Response model for billing statements"""
|
||||
id: int
|
||||
statement_number: str
|
||||
file_no: str
|
||||
customer_id: Optional[str] = None
|
||||
period_start: date
|
||||
period_end: date
|
||||
statement_date: date
|
||||
due_date: Optional[date] = None
|
||||
previous_balance: float
|
||||
current_charges: float
|
||||
payments_credits: float
|
||||
total_due: float
|
||||
trust_balance: float
|
||||
trust_applied: float
|
||||
status: StatementStatus
|
||||
billed_transaction_count: int
|
||||
approved_by: Optional[str] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
sent_by: Optional[str] = None
|
||||
sent_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
custom_footer: Optional[str] = None
|
||||
internal_notes: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class BillingStatementCreate(PydanticBaseModel):
|
||||
"""Create billing statement request"""
|
||||
file_no: str
|
||||
period_start: date
|
||||
period_end: date
|
||||
template_id: Optional[int] = None
|
||||
custom_footer: Optional[str] = None
|
||||
|
||||
class PaginatedStatementsResponse(PydanticBaseModel):
|
||||
"""Paginated statements response"""
|
||||
items: List[BillingStatementResponse]
|
||||
total: int
|
||||
|
||||
class PaginatedTemplatesResponse(PydanticBaseModel):
|
||||
"""Paginated templates response"""
|
||||
items: List[StatementTemplateResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# Statement Templates endpoints
|
||||
@router.get("/statement-templates", response_model=Union[List[StatementTemplateResponse], PaginatedTemplatesResponse])
|
||||
async def list_statement_templates(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
active_only: bool = Query(False, description="Filter to active templates only"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List statement templates"""
|
||||
query = db.query(StatementTemplate)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(StatementTemplate.is_active == True)
|
||||
|
||||
query = query.order_by(StatementTemplate.is_default.desc(), StatementTemplate.name)
|
||||
|
||||
if include_total:
|
||||
total = query.count()
|
||||
templates = query.offset(skip).limit(limit).all()
|
||||
return {"items": templates, "total": total}
|
||||
|
||||
templates = query.offset(skip).limit(limit).all()
|
||||
return templates
|
||||
|
||||
@router.post("/statement-templates", response_model=StatementTemplateResponse)
|
||||
async def create_statement_template(
|
||||
template_data: StatementTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new statement template"""
|
||||
# Check if template name already exists
|
||||
existing = db.query(StatementTemplate).filter(StatementTemplate.name == template_data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Template name already exists"
|
||||
)
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if template_data.is_default:
|
||||
db.query(StatementTemplate).filter(StatementTemplate.is_default == True).update({
|
||||
StatementTemplate.is_default: False
|
||||
})
|
||||
|
||||
template = StatementTemplate(
|
||||
**template_data.model_dump(),
|
||||
created_by=current_user.username,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return template
|
||||
|
||||
@router.get("/statement-templates/{template_id}", response_model=StatementTemplateResponse)
|
||||
async def get_statement_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific statement template"""
|
||||
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found"
|
||||
)
|
||||
return template
|
||||
|
||||
@router.put("/statement-templates/{template_id}", response_model=StatementTemplateResponse)
|
||||
async def update_statement_template(
|
||||
template_id: int,
|
||||
template_data: StatementTemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a statement template"""
|
||||
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found"
|
||||
)
|
||||
|
||||
# Check if new name conflicts with existing template
|
||||
if template_data.name and template_data.name != template.name:
|
||||
existing = db.query(StatementTemplate).filter(
|
||||
StatementTemplate.name == template_data.name,
|
||||
StatementTemplate.id != template_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Template name already exists"
|
||||
)
|
||||
|
||||
# If setting as default, unset other defaults
|
||||
if template_data.is_default:
|
||||
db.query(StatementTemplate).filter(
|
||||
StatementTemplate.is_default == True,
|
||||
StatementTemplate.id != template_id
|
||||
).update({StatementTemplate.is_default: False})
|
||||
|
||||
# Update fields
|
||||
for field, value in template_data.model_dump(exclude_unset=True).items():
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return template
|
||||
|
||||
@router.delete("/statement-templates/{template_id}")
|
||||
async def delete_statement_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a statement template"""
|
||||
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found"
|
||||
)
|
||||
|
||||
# Check if template is being used by statements
|
||||
statement_count = db.query(BillingStatement).filter(BillingStatement.template_id == template_id).count()
|
||||
if statement_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete template: {statement_count} statements are using this template"
|
||||
)
|
||||
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Template deleted successfully"}
|
||||
|
||||
|
||||
# Billing Statements endpoints
|
||||
@router.get("/billing-statements", response_model=Union[List[BillingStatementResponse], PaginatedStatementsResponse])
|
||||
async def list_billing_statements(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
file_no: Optional[str] = Query(None, description="Filter by file number"),
|
||||
status: Optional[StatementStatus] = Query(None, description="Filter by statement status"),
|
||||
start_date: Optional[date] = Query(None, description="Filter statements from this date"),
|
||||
end_date: Optional[date] = Query(None, description="Filter statements to this date"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List billing statements with filtering"""
|
||||
query = db.query(BillingStatement).options(
|
||||
joinedload(BillingStatement.file),
|
||||
joinedload(BillingStatement.customer),
|
||||
joinedload(BillingStatement.template)
|
||||
)
|
||||
|
||||
if file_no:
|
||||
query = query.filter(BillingStatement.file_no == file_no)
|
||||
|
||||
if status:
|
||||
query = query.filter(BillingStatement.status == status)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(BillingStatement.statement_date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(BillingStatement.statement_date <= end_date)
|
||||
|
||||
query = query.order_by(BillingStatement.statement_date.desc())
|
||||
|
||||
if include_total:
|
||||
total = query.count()
|
||||
statements = query.offset(skip).limit(limit).all()
|
||||
return {"items": statements, "total": total}
|
||||
|
||||
statements = query.offset(skip).limit(limit).all()
|
||||
return statements
|
||||
|
||||
@router.post("/billing-statements", response_model=BillingStatementResponse)
|
||||
async def create_billing_statement(
|
||||
statement_data: BillingStatementCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new billing statement"""
|
||||
try:
|
||||
service = BillingStatementService(db)
|
||||
statement = service.create_statement(
|
||||
file_no=statement_data.file_no,
|
||||
period_start=statement_data.period_start,
|
||||
period_end=statement_data.period_end,
|
||||
template_id=statement_data.template_id,
|
||||
custom_footer=statement_data.custom_footer,
|
||||
created_by=current_user.username
|
||||
)
|
||||
return statement
|
||||
except StatementGenerationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.get("/billing-statements/{statement_id}", response_model=BillingStatementResponse)
|
||||
async def get_billing_statement(
|
||||
statement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific billing statement"""
|
||||
statement = db.query(BillingStatement).options(
|
||||
joinedload(BillingStatement.file),
|
||||
joinedload(BillingStatement.customer),
|
||||
joinedload(BillingStatement.template),
|
||||
joinedload(BillingStatement.statement_items)
|
||||
).filter(BillingStatement.id == statement_id).first()
|
||||
|
||||
if not statement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Statement not found"
|
||||
)
|
||||
|
||||
return statement
|
||||
|
||||
@router.post("/billing-statements/{statement_id}/generate-html")
|
||||
async def generate_statement_html(
|
||||
statement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Generate HTML content for a statement"""
|
||||
try:
|
||||
service = BillingStatementService(db)
|
||||
html_content = service.generate_statement_html(statement_id)
|
||||
return {"html_content": html_content}
|
||||
except StatementGenerationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.post("/billing-statements/{statement_id}/approve", response_model=BillingStatementResponse)
|
||||
async def approve_billing_statement(
|
||||
statement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Approve a statement and mark transactions as billed"""
|
||||
try:
|
||||
service = BillingStatementService(db)
|
||||
statement = service.approve_statement(statement_id, current_user.username)
|
||||
return statement
|
||||
except StatementGenerationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.post("/billing-statements/{statement_id}/send", response_model=BillingStatementResponse)
|
||||
async def mark_statement_sent(
|
||||
statement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Mark statement as sent to client"""
|
||||
try:
|
||||
service = BillingStatementService(db)
|
||||
statement = service.mark_statement_sent(statement_id, current_user.username)
|
||||
return statement
|
||||
except StatementGenerationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.get("/billing-statements/{statement_id}/preview")
|
||||
async def preview_billing_statement(
|
||||
statement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get HTML preview of billing statement"""
|
||||
try:
|
||||
service = BillingStatementService(db)
|
||||
html_content = service.generate_statement_html(statement_id)
|
||||
return HTMLResponse(content=html_content)
|
||||
except StatementGenerationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.delete("/billing-statements/{statement_id}")
|
||||
async def delete_billing_statement(
|
||||
statement_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a billing statement (only if in draft status)"""
|
||||
statement = db.query(BillingStatement).filter(BillingStatement.id == statement_id).first()
|
||||
if not statement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Statement not found"
|
||||
)
|
||||
|
||||
if statement.status != StatementStatus.DRAFT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only draft statements can be deleted"
|
||||
)
|
||||
|
||||
db.delete(statement)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Statement deleted successfully"}
|
||||
|
||||
515
app/api/file_management.py
Normal file
515
app/api/file_management.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Enhanced file management API endpoints
|
||||
"""
|
||||
from typing import List, Optional, Union, Dict, Any
|
||||
from datetime import date, datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models import (
|
||||
File, FileStatus, FileType, Employee, User, FileStatusHistory,
|
||||
FileTransferHistory, FileArchiveInfo
|
||||
)
|
||||
from app.services.file_management import FileManagementService, FileManagementError, FileStatusWorkflow
|
||||
from app.auth.security import get_current_user
|
||||
from app.utils.logging import app_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = app_logger
|
||||
|
||||
|
||||
# Pydantic schemas for requests/responses
|
||||
class FileStatusChangeRequest(BaseModel):
|
||||
"""Request to change file status"""
|
||||
new_status: str = Field(..., description="New status code")
|
||||
notes: Optional[str] = Field(None, description="Notes about the status change")
|
||||
validate_transition: bool = Field(True, description="Whether to validate the status transition")
|
||||
|
||||
|
||||
class FileClosureRequest(BaseModel):
|
||||
"""Request to close a file"""
|
||||
force_close: bool = Field(False, description="Force closure even if there are warnings")
|
||||
final_payment_amount: Optional[float] = Field(None, gt=0, description="Final payment amount")
|
||||
closing_notes: Optional[str] = Field(None, description="Notes about file closure")
|
||||
|
||||
|
||||
class FileReopenRequest(BaseModel):
|
||||
"""Request to reopen a closed file"""
|
||||
new_status: str = Field("ACTIVE", description="Status to reopen file to")
|
||||
notes: Optional[str] = Field(None, description="Notes about reopening")
|
||||
|
||||
|
||||
class FileTransferRequest(BaseModel):
|
||||
"""Request to transfer file to different attorney"""
|
||||
new_attorney_id: str = Field(..., description="Employee ID of new attorney")
|
||||
transfer_reason: Optional[str] = Field(None, description="Reason for transfer")
|
||||
|
||||
|
||||
class FileArchiveRequest(BaseModel):
|
||||
"""Request to archive a file"""
|
||||
archive_location: Optional[str] = Field(None, description="Physical or digital archive location")
|
||||
notes: Optional[str] = Field(None, description="Archive notes")
|
||||
|
||||
|
||||
class BulkStatusUpdateRequest(BaseModel):
|
||||
"""Request to update status for multiple files"""
|
||||
file_numbers: List[str] = Field(..., max_length=100, description="List of file numbers")
|
||||
new_status: str = Field(..., description="New status for all files")
|
||||
notes: Optional[str] = Field(None, description="Notes for all status changes")
|
||||
|
||||
|
||||
class FileStatusHistoryResponse(BaseModel):
|
||||
"""Response for file status history"""
|
||||
id: int
|
||||
old_status: str
|
||||
new_status: str
|
||||
change_date: datetime
|
||||
changed_by: str
|
||||
notes: Optional[str] = None
|
||||
system_generated: bool
|
||||
|
||||
|
||||
class FileTransferHistoryResponse(BaseModel):
|
||||
"""Response for file transfer history"""
|
||||
id: int
|
||||
old_attorney_id: str
|
||||
new_attorney_id: str
|
||||
transfer_date: datetime
|
||||
authorized_by_name: str
|
||||
reason: Optional[str] = None
|
||||
old_hourly_rate: Optional[float] = None
|
||||
new_hourly_rate: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FileClosureSummaryResponse(BaseModel):
|
||||
"""Response for file closure summary"""
|
||||
file_no: str
|
||||
closure_date: date
|
||||
actions_taken: List[str]
|
||||
warnings: List[str]
|
||||
final_balance: float
|
||||
trust_balance: float
|
||||
|
||||
|
||||
class FileValidationResponse(BaseModel):
|
||||
"""Response for file validation checks"""
|
||||
file_no: str
|
||||
current_status: str
|
||||
valid_transitions: List[str]
|
||||
can_close: bool
|
||||
blocking_issues: List[str]
|
||||
warnings: List[str]
|
||||
|
||||
|
||||
class ClosureCandidateResponse(BaseModel):
|
||||
"""Response for file closure candidates"""
|
||||
file_no: str
|
||||
client_name: str
|
||||
attorney: str
|
||||
opened_date: date
|
||||
last_activity: Optional[date] = None
|
||||
outstanding_balance: float
|
||||
status: str
|
||||
|
||||
|
||||
class BulkOperationResult(BaseModel):
|
||||
"""Result of bulk operation"""
|
||||
successful: List[str]
|
||||
failed: List[Dict[str, str]]
|
||||
total: int
|
||||
|
||||
|
||||
# File status management endpoints
|
||||
@router.post("/{file_no}/change-status")
|
||||
async def change_file_status(
|
||||
file_no: str,
|
||||
request: FileStatusChangeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Change file status with workflow validation"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
file_obj = service.change_file_status(
|
||||
file_no=file_no,
|
||||
new_status=request.new_status,
|
||||
user_id=current_user.id,
|
||||
notes=request.notes,
|
||||
validate_transition=request.validate_transition
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"File {file_no} status changed to {request.new_status}",
|
||||
"file_no": file_obj.file_no,
|
||||
"old_status": file_obj.status,
|
||||
"new_status": request.new_status
|
||||
}
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{file_no}/valid-transitions")
|
||||
async def get_valid_status_transitions(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get valid status transitions for a file"""
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
workflow = FileStatusWorkflow()
|
||||
valid_transitions = workflow.get_valid_transitions(file_obj.status)
|
||||
|
||||
return {
|
||||
"file_no": file_no,
|
||||
"current_status": file_obj.status,
|
||||
"valid_transitions": valid_transitions
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{file_no}/closure-validation", response_model=FileValidationResponse)
|
||||
async def validate_file_closure(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Validate if file is ready for closure"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
validation_result = service._validate_file_closure(file_obj)
|
||||
workflow = FileStatusWorkflow()
|
||||
|
||||
return FileValidationResponse(
|
||||
file_no=file_no,
|
||||
current_status=file_obj.status,
|
||||
valid_transitions=workflow.get_valid_transitions(file_obj.status),
|
||||
can_close=validation_result["can_close"],
|
||||
blocking_issues=validation_result.get("blocking_issues", []),
|
||||
warnings=validation_result.get("warnings", [])
|
||||
)
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{file_no}/close", response_model=FileClosureSummaryResponse)
|
||||
async def close_file(
|
||||
file_no: str,
|
||||
request: FileClosureRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Close a file with automated closure process"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
closure_summary = service.close_file(
|
||||
file_no=file_no,
|
||||
user_id=current_user.id,
|
||||
force_close=request.force_close,
|
||||
final_payment_amount=request.final_payment_amount,
|
||||
closing_notes=request.closing_notes
|
||||
)
|
||||
|
||||
return FileClosureSummaryResponse(**closure_summary)
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{file_no}/reopen")
|
||||
async def reopen_file(
|
||||
file_no: str,
|
||||
request: FileReopenRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Reopen a closed file"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
file_obj = service.reopen_file(
|
||||
file_no=file_no,
|
||||
user_id=current_user.id,
|
||||
new_status=request.new_status,
|
||||
notes=request.notes
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"File {file_no} reopened with status {request.new_status}",
|
||||
"file_no": file_obj.file_no,
|
||||
"new_status": file_obj.status
|
||||
}
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{file_no}/transfer")
|
||||
async def transfer_file(
|
||||
file_no: str,
|
||||
request: FileTransferRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Transfer file to a different attorney"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
file_obj = service.transfer_file(
|
||||
file_no=file_no,
|
||||
new_attorney_id=request.new_attorney_id,
|
||||
user_id=current_user.id,
|
||||
transfer_reason=request.transfer_reason
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"File {file_no} transferred to attorney {request.new_attorney_id}",
|
||||
"file_no": file_obj.file_no,
|
||||
"new_attorney": file_obj.empl_num,
|
||||
"new_rate": file_obj.rate_per_hour
|
||||
}
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{file_no}/archive")
|
||||
async def archive_file(
|
||||
file_no: str,
|
||||
request: FileArchiveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Archive a closed file"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
file_obj = service.archive_file(
|
||||
file_no=file_no,
|
||||
user_id=current_user.id,
|
||||
archive_location=request.archive_location,
|
||||
notes=request.notes
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"File {file_no} has been archived",
|
||||
"file_no": file_obj.file_no,
|
||||
"status": file_obj.status,
|
||||
"archive_location": request.archive_location
|
||||
}
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# File history endpoints
|
||||
@router.get("/{file_no}/status-history", response_model=List[FileStatusHistoryResponse])
|
||||
async def get_file_status_history(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get status change history for a file"""
|
||||
# Verify file exists
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
service = FileManagementService(db)
|
||||
history = service.get_file_status_history(file_no)
|
||||
|
||||
return [FileStatusHistoryResponse(**item) for item in history]
|
||||
|
||||
|
||||
@router.get("/{file_no}/transfer-history", response_model=List[FileTransferHistoryResponse])
|
||||
async def get_file_transfer_history(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get transfer history for a file"""
|
||||
# Verify file exists
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
transfers = db.query(FileTransferHistory).filter(
|
||||
FileTransferHistory.file_no == file_no
|
||||
).order_by(FileTransferHistory.transfer_date.desc()).all()
|
||||
|
||||
return transfers
|
||||
|
||||
|
||||
# Bulk operations
|
||||
@router.post("/bulk-status-update", response_model=BulkOperationResult)
|
||||
async def bulk_status_update(
|
||||
request: BulkStatusUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update status for multiple files"""
|
||||
try:
|
||||
service = FileManagementService(db)
|
||||
results = service.bulk_status_update(
|
||||
file_numbers=request.file_numbers,
|
||||
new_status=request.new_status,
|
||||
user_id=current_user.id,
|
||||
notes=request.notes
|
||||
)
|
||||
|
||||
return BulkOperationResult(**results)
|
||||
except FileManagementError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# File queries and reports
|
||||
@router.get("/by-status/{status}")
|
||||
async def get_files_by_status(
|
||||
status: str,
|
||||
attorney_id: Optional[str] = Query(None, description="Filter by attorney ID"),
|
||||
limit: int = Query(100, ge=1, le=500, description="Maximum number of files to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get files by status with optional attorney filter"""
|
||||
service = FileManagementService(db)
|
||||
files = service.get_files_by_status(status, attorney_id, limit)
|
||||
|
||||
return [
|
||||
{
|
||||
"file_no": f.file_no,
|
||||
"client_name": f"{f.owner.first or ''} {f.owner.last}".strip() if f.owner else "Unknown",
|
||||
"regarding": f.regarding,
|
||||
"attorney": f.empl_num,
|
||||
"opened_date": f.opened,
|
||||
"closed_date": f.closed,
|
||||
"status": f.status
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
|
||||
@router.get("/closure-candidates", response_model=List[ClosureCandidateResponse])
|
||||
async def get_closure_candidates(
|
||||
days_inactive: int = Query(90, ge=30, le=365, description="Days since last activity"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get files that are candidates for closure"""
|
||||
service = FileManagementService(db)
|
||||
candidates = service.get_closure_candidates(days_inactive)
|
||||
|
||||
return [ClosureCandidateResponse(**candidate) for candidate in candidates]
|
||||
|
||||
|
||||
# Lookup endpoints
|
||||
@router.get("/statuses")
|
||||
async def get_file_statuses(
|
||||
active_only: bool = Query(True, description="Return only active statuses"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available file statuses"""
|
||||
query = db.query(FileStatus)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(FileStatus.active == True)
|
||||
|
||||
statuses = query.order_by(FileStatus.sort_order, FileStatus.status_code).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"status_code": s.status_code,
|
||||
"description": s.description,
|
||||
"active": s.active,
|
||||
"send": s.send,
|
||||
"footer_code": s.footer_code
|
||||
}
|
||||
for s in statuses
|
||||
]
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
async def get_file_types(
|
||||
active_only: bool = Query(True, description="Return only active file types"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available file types"""
|
||||
query = db.query(FileType)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(FileType.active == True)
|
||||
|
||||
types = query.order_by(FileType.type_code).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"type_code": t.type_code,
|
||||
"description": t.description,
|
||||
"default_rate": t.default_rate,
|
||||
"active": t.active
|
||||
}
|
||||
for t in types
|
||||
]
|
||||
|
||||
|
||||
@router.get("/attorneys")
|
||||
async def get_attorneys(
|
||||
active_only: bool = Query(True, description="Return only active attorneys"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available attorneys for file assignment"""
|
||||
query = db.query(Employee)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Employee.active == True)
|
||||
|
||||
attorneys = query.order_by(Employee.last_name, Employee.first_name).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"empl_num": a.empl_num,
|
||||
"name": f"{a.first_name or ''} {a.last_name}".strip(),
|
||||
"title": a.title,
|
||||
"rate_per_hour": a.rate_per_hour,
|
||||
"active": a.active
|
||||
}
|
||||
for a in attorneys
|
||||
]
|
||||
577
app/api/timers.py
Normal file
577
app/api/timers.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
Timer and time tracking API endpoints
|
||||
"""
|
||||
from typing import List, Optional, Union
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models import Timer, TimeEntry, TimerTemplate, TimerStatus, TimerType, User
|
||||
from app.services.timers import TimerService, TimerServiceError
|
||||
from app.auth.security import get_current_user
|
||||
from app.utils.logging import app_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = app_logger
|
||||
|
||||
|
||||
# Pydantic schemas for requests/responses
|
||||
class TimerResponse(BaseModel):
|
||||
"""Response model for timers"""
|
||||
id: int
|
||||
user_id: int
|
||||
file_no: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
timer_type: TimerType
|
||||
status: TimerStatus
|
||||
total_seconds: int
|
||||
hourly_rate: Optional[float] = None
|
||||
is_billable: bool
|
||||
task_category: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
last_started_at: Optional[datetime] = None
|
||||
last_paused_at: Optional[datetime] = None
|
||||
stopped_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
# Computed properties
|
||||
total_hours: Optional[float] = None
|
||||
is_active: Optional[bool] = None
|
||||
current_session_seconds: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def from_timer(cls, timer: Timer) -> "TimerResponse":
|
||||
"""Create response from Timer model with computed properties"""
|
||||
return cls(
|
||||
**timer.__dict__,
|
||||
total_hours=timer.total_hours,
|
||||
is_active=timer.is_active,
|
||||
current_session_seconds=timer.get_current_session_seconds()
|
||||
)
|
||||
|
||||
|
||||
class TimerCreate(BaseModel):
|
||||
"""Create timer request"""
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
file_no: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
timer_type: TimerType = TimerType.BILLABLE
|
||||
hourly_rate: Optional[float] = Field(None, gt=0)
|
||||
task_category: Optional[str] = None
|
||||
template_id: Optional[int] = None
|
||||
|
||||
|
||||
class TimerUpdate(BaseModel):
|
||||
"""Update timer request"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
file_no: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
timer_type: Optional[TimerType] = None
|
||||
hourly_rate: Optional[float] = Field(None, gt=0)
|
||||
task_category: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class TimeEntryResponse(BaseModel):
|
||||
"""Response model for time entries"""
|
||||
id: int
|
||||
timer_id: Optional[int] = None
|
||||
user_id: int
|
||||
file_no: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
entry_type: TimerType
|
||||
hours: float
|
||||
entry_date: datetime
|
||||
hourly_rate: Optional[float] = None
|
||||
is_billable: bool
|
||||
billed: bool
|
||||
ledger_id: Optional[int] = None
|
||||
task_category: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
approved: bool
|
||||
approved_by: Optional[str] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
|
||||
# Computed property
|
||||
calculated_amount: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def from_time_entry(cls, entry: TimeEntry) -> "TimeEntryResponse":
|
||||
"""Create response from TimeEntry model with computed properties"""
|
||||
return cls(
|
||||
**entry.__dict__,
|
||||
calculated_amount=entry.calculated_amount
|
||||
)
|
||||
|
||||
|
||||
class TimeEntryCreate(BaseModel):
|
||||
"""Create time entry request"""
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
file_no: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
hours: float = Field(..., gt=0, le=24)
|
||||
entry_date: datetime
|
||||
hourly_rate: Optional[float] = Field(None, gt=0)
|
||||
entry_type: TimerType = TimerType.BILLABLE
|
||||
task_category: Optional[str] = None
|
||||
|
||||
|
||||
class TimeEntryFromTimerCreate(BaseModel):
|
||||
"""Create time entry from timer request"""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
hours_override: Optional[float] = Field(None, gt=0, le=24)
|
||||
entry_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class TimerTemplateResponse(BaseModel):
|
||||
"""Response model for timer templates"""
|
||||
id: int
|
||||
name: str
|
||||
title_template: str
|
||||
description_template: Optional[str] = None
|
||||
timer_type: TimerType
|
||||
task_category: Optional[str] = None
|
||||
default_rate: Optional[float] = None
|
||||
is_billable: bool
|
||||
is_active: bool
|
||||
usage_count: int
|
||||
created_by: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TimerTemplateCreate(BaseModel):
|
||||
"""Create timer template request"""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
title_template: str = Field(..., min_length=1, max_length=200)
|
||||
description_template: Optional[str] = None
|
||||
timer_type: TimerType = TimerType.BILLABLE
|
||||
task_category: Optional[str] = None
|
||||
default_rate: Optional[float] = Field(None, gt=0)
|
||||
is_billable: bool = True
|
||||
|
||||
|
||||
class TimerStatistics(BaseModel):
|
||||
"""Timer statistics response"""
|
||||
period_days: int
|
||||
total_hours: float
|
||||
billable_hours: float
|
||||
non_billable_hours: float
|
||||
active_timers: int
|
||||
time_entries_created: int
|
||||
time_entries_billed: int
|
||||
billable_percentage: float
|
||||
|
||||
|
||||
class PaginatedTimersResponse(BaseModel):
|
||||
"""Paginated timers response"""
|
||||
items: List[TimerResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PaginatedTimeEntriesResponse(BaseModel):
|
||||
"""Paginated time entries response"""
|
||||
items: List[TimeEntryResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# Timer endpoints
|
||||
@router.get("/", response_model=Union[List[TimerResponse], PaginatedTimersResponse])
|
||||
async def list_timers(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
status: Optional[TimerStatus] = Query(None, description="Filter by timer status"),
|
||||
file_no: Optional[str] = Query(None, description="Filter by file number"),
|
||||
active_only: bool = Query(False, description="Show only active (running/paused) timers"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List timers for the current user"""
|
||||
service = TimerService(db)
|
||||
|
||||
if active_only:
|
||||
timers = service.get_active_timers(current_user.id)
|
||||
timer_responses = [TimerResponse.from_timer(timer) for timer in timers]
|
||||
if include_total:
|
||||
return {"items": timer_responses, "total": len(timer_responses)}
|
||||
return timer_responses
|
||||
|
||||
# Get all timers with filtering
|
||||
timers = service.get_user_timers(
|
||||
user_id=current_user.id,
|
||||
status_filter=status,
|
||||
file_no=file_no,
|
||||
limit=limit + skip if not include_total else 1000 # Get more for pagination
|
||||
)
|
||||
|
||||
# Apply pagination manually for now
|
||||
if include_total:
|
||||
total = len(timers)
|
||||
paginated_timers = timers[skip:skip + limit]
|
||||
timer_responses = [TimerResponse.from_timer(timer) for timer in paginated_timers]
|
||||
return {"items": timer_responses, "total": total}
|
||||
|
||||
paginated_timers = timers[skip:skip + limit]
|
||||
timer_responses = [TimerResponse.from_timer(timer) for timer in paginated_timers]
|
||||
return timer_responses
|
||||
|
||||
|
||||
@router.post("/", response_model=TimerResponse)
|
||||
async def create_timer(
|
||||
timer_data: TimerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service.create_timer(
|
||||
user_id=current_user.id,
|
||||
**timer_data.model_dump()
|
||||
)
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{timer_id}", response_model=TimerResponse)
|
||||
async def get_timer(
|
||||
timer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service._get_user_timer(timer_id, current_user.id)
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{timer_id}", response_model=TimerResponse)
|
||||
async def update_timer(
|
||||
timer_id: int,
|
||||
timer_data: TimerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service._get_user_timer(timer_id, current_user.id)
|
||||
|
||||
# Update fields
|
||||
for field, value in timer_data.model_dump(exclude_unset=True).items():
|
||||
setattr(timer, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(timer)
|
||||
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{timer_id}")
|
||||
async def delete_timer(
|
||||
timer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
service.delete_timer(timer_id, current_user.id)
|
||||
return {"message": "Timer deleted successfully"}
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# Timer control endpoints
|
||||
@router.post("/{timer_id}/start", response_model=TimerResponse)
|
||||
async def start_timer(
|
||||
timer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Start a timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service.start_timer(timer_id, current_user.id)
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{timer_id}/pause", response_model=TimerResponse)
|
||||
async def pause_timer(
|
||||
timer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Pause a timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service.pause_timer(timer_id, current_user.id)
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{timer_id}/resume", response_model=TimerResponse)
|
||||
async def resume_timer(
|
||||
timer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Resume a paused timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service.resume_timer(timer_id, current_user.id)
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{timer_id}/stop", response_model=TimerResponse)
|
||||
async def stop_timer(
|
||||
timer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Stop a timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
timer = service.stop_timer(timer_id, current_user.id)
|
||||
return TimerResponse.from_timer(timer)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# Time entry endpoints
|
||||
@router.get("/time-entries/", response_model=Union[List[TimeEntryResponse], PaginatedTimeEntriesResponse])
|
||||
async def list_time_entries(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
|
||||
file_no: Optional[str] = Query(None, description="Filter by file number"),
|
||||
billed: Optional[bool] = Query(None, description="Filter by billing status"),
|
||||
start_date: Optional[date] = Query(None, description="Filter entries from this date"),
|
||||
end_date: Optional[date] = Query(None, description="Filter entries to this date"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List time entries for the current user"""
|
||||
query = db.query(TimeEntry).filter(TimeEntry.user_id == current_user.id)
|
||||
|
||||
if file_no:
|
||||
query = query.filter(TimeEntry.file_no == file_no)
|
||||
|
||||
if billed is not None:
|
||||
query = query.filter(TimeEntry.billed == billed)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(TimeEntry.entry_date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(TimeEntry.entry_date <= end_date)
|
||||
|
||||
query = query.order_by(TimeEntry.entry_date.desc())
|
||||
|
||||
if include_total:
|
||||
total = query.count()
|
||||
entries = query.offset(skip).limit(limit).all()
|
||||
entry_responses = [TimeEntryResponse.from_time_entry(entry) for entry in entries]
|
||||
return {"items": entry_responses, "total": total}
|
||||
|
||||
entries = query.offset(skip).limit(limit).all()
|
||||
entry_responses = [TimeEntryResponse.from_time_entry(entry) for entry in entries]
|
||||
return entry_responses
|
||||
|
||||
|
||||
@router.post("/time-entries/", response_model=TimeEntryResponse)
|
||||
async def create_manual_time_entry(
|
||||
entry_data: TimeEntryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a manual time entry"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
entry = service.create_manual_time_entry(
|
||||
user_id=current_user.id,
|
||||
**entry_data.model_dump()
|
||||
)
|
||||
return TimeEntryResponse.from_time_entry(entry)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{timer_id}/create-entry", response_model=TimeEntryResponse)
|
||||
async def create_time_entry_from_timer(
|
||||
timer_id: int,
|
||||
entry_data: TimeEntryFromTimerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a time entry from a completed timer"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
entry = service.create_time_entry_from_timer(
|
||||
timer_id=timer_id,
|
||||
user_id=current_user.id,
|
||||
**entry_data.model_dump()
|
||||
)
|
||||
return TimeEntryResponse.from_time_entry(entry)
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/time-entries/{entry_id}/convert-to-billing")
|
||||
async def convert_time_entry_to_billing(
|
||||
entry_id: int,
|
||||
transaction_code: str = Query("TIME", description="Transaction code for billing entry"),
|
||||
notes: Optional[str] = Query(None, description="Additional notes for billing entry"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Convert a time entry to a billable ledger transaction"""
|
||||
try:
|
||||
service = TimerService(db)
|
||||
ledger_entry = service.convert_time_entry_to_ledger(
|
||||
time_entry_id=entry_id,
|
||||
user_id=current_user.id,
|
||||
transaction_code=transaction_code,
|
||||
notes=notes
|
||||
)
|
||||
return {
|
||||
"message": "Time entry converted to billing successfully",
|
||||
"ledger_id": ledger_entry.id,
|
||||
"amount": ledger_entry.amount
|
||||
}
|
||||
except TimerServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# Timer templates endpoints
|
||||
@router.get("/templates/", response_model=List[TimerTemplateResponse])
|
||||
async def list_timer_templates(
|
||||
active_only: bool = Query(True, description="Show only active templates"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List timer templates"""
|
||||
query = db.query(TimerTemplate)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(TimerTemplate.is_active == True)
|
||||
|
||||
templates = query.order_by(TimerTemplate.usage_count.desc(), TimerTemplate.name).all()
|
||||
return templates
|
||||
|
||||
|
||||
@router.post("/templates/", response_model=TimerTemplateResponse)
|
||||
async def create_timer_template(
|
||||
template_data: TimerTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new timer template"""
|
||||
# Check if template name already exists
|
||||
existing = db.query(TimerTemplate).filter(TimerTemplate.name == template_data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Template name already exists"
|
||||
)
|
||||
|
||||
template = TimerTemplate(
|
||||
**template_data.model_dump(),
|
||||
created_by=current_user.username
|
||||
)
|
||||
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
# Statistics endpoint
|
||||
@router.get("/statistics/", response_model=TimerStatistics)
|
||||
async def get_timer_statistics(
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get timer statistics for the current user"""
|
||||
service = TimerService(db)
|
||||
stats = service.get_timer_statistics(current_user.id, days)
|
||||
return TimerStatistics(**stats)
|
||||
|
||||
|
||||
# Active timers quick access
|
||||
@router.get("/active/", response_model=List[TimerResponse])
|
||||
async def get_active_timers(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all active timers for the current user"""
|
||||
service = TimerService(db)
|
||||
timers = service.get_active_timers(current_user.id)
|
||||
return [TimerResponse.from_timer(timer) for timer in timers]
|
||||
@@ -94,6 +94,8 @@ from app.api.mortality import router as mortality_router
|
||||
from app.api.pensions import router as pensions_router
|
||||
from app.api.templates import router as templates_router
|
||||
from app.api.qdros import router as qdros_router
|
||||
from app.api.timers import router as timers_router
|
||||
from app.api.file_management import router as file_management_router
|
||||
|
||||
logger.info("Including API routers")
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
|
||||
@@ -112,6 +114,8 @@ app.include_router(mortality_router, prefix="/api/mortality", tags=["mortality"]
|
||||
app.include_router(pensions_router, prefix="/api/pensions", tags=["pensions"])
|
||||
app.include_router(templates_router, prefix="/api/templates", tags=["templates"])
|
||||
app.include_router(qdros_router, prefix="/api", tags=["qdros"])
|
||||
app.include_router(timers_router, prefix="/api/timers", tags=["timers"])
|
||||
app.include_router(file_management_router, prefix="/api/file-management", tags=["file-management"])
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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})>"
|
||||
|
||||
|
||||
|
||||
191
app/models/file_management.py
Normal file
191
app/models/file_management.py
Normal 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})>"
|
||||
@@ -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")
|
||||
@@ -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
227
app/models/timers.py
Normal 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})>"
|
||||
@@ -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}')>"
|
||||
539
app/services/billing.py
Normal file
539
app/services/billing.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
Billing statement generation service
|
||||
Handles statement creation, template rendering, and PDF generation
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from jinja2 import Template, Environment, BaseLoader
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from app.models import (
|
||||
BillingStatement, StatementTemplate, BillingStatementItem,
|
||||
File, Ledger, Rolodex, StatementStatus
|
||||
)
|
||||
from app.utils.logging import app_logger
|
||||
|
||||
logger = app_logger
|
||||
|
||||
|
||||
class StatementGenerationError(Exception):
|
||||
"""Exception raised when statement generation fails"""
|
||||
pass
|
||||
|
||||
|
||||
class BillingStatementService:
|
||||
"""Service for generating and managing billing statements"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.jinja_env = Environment(loader=BaseLoader())
|
||||
|
||||
def generate_statement_number(self) -> str:
|
||||
"""Generate unique statement number"""
|
||||
today = date.today()
|
||||
prefix = f"STMT-{today.strftime('%Y%m')}"
|
||||
|
||||
# Find highest number for this month
|
||||
last_stmt = self.db.query(BillingStatement).filter(
|
||||
BillingStatement.statement_number.like(f"{prefix}%")
|
||||
).order_by(BillingStatement.statement_number.desc()).first()
|
||||
|
||||
if last_stmt:
|
||||
try:
|
||||
last_num = int(last_stmt.statement_number.split('-')[-1])
|
||||
next_num = last_num + 1
|
||||
except (ValueError, IndexError):
|
||||
next_num = 1
|
||||
else:
|
||||
next_num = 1
|
||||
|
||||
return f"{prefix}-{next_num:04d}"
|
||||
|
||||
def get_unbilled_transactions(
|
||||
self,
|
||||
file_no: str,
|
||||
period_start: date = None,
|
||||
period_end: date = None
|
||||
) -> List[Ledger]:
|
||||
"""Get unbilled transactions for a file within date range"""
|
||||
query = self.db.query(Ledger).filter(
|
||||
Ledger.file_no == file_no,
|
||||
Ledger.billed == "N"
|
||||
)
|
||||
|
||||
if period_start:
|
||||
query = query.filter(Ledger.date >= period_start)
|
||||
if period_end:
|
||||
query = query.filter(Ledger.date <= period_end)
|
||||
|
||||
return query.order_by(Ledger.date).all()
|
||||
|
||||
def calculate_statement_totals(self, transactions: List[Ledger]) -> Dict[str, float]:
|
||||
"""Calculate financial totals for statement"""
|
||||
totals = {
|
||||
'fees': 0.0,
|
||||
'costs': 0.0,
|
||||
'payments': 0.0,
|
||||
'trust_deposits': 0.0,
|
||||
'trust_transfers': 0.0,
|
||||
'adjustments': 0.0,
|
||||
'current_charges': 0.0,
|
||||
'net_charges': 0.0
|
||||
}
|
||||
|
||||
for txn in transactions:
|
||||
amount = float(txn.amount or 0.0)
|
||||
|
||||
# Categorize by transaction type
|
||||
if txn.t_type in ['1', '2']: # Fees (hourly and flat)
|
||||
totals['fees'] += amount
|
||||
totals['current_charges'] += amount
|
||||
elif txn.t_type == '3': # Costs/disbursements
|
||||
totals['costs'] += amount
|
||||
totals['current_charges'] += amount
|
||||
elif txn.t_type == '4': # Payments
|
||||
totals['payments'] += amount
|
||||
totals['net_charges'] -= amount
|
||||
elif txn.t_type == '5': # Trust deposits
|
||||
totals['trust_deposits'] += amount
|
||||
# Add more categorization as needed
|
||||
|
||||
totals['net_charges'] = totals['current_charges'] - totals['payments']
|
||||
return totals
|
||||
|
||||
def get_previous_balance(self, file_no: str, period_start: date) -> float:
|
||||
"""Calculate previous balance before statement period"""
|
||||
# Sum all transactions before period start
|
||||
result = self.db.query(func.sum(Ledger.amount)).filter(
|
||||
Ledger.file_no == file_no,
|
||||
Ledger.date < period_start
|
||||
).scalar()
|
||||
|
||||
return float(result or 0.0)
|
||||
|
||||
def get_trust_balance(self, file_no: str) -> float:
|
||||
"""Get current trust account balance for file"""
|
||||
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
|
||||
return float(file_obj.trust_bal or 0.0) if file_obj else 0.0
|
||||
|
||||
def create_statement(
|
||||
self,
|
||||
file_no: str,
|
||||
period_start: date,
|
||||
period_end: date,
|
||||
template_id: Optional[int] = None,
|
||||
custom_footer: Optional[str] = None,
|
||||
created_by: Optional[str] = None
|
||||
) -> BillingStatement:
|
||||
"""Create a new billing statement for a file"""
|
||||
|
||||
# Get file and customer info
|
||||
file_obj = self.db.query(File).options(
|
||||
joinedload(File.owner)
|
||||
).filter(File.file_no == file_no).first()
|
||||
|
||||
if not file_obj:
|
||||
raise StatementGenerationError(f"File {file_no} not found")
|
||||
|
||||
# Get unbilled transactions
|
||||
transactions = self.get_unbilled_transactions(file_no, period_start, period_end)
|
||||
|
||||
if not transactions:
|
||||
raise StatementGenerationError(f"No unbilled transactions found for file {file_no}")
|
||||
|
||||
# Calculate totals
|
||||
totals = self.calculate_statement_totals(transactions)
|
||||
previous_balance = self.get_previous_balance(file_no, period_start)
|
||||
trust_balance = self.get_trust_balance(file_no)
|
||||
|
||||
# Calculate total due
|
||||
total_due = previous_balance + totals['current_charges'] - totals['payments']
|
||||
|
||||
# Get or create default template
|
||||
if not template_id:
|
||||
template = self.db.query(StatementTemplate).filter(
|
||||
StatementTemplate.is_default == True,
|
||||
StatementTemplate.is_active == True
|
||||
).first()
|
||||
if template:
|
||||
template_id = template.id
|
||||
|
||||
# Create statement
|
||||
statement = BillingStatement(
|
||||
statement_number=self.generate_statement_number(),
|
||||
file_no=file_no,
|
||||
customer_id=file_obj.owner.id if file_obj.owner else None,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
statement_date=date.today(),
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
previous_balance=previous_balance,
|
||||
current_charges=totals['current_charges'],
|
||||
payments_credits=totals['payments'],
|
||||
total_due=total_due,
|
||||
trust_balance=trust_balance,
|
||||
template_id=template_id,
|
||||
billed_transaction_count=len(transactions),
|
||||
custom_footer=custom_footer,
|
||||
created_by=created_by,
|
||||
status=StatementStatus.DRAFT
|
||||
)
|
||||
|
||||
self.db.add(statement)
|
||||
self.db.flush() # Get the statement ID
|
||||
|
||||
# Create statement items
|
||||
for txn in transactions:
|
||||
item = BillingStatementItem(
|
||||
statement_id=statement.id,
|
||||
ledger_id=txn.id,
|
||||
date=txn.date,
|
||||
description=txn.note or f"{txn.t_code} - {txn.empl_num}",
|
||||
quantity=float(txn.quantity or 0.0),
|
||||
rate=float(txn.rate or 0.0),
|
||||
amount=float(txn.amount or 0.0),
|
||||
item_category=self._categorize_transaction(txn)
|
||||
)
|
||||
self.db.add(item)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(statement)
|
||||
|
||||
logger.info(f"Created statement {statement.statement_number} for file {file_no}")
|
||||
return statement
|
||||
|
||||
def _categorize_transaction(self, txn: Ledger) -> str:
|
||||
"""Categorize transaction for statement display"""
|
||||
if txn.t_type in ['1', '2']:
|
||||
return 'fees'
|
||||
elif txn.t_type == '3':
|
||||
return 'costs'
|
||||
elif txn.t_type == '4':
|
||||
return 'payments'
|
||||
elif txn.t_type == '5':
|
||||
return 'trust'
|
||||
else:
|
||||
return 'other'
|
||||
|
||||
def generate_statement_html(self, statement_id: int) -> str:
|
||||
"""Generate HTML content for a statement"""
|
||||
statement = self.db.query(BillingStatement).options(
|
||||
joinedload(BillingStatement.file).joinedload(File.owner),
|
||||
joinedload(BillingStatement.customer),
|
||||
joinedload(BillingStatement.template),
|
||||
joinedload(BillingStatement.statement_items)
|
||||
).filter(BillingStatement.id == statement_id).first()
|
||||
|
||||
if not statement:
|
||||
raise StatementGenerationError(f"Statement {statement_id} not found")
|
||||
|
||||
# Prepare template context
|
||||
context = self._prepare_template_context(statement)
|
||||
|
||||
# Get template or use default
|
||||
template_content = self._get_statement_template(statement)
|
||||
|
||||
# Render template
|
||||
template = self.jinja_env.from_string(template_content)
|
||||
html_content = template.render(**context)
|
||||
|
||||
# Save generated HTML
|
||||
statement.html_content = html_content
|
||||
self.db.commit()
|
||||
|
||||
return html_content
|
||||
|
||||
def _prepare_template_context(self, statement: BillingStatement) -> Dict[str, Any]:
|
||||
"""Prepare context data for template rendering"""
|
||||
|
||||
# Group statement items by category
|
||||
items_by_category = {}
|
||||
for item in statement.statement_items:
|
||||
category = item.item_category or 'other'
|
||||
if category not in items_by_category:
|
||||
items_by_category[category] = []
|
||||
items_by_category[category].append(item)
|
||||
|
||||
return {
|
||||
'statement': statement,
|
||||
'file': statement.file,
|
||||
'customer': statement.customer or statement.file.owner,
|
||||
'items_by_category': items_by_category,
|
||||
'total_fees': sum(item.amount for item in items_by_category.get('fees', [])),
|
||||
'total_costs': sum(item.amount for item in items_by_category.get('costs', [])),
|
||||
'total_payments': sum(item.amount for item in items_by_category.get('payments', [])),
|
||||
'generation_date': datetime.now(),
|
||||
'custom_footer': statement.custom_footer
|
||||
}
|
||||
|
||||
def _get_statement_template(self, statement: BillingStatement) -> str:
|
||||
"""Get template content for statement"""
|
||||
if statement.template and statement.template.is_active:
|
||||
# Use custom template
|
||||
header = statement.template.header_template or ""
|
||||
footer = statement.template.footer_template or ""
|
||||
css = statement.template.css_styles or ""
|
||||
|
||||
return self._build_complete_template(header, footer, css)
|
||||
else:
|
||||
# Use default template
|
||||
return self._get_default_template()
|
||||
|
||||
def _build_complete_template(self, header: str, footer: str, css: str) -> str:
|
||||
"""Build complete HTML template from components"""
|
||||
# Use regular string formatting to avoid f-string conflicts with Jinja2
|
||||
template = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Billing Statement - {{ statement.statement_number }}</title>
|
||||
<style>
|
||||
%(css)s
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.statement-header { margin-bottom: 30px; }
|
||||
.statement-details { margin-bottom: 20px; }
|
||||
.statement-items { margin-bottom: 30px; }
|
||||
.statement-footer { margin-top: 30px; }
|
||||
table { width: 100%%; border-collapse: collapse; }
|
||||
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
.amount { text-align: right; }
|
||||
.total-row { font-weight: bold; border-top: 2px solid #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="statement-header">
|
||||
%(header)s
|
||||
</div>
|
||||
|
||||
<div class="statement-content">
|
||||
<!-- Default statement content will be inserted here -->
|
||||
{{ self.default_content() }}
|
||||
</div>
|
||||
|
||||
<div class="statement-footer">
|
||||
%(footer)s
|
||||
{%% if custom_footer %%}
|
||||
<div class="custom-footer">{{ custom_footer }}</div>
|
||||
{%% endif %%}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""" % {"css": css, "header": header, "footer": footer}
|
||||
return template
|
||||
|
||||
def _get_default_template(self) -> str:
|
||||
"""Get default statement template"""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Billing Statement - {{ statement.statement_number }}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.client-info { margin-bottom: 20px; }
|
||||
.statement-details { margin-bottom: 20px; }
|
||||
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
|
||||
.items-table th, .items-table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
.amount { text-align: right; }
|
||||
.total-row { font-weight: bold; border-top: 2px solid #000; }
|
||||
.summary { margin-top: 20px; }
|
||||
.footer { margin-top: 40px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>BILLING STATEMENT</h1>
|
||||
<h2>{{ statement.statement_number }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="client-info">
|
||||
<strong>Bill To:</strong><br>
|
||||
{{ customer.first }} {{ customer.last }}<br>
|
||||
{% if customer.a1 %}{{ customer.a1 }}<br>{% endif %}
|
||||
{% if customer.a2 %}{{ customer.a2 }}<br>{% endif %}
|
||||
{% if customer.city %}{{ customer.city }}, {{ customer.abrev }} {{ customer.zip }}{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="statement-details">
|
||||
<table>
|
||||
<tr><td><strong>File Number:</strong></td><td>{{ statement.file_no }}</td></tr>
|
||||
<tr><td><strong>Statement Date:</strong></td><td>{{ statement.statement_date.strftime('%m/%d/%Y') }}</td></tr>
|
||||
<tr><td><strong>Period:</strong></td><td>{{ statement.period_start.strftime('%m/%d/%Y') }} - {{ statement.period_end.strftime('%m/%d/%Y') }}</td></tr>
|
||||
<tr><td><strong>Due Date:</strong></td><td>{{ statement.due_date.strftime('%m/%d/%Y') if statement.due_date else 'Upon Receipt' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Fees Section -->
|
||||
{% if items_by_category.fees %}
|
||||
<h3>Professional Services</h3>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Quantity</th>
|
||||
<th>Rate</th>
|
||||
<th class="amount">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items_by_category.fees %}
|
||||
<tr>
|
||||
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ "%.2f"|format(item.quantity) if item.quantity else '' }}</td>
|
||||
<td>{{ "$%.2f"|format(item.rate) if item.rate else '' }}</td>
|
||||
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td colspan="4">Total Professional Services</td>
|
||||
<td class="amount">${{ "%.2f"|format(total_fees) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<!-- Costs Section -->
|
||||
{% if items_by_category.costs %}
|
||||
<h3>Costs and Disbursements</h3>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th class="amount">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items_by_category.costs %}
|
||||
<tr>
|
||||
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Costs and Disbursements</td>
|
||||
<td class="amount">${{ "%.2f"|format(total_costs) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<!-- Payments Section -->
|
||||
{% if items_by_category.payments %}
|
||||
<h3>Payments and Credits</h3>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th class="amount">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items_by_category.payments %}
|
||||
<tr>
|
||||
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Payments and Credits</td>
|
||||
<td class="amount">${{ "%.2f"|format(total_payments) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="summary">
|
||||
<table class="items-table">
|
||||
<tr>
|
||||
<td><strong>Previous Balance:</strong></td>
|
||||
<td class="amount">${{ "%.2f"|format(statement.previous_balance) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Current Charges:</strong></td>
|
||||
<td class="amount">${{ "%.2f"|format(statement.current_charges) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Payments/Credits:</strong></td>
|
||||
<td class="amount">${{ "%.2f"|format(statement.payments_credits) }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td><strong>TOTAL DUE:</strong></td>
|
||||
<td class="amount"><strong>${{ "%.2f"|format(statement.total_due) }}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if statement.trust_balance > 0 %}
|
||||
<div class="trust-info">
|
||||
<p><strong>Trust Account Balance:</strong> ${{ "%.2f"|format(statement.trust_balance) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<p>Thank you for your business. Please remit payment by the due date.</p>
|
||||
{% if custom_footer %}
|
||||
<p>{{ custom_footer }}</p>
|
||||
{% endif %}
|
||||
<p><em>Generated on {{ generation_date.strftime('%m/%d/%Y at %I:%M %p') }}</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def approve_statement(self, statement_id: int, approved_by: str) -> BillingStatement:
|
||||
"""Approve a statement and mark transactions as billed"""
|
||||
statement = self.db.query(BillingStatement).filter(
|
||||
BillingStatement.id == statement_id
|
||||
).first()
|
||||
|
||||
if not statement:
|
||||
raise StatementGenerationError(f"Statement {statement_id} not found")
|
||||
|
||||
if statement.status != StatementStatus.DRAFT:
|
||||
raise StatementGenerationError(f"Only draft statements can be approved")
|
||||
|
||||
# Mark statement as approved
|
||||
statement.status = StatementStatus.APPROVED
|
||||
statement.approved_by = approved_by
|
||||
statement.approved_at = datetime.now()
|
||||
|
||||
# Mark all related transactions as billed
|
||||
ledger_ids = [item.ledger_id for item in statement.statement_items]
|
||||
self.db.query(Ledger).filter(
|
||||
Ledger.id.in_(ledger_ids)
|
||||
).update({Ledger.billed: "Y"}, synchronize_session=False)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Approved statement {statement.statement_number} by {approved_by}")
|
||||
return statement
|
||||
|
||||
def mark_statement_sent(self, statement_id: int, sent_by: str) -> BillingStatement:
|
||||
"""Mark statement as sent"""
|
||||
statement = self.db.query(BillingStatement).filter(
|
||||
BillingStatement.id == statement_id
|
||||
).first()
|
||||
|
||||
if not statement:
|
||||
raise StatementGenerationError(f"Statement {statement_id} not found")
|
||||
|
||||
statement.status = StatementStatus.SENT
|
||||
statement.sent_by = sent_by
|
||||
statement.sent_at = datetime.now()
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Marked statement {statement.statement_number} as sent by {sent_by}")
|
||||
return statement
|
||||
651
app/services/file_management.py
Normal file
651
app/services/file_management.py
Normal file
@@ -0,0 +1,651 @@
|
||||
"""
|
||||
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 "")
|
||||
)
|
||||
530
app/services/timers.py
Normal file
530
app/services/timers.py
Normal file
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
Timer service for time tracking functionality
|
||||
Handles timer start/stop/pause operations and time entry creation
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.models import (
|
||||
Timer, TimeEntry, TimerSession, TimerTemplate, TimerStatus, TimerType,
|
||||
User, File, Ledger, Rolodex
|
||||
)
|
||||
from app.utils.logging import app_logger
|
||||
|
||||
logger = app_logger
|
||||
|
||||
|
||||
class TimerServiceError(Exception):
|
||||
"""Exception raised when timer operations fail"""
|
||||
pass
|
||||
|
||||
|
||||
class TimerService:
|
||||
"""Service for managing timers and time tracking"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_timer(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
file_no: Optional[str] = None,
|
||||
customer_id: Optional[str] = None,
|
||||
timer_type: TimerType = TimerType.BILLABLE,
|
||||
hourly_rate: Optional[float] = None,
|
||||
task_category: Optional[str] = None,
|
||||
template_id: Optional[int] = None
|
||||
) -> Timer:
|
||||
"""Create a new timer"""
|
||||
|
||||
# Validate user exists
|
||||
user = self.db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise TimerServiceError(f"User {user_id} not found")
|
||||
|
||||
# Validate file exists if provided
|
||||
if file_no:
|
||||
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise TimerServiceError(f"File {file_no} not found")
|
||||
|
||||
# Use file's hourly rate if not specified
|
||||
if not hourly_rate and file_obj.rate_per_hour:
|
||||
hourly_rate = file_obj.rate_per_hour
|
||||
|
||||
# Validate customer exists if provided
|
||||
if customer_id:
|
||||
customer = self.db.query(Rolodex).filter(Rolodex.id == customer_id).first()
|
||||
if not customer:
|
||||
raise TimerServiceError(f"Customer {customer_id} not found")
|
||||
|
||||
# Apply template if provided
|
||||
if template_id:
|
||||
template = self.db.query(TimerTemplate).filter(TimerTemplate.id == template_id).first()
|
||||
if template:
|
||||
if not title:
|
||||
title = template.title_template
|
||||
if not description:
|
||||
description = template.description_template
|
||||
if timer_type == TimerType.BILLABLE: # Only override if default
|
||||
timer_type = template.timer_type
|
||||
if not hourly_rate and template.default_rate:
|
||||
hourly_rate = template.default_rate
|
||||
if not task_category:
|
||||
task_category = template.task_category
|
||||
|
||||
# Update template usage count
|
||||
template.usage_count += 1
|
||||
|
||||
timer = Timer(
|
||||
user_id=user_id,
|
||||
file_no=file_no,
|
||||
customer_id=customer_id,
|
||||
title=title,
|
||||
description=description,
|
||||
timer_type=timer_type,
|
||||
hourly_rate=hourly_rate,
|
||||
task_category=task_category,
|
||||
is_billable=(timer_type == TimerType.BILLABLE),
|
||||
status=TimerStatus.STOPPED
|
||||
)
|
||||
|
||||
self.db.add(timer)
|
||||
self.db.commit()
|
||||
self.db.refresh(timer)
|
||||
|
||||
logger.info(f"Created timer {timer.id} for user {user_id}: {title}")
|
||||
return timer
|
||||
|
||||
def start_timer(self, timer_id: int, user_id: int) -> Timer:
|
||||
"""Start a timer"""
|
||||
timer = self._get_user_timer(timer_id, user_id)
|
||||
|
||||
if timer.status == TimerStatus.RUNNING:
|
||||
raise TimerServiceError("Timer is already running")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Stop any other running timers for this user
|
||||
self._stop_other_timers(user_id, timer_id)
|
||||
|
||||
# Update timer status
|
||||
timer.status = TimerStatus.RUNNING
|
||||
timer.last_started_at = now
|
||||
|
||||
if not timer.started_at:
|
||||
timer.started_at = now
|
||||
|
||||
# Create session record
|
||||
session = TimerSession(
|
||||
timer_id=timer.id,
|
||||
started_at=now
|
||||
)
|
||||
self.db.add(session)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(timer)
|
||||
|
||||
logger.info(f"Started timer {timer.id} for user {user_id}")
|
||||
return timer
|
||||
|
||||
def pause_timer(self, timer_id: int, user_id: int) -> Timer:
|
||||
"""Pause a running timer"""
|
||||
timer = self._get_user_timer(timer_id, user_id)
|
||||
|
||||
if timer.status != TimerStatus.RUNNING:
|
||||
raise TimerServiceError("Timer is not running")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Calculate session time and add to total
|
||||
if timer.last_started_at:
|
||||
session_seconds = int((now - timer.last_started_at).total_seconds())
|
||||
timer.total_seconds += session_seconds
|
||||
|
||||
# Update timer status
|
||||
timer.status = TimerStatus.PAUSED
|
||||
timer.last_paused_at = now
|
||||
|
||||
# Update current session
|
||||
current_session = self.db.query(TimerSession).filter(
|
||||
TimerSession.timer_id == timer.id,
|
||||
TimerSession.ended_at.is_(None)
|
||||
).order_by(TimerSession.started_at.desc()).first()
|
||||
|
||||
if current_session:
|
||||
current_session.ended_at = now
|
||||
current_session.duration_seconds = int((now - current_session.started_at).total_seconds())
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(timer)
|
||||
|
||||
logger.info(f"Paused timer {timer.id} for user {user_id}")
|
||||
return timer
|
||||
|
||||
def stop_timer(self, timer_id: int, user_id: int) -> Timer:
|
||||
"""Stop a timer completely"""
|
||||
timer = self._get_user_timer(timer_id, user_id)
|
||||
|
||||
if timer.status == TimerStatus.STOPPED:
|
||||
raise TimerServiceError("Timer is already stopped")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# If running, calculate final session time
|
||||
if timer.status == TimerStatus.RUNNING and timer.last_started_at:
|
||||
session_seconds = int((now - timer.last_started_at).total_seconds())
|
||||
timer.total_seconds += session_seconds
|
||||
|
||||
# Update timer status
|
||||
timer.status = TimerStatus.STOPPED
|
||||
timer.stopped_at = now
|
||||
|
||||
# Update current session
|
||||
current_session = self.db.query(TimerSession).filter(
|
||||
TimerSession.timer_id == timer.id,
|
||||
TimerSession.ended_at.is_(None)
|
||||
).order_by(TimerSession.started_at.desc()).first()
|
||||
|
||||
if current_session:
|
||||
current_session.ended_at = now
|
||||
current_session.duration_seconds = int((now - current_session.started_at).total_seconds())
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(timer)
|
||||
|
||||
logger.info(f"Stopped timer {timer.id} for user {user_id}, total time: {timer.total_hours:.2f} hours")
|
||||
return timer
|
||||
|
||||
def resume_timer(self, timer_id: int, user_id: int) -> Timer:
|
||||
"""Resume a paused timer"""
|
||||
timer = self._get_user_timer(timer_id, user_id)
|
||||
|
||||
if timer.status != TimerStatus.PAUSED:
|
||||
raise TimerServiceError("Timer is not paused")
|
||||
|
||||
# Stop any other running timers for this user
|
||||
self._stop_other_timers(user_id, timer_id)
|
||||
|
||||
return self.start_timer(timer_id, user_id)
|
||||
|
||||
def delete_timer(self, timer_id: int, user_id: int) -> bool:
|
||||
"""Delete a timer (only if stopped)"""
|
||||
timer = self._get_user_timer(timer_id, user_id)
|
||||
|
||||
if timer.status != TimerStatus.STOPPED:
|
||||
raise TimerServiceError("Can only delete stopped timers")
|
||||
|
||||
# Check if timer has associated time entries
|
||||
entry_count = self.db.query(TimeEntry).filter(TimeEntry.timer_id == timer_id).count()
|
||||
if entry_count > 0:
|
||||
raise TimerServiceError(f"Cannot delete timer: {entry_count} time entries are linked to this timer")
|
||||
|
||||
self.db.delete(timer)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Deleted timer {timer_id} for user {user_id}")
|
||||
return True
|
||||
|
||||
def get_active_timers(self, user_id: int) -> List[Timer]:
|
||||
"""Get all active (running or paused) timers for a user"""
|
||||
return self.db.query(Timer).filter(
|
||||
Timer.user_id == user_id,
|
||||
Timer.status.in_([TimerStatus.RUNNING, TimerStatus.PAUSED])
|
||||
).options(
|
||||
joinedload(Timer.file),
|
||||
joinedload(Timer.customer)
|
||||
).all()
|
||||
|
||||
def get_user_timers(
|
||||
self,
|
||||
user_id: int,
|
||||
status_filter: Optional[TimerStatus] = None,
|
||||
file_no: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> List[Timer]:
|
||||
"""Get timers for a user with optional filtering"""
|
||||
query = self.db.query(Timer).filter(Timer.user_id == user_id)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(Timer.status == status_filter)
|
||||
|
||||
if file_no:
|
||||
query = query.filter(Timer.file_no == file_no)
|
||||
|
||||
return query.options(
|
||||
joinedload(Timer.file),
|
||||
joinedload(Timer.customer)
|
||||
).order_by(Timer.updated_at.desc()).limit(limit).all()
|
||||
|
||||
def create_time_entry_from_timer(
|
||||
self,
|
||||
timer_id: int,
|
||||
user_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
hours_override: Optional[float] = None,
|
||||
entry_date: Optional[datetime] = None
|
||||
) -> TimeEntry:
|
||||
"""Create a time entry from a completed timer"""
|
||||
timer = self._get_user_timer(timer_id, user_id)
|
||||
|
||||
if timer.status != TimerStatus.STOPPED:
|
||||
raise TimerServiceError("Timer must be stopped to create time entry")
|
||||
|
||||
if timer.total_seconds == 0:
|
||||
raise TimerServiceError("Timer has no recorded time")
|
||||
|
||||
# Use timer details or overrides
|
||||
entry_title = title or timer.title
|
||||
entry_description = description or timer.description
|
||||
entry_hours = hours_override or timer.total_hours
|
||||
entry_date = entry_date or timer.stopped_at or datetime.now(timezone.utc)
|
||||
|
||||
time_entry = TimeEntry(
|
||||
timer_id=timer.id,
|
||||
user_id=user_id,
|
||||
file_no=timer.file_no,
|
||||
customer_id=timer.customer_id,
|
||||
title=entry_title,
|
||||
description=entry_description,
|
||||
entry_type=timer.timer_type,
|
||||
hours=entry_hours,
|
||||
entry_date=entry_date,
|
||||
hourly_rate=timer.hourly_rate,
|
||||
is_billable=timer.is_billable,
|
||||
task_category=timer.task_category,
|
||||
created_by=f"user_{user_id}"
|
||||
)
|
||||
|
||||
self.db.add(time_entry)
|
||||
self.db.commit()
|
||||
self.db.refresh(time_entry)
|
||||
|
||||
logger.info(f"Created time entry {time_entry.id} from timer {timer_id}: {entry_hours:.2f} hours")
|
||||
return time_entry
|
||||
|
||||
def create_manual_time_entry(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
hours: float,
|
||||
entry_date: datetime,
|
||||
description: Optional[str] = None,
|
||||
file_no: Optional[str] = None,
|
||||
customer_id: Optional[str] = None,
|
||||
hourly_rate: Optional[float] = None,
|
||||
entry_type: TimerType = TimerType.BILLABLE,
|
||||
task_category: Optional[str] = None
|
||||
) -> TimeEntry:
|
||||
"""Create a manual time entry (not from a timer)"""
|
||||
|
||||
# Validate user
|
||||
user = self.db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise TimerServiceError(f"User {user_id} not found")
|
||||
|
||||
# Validate file if provided
|
||||
if file_no:
|
||||
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise TimerServiceError(f"File {file_no} not found")
|
||||
|
||||
# Use file's rate if not specified
|
||||
if not hourly_rate and file_obj.rate_per_hour:
|
||||
hourly_rate = file_obj.rate_per_hour
|
||||
|
||||
# Validate customer if provided
|
||||
if customer_id:
|
||||
customer = self.db.query(Rolodex).filter(Rolodex.id == customer_id).first()
|
||||
if not customer:
|
||||
raise TimerServiceError(f"Customer {customer_id} not found")
|
||||
|
||||
time_entry = TimeEntry(
|
||||
user_id=user_id,
|
||||
file_no=file_no,
|
||||
customer_id=customer_id,
|
||||
title=title,
|
||||
description=description,
|
||||
entry_type=entry_type,
|
||||
hours=hours,
|
||||
entry_date=entry_date,
|
||||
hourly_rate=hourly_rate,
|
||||
is_billable=(entry_type == TimerType.BILLABLE),
|
||||
task_category=task_category,
|
||||
created_by=f"user_{user_id}"
|
||||
)
|
||||
|
||||
self.db.add(time_entry)
|
||||
self.db.commit()
|
||||
self.db.refresh(time_entry)
|
||||
|
||||
logger.info(f"Created manual time entry {time_entry.id} for user {user_id}: {hours:.2f} hours")
|
||||
return time_entry
|
||||
|
||||
def convert_time_entry_to_ledger(
|
||||
self,
|
||||
time_entry_id: int,
|
||||
user_id: int,
|
||||
transaction_code: str = "TIME",
|
||||
notes: Optional[str] = None
|
||||
) -> Ledger:
|
||||
"""Convert a time entry to a billable ledger transaction"""
|
||||
|
||||
time_entry = self.db.query(TimeEntry).filter(
|
||||
TimeEntry.id == time_entry_id,
|
||||
TimeEntry.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not time_entry:
|
||||
raise TimerServiceError(f"Time entry {time_entry_id} not found")
|
||||
|
||||
if time_entry.billed:
|
||||
raise TimerServiceError("Time entry has already been billed")
|
||||
|
||||
if not time_entry.is_billable:
|
||||
raise TimerServiceError("Time entry is not billable")
|
||||
|
||||
if not time_entry.file_no:
|
||||
raise TimerServiceError("Time entry must have a file assignment for billing")
|
||||
|
||||
if not time_entry.hourly_rate or time_entry.hourly_rate <= 0:
|
||||
raise TimerServiceError("Time entry must have a valid hourly rate for billing")
|
||||
|
||||
# Get next item number for this file
|
||||
max_item = self.db.query(func.max(Ledger.item_no)).filter(
|
||||
Ledger.file_no == time_entry.file_no
|
||||
).scalar() or 0
|
||||
|
||||
# Calculate amount
|
||||
amount = time_entry.hours * time_entry.hourly_rate
|
||||
|
||||
# Create ledger entry
|
||||
ledger_entry = Ledger(
|
||||
file_no=time_entry.file_no,
|
||||
item_no=max_item + 1,
|
||||
date=time_entry.entry_date.date() if hasattr(time_entry.entry_date, 'date') else time_entry.entry_date,
|
||||
t_code=transaction_code,
|
||||
t_type="1", # Type 1 = hourly fees
|
||||
empl_num=f"user_{user_id}",
|
||||
quantity=time_entry.hours,
|
||||
rate=time_entry.hourly_rate,
|
||||
amount=amount,
|
||||
billed="N", # Will be marked as billed when statement is approved
|
||||
note=notes or time_entry.description or time_entry.title
|
||||
)
|
||||
|
||||
# Link time entry to ledger entry
|
||||
time_entry.ledger_id = ledger_entry.id
|
||||
time_entry.billed = True
|
||||
|
||||
self.db.add(ledger_entry)
|
||||
self.db.commit()
|
||||
self.db.refresh(ledger_entry)
|
||||
|
||||
# Update the time entry with the ledger ID
|
||||
time_entry.ledger_id = ledger_entry.id
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Converted time entry {time_entry_id} to ledger entry {ledger_entry.id}: ${amount:.2f}")
|
||||
return ledger_entry
|
||||
|
||||
def update_timer_total(self, timer_id: int) -> Timer:
|
||||
"""Recalculate timer total from sessions (for data consistency)"""
|
||||
timer = self.db.query(Timer).filter(Timer.id == timer_id).first()
|
||||
if not timer:
|
||||
raise TimerServiceError(f"Timer {timer_id} not found")
|
||||
|
||||
# Calculate total from completed sessions
|
||||
total_seconds = self.db.query(func.sum(TimerSession.duration_seconds)).filter(
|
||||
TimerSession.timer_id == timer_id,
|
||||
TimerSession.ended_at.isnot(None)
|
||||
).scalar() or 0
|
||||
|
||||
# Add current running session if applicable
|
||||
if timer.status == TimerStatus.RUNNING:
|
||||
total_seconds += timer.get_current_session_seconds()
|
||||
|
||||
timer.total_seconds = total_seconds
|
||||
self.db.commit()
|
||||
|
||||
return timer
|
||||
|
||||
def _get_user_timer(self, timer_id: int, user_id: int) -> Timer:
|
||||
"""Get timer and verify ownership"""
|
||||
timer = self.db.query(Timer).filter(
|
||||
Timer.id == timer_id,
|
||||
Timer.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not timer:
|
||||
raise TimerServiceError(f"Timer {timer_id} not found or access denied")
|
||||
|
||||
return timer
|
||||
|
||||
def _stop_other_timers(self, user_id: int, exclude_timer_id: int):
|
||||
"""Stop all other running timers for a user"""
|
||||
running_timers = self.db.query(Timer).filter(
|
||||
Timer.user_id == user_id,
|
||||
Timer.status == TimerStatus.RUNNING,
|
||||
Timer.id != exclude_timer_id
|
||||
).all()
|
||||
|
||||
for timer in running_timers:
|
||||
try:
|
||||
self.pause_timer(timer.id, user_id)
|
||||
logger.info(f"Auto-paused timer {timer.id} when starting timer {exclude_timer_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to auto-pause timer {timer.id}: {str(e)}")
|
||||
|
||||
def get_timer_statistics(self, user_id: int, days: int = 30) -> Dict[str, Any]:
|
||||
"""Get timer statistics for a user over the last N days"""
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
# Total time tracked
|
||||
total_seconds = self.db.query(func.sum(Timer.total_seconds)).filter(
|
||||
Timer.user_id == user_id,
|
||||
Timer.created_at >= cutoff_date
|
||||
).scalar() or 0
|
||||
|
||||
# Total billable time
|
||||
billable_seconds = self.db.query(func.sum(Timer.total_seconds)).filter(
|
||||
Timer.user_id == user_id,
|
||||
Timer.is_billable == True,
|
||||
Timer.created_at >= cutoff_date
|
||||
).scalar() or 0
|
||||
|
||||
# Number of active timers
|
||||
active_count = self.db.query(Timer).filter(
|
||||
Timer.user_id == user_id,
|
||||
Timer.status.in_([TimerStatus.RUNNING, TimerStatus.PAUSED])
|
||||
).count()
|
||||
|
||||
# Number of time entries created
|
||||
entries_count = self.db.query(TimeEntry).filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.created_at >= cutoff_date
|
||||
).count()
|
||||
|
||||
# Entries converted to billing
|
||||
billed_entries = self.db.query(TimeEntry).filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.billed == True,
|
||||
TimeEntry.created_at >= cutoff_date
|
||||
).count()
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"total_hours": total_seconds / 3600.0,
|
||||
"billable_hours": billable_seconds / 3600.0,
|
||||
"non_billable_hours": (total_seconds - billable_seconds) / 3600.0,
|
||||
"active_timers": active_count,
|
||||
"time_entries_created": entries_count,
|
||||
"time_entries_billed": billed_entries,
|
||||
"billable_percentage": (billable_seconds / total_seconds * 100) if total_seconds > 0 else 0
|
||||
}
|
||||
Reference in New Issue
Block a user