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 APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
|
||||||
from fastapi import Path as PathParam
|
from fastapi import Path as PathParam
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy.orm import Session, joinedload
|
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.responses import BulkOperationResponse, ErrorDetail
|
||||||
from app.utils.logging import StructuredLogger
|
from app.utils.logging import StructuredLogger
|
||||||
from app.services.cache import cache_get_json, cache_set_json
|
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()
|
router = APIRouter()
|
||||||
@@ -1605,3 +1609,417 @@ async def download_latest_statement(
|
|||||||
media_type="text/html",
|
media_type="text/html",
|
||||||
filename=latest_path.name,
|
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.pensions import router as pensions_router
|
||||||
from app.api.templates import router as templates_router
|
from app.api.templates import router as templates_router
|
||||||
from app.api.qdros import router as qdros_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")
|
logger.info("Including API routers")
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
|
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(pensions_router, prefix="/api/pensions", tags=["pensions"])
|
||||||
app.include_router(templates_router, prefix="/api/templates", tags=["templates"])
|
app.include_router(templates_router, prefix="/api/templates", tags=["templates"])
|
||||||
app.include_router(qdros_router, prefix="/api", tags=["qdros"])
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ from .pensions import (
|
|||||||
SeparationAgreement, LifeTable, NumberTable, PensionResult
|
SeparationAgreement, LifeTable, NumberTable, PensionResult
|
||||||
)
|
)
|
||||||
from .templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
|
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 (
|
from .lookups import (
|
||||||
Employee, FileType, FileStatus, TransactionType, TransactionCode,
|
Employee, FileType, FileStatus, TransactionType, TransactionCode,
|
||||||
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
|
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
|
||||||
@@ -34,5 +44,9 @@ __all__ = [
|
|||||||
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
|
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
|
||||||
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
|
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
|
||||||
"PrinterSetup", "SystemSetup", "FormKeyword", "TemplateKeyword",
|
"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
|
from app.models.base import BaseModel
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
class BillingBatch(BaseModel):
|
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")
|
separation_agreements = relationship("SeparationAgreement", back_populates="file", cascade="all, delete-orphan")
|
||||||
payments = relationship("Payment", 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")
|
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")
|
phone_numbers = relationship("Phone", back_populates="rolodex_entry", cascade="all, delete-orphan")
|
||||||
files = relationship("File", back_populates="owner")
|
files = relationship("File", back_populates="owner")
|
||||||
payments = relationship("Payment", back_populates="client")
|
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):
|
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
|
# Relationships
|
||||||
audit_logs = relationship("AuditLog", back_populates="user")
|
audit_logs = relationship("AuditLog", back_populates="user")
|
||||||
submitted_tickets = relationship("SupportTicket", foreign_keys="SupportTicket.user_id", back_populates="submitter")
|
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):
|
def __repr__(self):
|
||||||
return f"<User(username='{self.username}', email='{self.email}')>"
|
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
|
||||||
|
}
|
||||||
@@ -165,46 +165,60 @@ POST /api/documents/generate-batch
|
|||||||
|
|
||||||
**Legacy Feature**: Automated billing statement generation with trust account management
|
**Legacy Feature**: Automated billing statement generation with trust account management
|
||||||
|
|
||||||
**Current Status**: 🟡 **IN PROGRESS** (basic transactions exist, working on statement generation)
|
**Current Status**: ✅ **COMPLETED** (core statement generation system implemented)
|
||||||
|
|
||||||
**Missing Components**:
|
**Implemented Components**:
|
||||||
|
|
||||||
#### 3.1 Statement Generation Engine
|
#### 3.1 Statement Generation Engine
|
||||||
- [ ] Create billing statement templates
|
- [x] Create billing statement templates (StatementTemplate model)
|
||||||
- [ ] Automated statement generation by file/client
|
- [x] Automated statement generation by file/client (BillingStatementService)
|
||||||
- [ ] Customizable statement footers by file status
|
- [x] Customizable statement footers by file status (custom_footer field)
|
||||||
- [ ] Statement preview and approval workflow
|
- [x] Statement preview and approval workflow (preview/approve endpoints)
|
||||||
- [ ] Batch statement generation
|
- [x] Template-based HTML generation with Jinja2 engine
|
||||||
|
- [x] Default statement template with professional styling
|
||||||
|
- [x] Statement numbering system (STMT-YYYYMM-#### format)
|
||||||
|
|
||||||
#### 3.2 Enhanced Trust Account Management
|
#### 3.2 Enhanced Trust Account Management
|
||||||
- [ ] Extend trust account transaction types
|
- [x] Trust account balance tracking per file (trust_balance field)
|
||||||
- [ ] Trust account balance tracking per file
|
- [x] Trust account transaction integration (existing trust field in File model)
|
||||||
- [ ] IOLTA compliance reporting
|
- [ ] IOLTA compliance reporting (future enhancement)
|
||||||
- [ ] Trust-to-fee transfer automation
|
- [ ] Trust-to-fee transfer automation (future enhancement)
|
||||||
- [ ] Trust account reconciliation tools
|
- [ ] Trust account reconciliation tools (future enhancement)
|
||||||
|
|
||||||
#### 3.3 Billing Workflow Management
|
#### 3.3 Billing Workflow Management
|
||||||
- [ ] Billed/unbilled transaction status tracking
|
- [x] Billed/unbilled transaction status tracking (billed field in Ledger model)
|
||||||
- [ ] Bulk billing status updates
|
- [x] Statement approval and locking (StatementStatus enum with draft/approved/sent workflow)
|
||||||
- [ ] Statement approval and locking
|
- [x] Statement metadata tracking (approved_by, sent_by, timestamps)
|
||||||
- [ ] Payment application workflow
|
- [x] Statement deletion controls (only draft statements can be deleted)
|
||||||
- [ ] Account aging reports
|
- [ ] Bulk billing status updates (future enhancement)
|
||||||
|
- [ ] Payment application workflow (future enhancement)
|
||||||
|
- [ ] Account aging reports (future enhancement)
|
||||||
|
|
||||||
#### 3.4 Advanced Financial Reports
|
#### 3.4 Advanced Financial Reports
|
||||||
- [ ] Account balance summaries by employee
|
- [ ] Account balance summaries by employee (future enhancement)
|
||||||
- [ ] Account aging reports
|
- [ ] Account aging reports (future enhancement)
|
||||||
- [ ] Trust account activity reports
|
- [ ] Trust account activity reports (future enhancement)
|
||||||
- [ ] Revenue reports by area of law
|
- [ ] Revenue reports by area of law (future enhancement)
|
||||||
- [ ] Time utilization reports
|
- [ ] Time utilization reports (future enhancement)
|
||||||
|
|
||||||
**API Endpoints Needed**:
|
**API Endpoints Implemented**:
|
||||||
```
|
```
|
||||||
POST /api/billing/statements/generate
|
✅ GET /api/billing/statement-templates # List statement templates
|
||||||
GET /api/billing/statements/{file_no}
|
✅ POST /api/billing/statement-templates # Create statement template
|
||||||
POST /api/billing/statements/batch
|
✅ GET /api/billing/statement-templates/{id} # Get statement template
|
||||||
PUT /api/financial/transactions/bulk-bill
|
✅ PUT /api/billing/statement-templates/{id} # Update statement template
|
||||||
GET /api/reports/trust-account
|
✅ DELETE /api/billing/statement-templates/{id} # Delete statement template
|
||||||
GET /api/reports/account-aging
|
✅ GET /api/billing/billing-statements # List billing statements
|
||||||
|
✅ POST /api/billing/billing-statements # Create billing statement
|
||||||
|
✅ GET /api/billing/billing-statements/{id} # Get billing statement
|
||||||
|
✅ POST /api/billing/billing-statements/{id}/generate-html # Generate HTML
|
||||||
|
✅ POST /api/billing/billing-statements/{id}/approve # Approve statement
|
||||||
|
✅ POST /api/billing/billing-statements/{id}/send # Mark as sent
|
||||||
|
✅ GET /api/billing/billing-statements/{id}/preview # Preview HTML
|
||||||
|
✅ DELETE /api/billing/billing-statements/{id} # Delete draft statement
|
||||||
|
⏳ PUT /api/financial/transactions/bulk-bill # Future enhancement
|
||||||
|
⏳ GET /api/reports/trust-account # Future enhancement
|
||||||
|
⏳ GET /api/reports/account-aging # Future enhancement
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -215,71 +229,98 @@ GET /api/reports/account-aging
|
|||||||
|
|
||||||
**Legacy Feature**: Built-in timer with start/stop functionality
|
**Legacy Feature**: Built-in timer with start/stop functionality
|
||||||
|
|
||||||
**Current Status**: ❌ Not implemented
|
**Current Status**: ✅ **COMPLETED** (comprehensive time tracking system implemented)
|
||||||
|
|
||||||
**Required Components**:
|
**Implemented Components**:
|
||||||
|
|
||||||
#### 4.1 Timer System
|
#### 4.1 Timer System
|
||||||
- [ ] Real-time timer with start/stop/pause
|
- [x] Real-time timer with start/stop/pause/resume functionality (Timer model with TimerStatus enum)
|
||||||
- [ ] Timer state persistence across sessions
|
- [x] Timer state persistence across sessions (TimerSession model for detailed tracking)
|
||||||
- [ ] Multiple concurrent timers by file/task
|
- [x] Multiple concurrent timers by file/task (automatic pause of other timers when starting new one)
|
||||||
- [ ] Timer integration with transaction entry
|
- [x] Timer integration with transaction entry (convert_time_entry_to_ledger method)
|
||||||
|
- [x] Timer templates for quick creation of common task timers (TimerTemplate model)
|
||||||
|
- [x] Timer categorization by task type (research, drafting, client_call, etc.)
|
||||||
|
|
||||||
#### 4.2 Time Entry Automation
|
#### 4.2 Time Entry Automation
|
||||||
- [ ] Auto-populate time entries from timer
|
- [x] Auto-populate time entries from timer (create_time_entry_from_timer method)
|
||||||
- [ ] Default rate assignment by employee
|
- [x] Default rate assignment by employee/file (hourly_rate from File model or user override)
|
||||||
- [ ] Automatic quantity calculation
|
- [x] Automatic quantity calculation (hours computed from timer duration)
|
||||||
- [ ] Timer history and reporting
|
- [x] Timer history and reporting (TimerSession tracking, timer statistics)
|
||||||
|
- [x] Manual time entry creation (for non-timer based time logging)
|
||||||
|
- [x] Time entry approval workflow (approved/approved_by fields)
|
||||||
|
|
||||||
**Frontend Components**:
|
#### 4.3 Advanced Features
|
||||||
|
- [x] Billable vs non-billable time tracking (TimerType enum)
|
||||||
|
- [x] Multiple timer types (billable, non_billable, administrative)
|
||||||
|
- [x] Timer statistics and reporting (total hours, billable percentage, etc.)
|
||||||
|
- [x] Time entry to ledger conversion (automatic billing transaction creation)
|
||||||
|
- [x] File and customer assignment for timers
|
||||||
|
- [x] Detailed session tracking with pause/resume cycles
|
||||||
|
|
||||||
|
**API Endpoints Implemented**:
|
||||||
```
|
```
|
||||||
TimerWidget
|
✅ GET /api/timers/ # List user timers
|
||||||
├── Timer display (HH:MM:SS)
|
✅ POST /api/timers/ # Create timer
|
||||||
├── Start/Stop/Pause controls
|
✅ GET /api/timers/{id} # Get timer details
|
||||||
├── File/task selection
|
✅ PUT /api/timers/{id} # Update timer
|
||||||
└── Quick time entry creation
|
✅ DELETE /api/timers/{id} # Delete timer
|
||||||
|
✅ POST /api/timers/{id}/start # Start timer
|
||||||
|
✅ POST /api/timers/{id}/pause # Pause timer
|
||||||
|
✅ POST /api/timers/{id}/resume # Resume timer
|
||||||
|
✅ POST /api/timers/{id}/stop # Stop timer
|
||||||
|
✅ POST /api/timers/{id}/create-entry # Create time entry from timer
|
||||||
|
✅ GET /api/timers/time-entries/ # List time entries
|
||||||
|
✅ POST /api/timers/time-entries/ # Create manual time entry
|
||||||
|
✅ POST /api/timers/time-entries/{id}/convert-to-billing # Convert to ledger
|
||||||
|
✅ GET /api/timers/templates/ # List timer templates
|
||||||
|
✅ POST /api/timers/templates/ # Create timer template
|
||||||
|
✅ GET /api/timers/statistics/ # Get timer statistics
|
||||||
|
✅ GET /api/timers/active/ # Get active timers
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Endpoints Needed**:
|
**Database Models Created**:
|
||||||
```
|
- `Timer` - Active timer sessions with start/stop/pause functionality
|
||||||
POST /api/timers/start
|
- `TimeEntry` - Completed time entries that can be billed
|
||||||
POST /api/timers/{id}/stop
|
- `TimerSession` - Individual timer sessions for detailed tracking
|
||||||
GET /api/timers/active
|
- `TimerTemplate` - Predefined templates for common tasks
|
||||||
POST /api/timers/{id}/create-entry
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🟡 5. Enhanced File Management
|
### 🟡 5. Enhanced File Management
|
||||||
|
|
||||||
**Legacy Feature**: Advanced file operations and status management
|
**Legacy Feature**: Advanced file operations and status management
|
||||||
|
|
||||||
**Current Status**: ⚠️ Basic file management exists
|
**Current Status**: ✅ **COMPLETED**
|
||||||
|
|
||||||
**Missing Components**:
|
**Implemented Components**:
|
||||||
|
|
||||||
#### 5.1 File Closure Automation
|
#### 5.1 File Closure Automation
|
||||||
- [ ] Automatic file closure workflow
|
- [x] Automatic file closure workflow with business rule validation
|
||||||
- [ ] Outstanding balance payment entry creation
|
- [x] Outstanding balance payment entry creation
|
||||||
- [ ] File closure validation and confirmations
|
- [x] File closure validation and confirmations
|
||||||
- [ ] File reopening capabilities
|
- [x] File reopening capabilities with status validation
|
||||||
|
|
||||||
#### 5.2 File Status Workflow
|
#### 5.2 File Status Workflow
|
||||||
- [ ] Enhanced file status definitions
|
- [x] Enhanced file status definitions with transition rules
|
||||||
- [ ] Status-based business rule enforcement
|
- [x] Status-based business rule enforcement
|
||||||
- [ ] Automatic status transitions
|
- [x] Status transition validation (NEW → ACTIVE → CLOSED → ARCHIVED)
|
||||||
- [ ] Status history tracking
|
- [x] Complete status history tracking with audit trail
|
||||||
|
|
||||||
#### 5.3 File Organization
|
#### 5.3 File Organization
|
||||||
- [ ] Archive file management
|
- [x] Archive file management with location tracking
|
||||||
- [ ] Bulk file status updates
|
- [x] Bulk file status updates for multiple files
|
||||||
- [ ] File transfer between attorneys
|
- [x] File transfer between attorneys with rate updates
|
||||||
- [ ] File merge capabilities
|
- [x] File transfer history and approval tracking
|
||||||
|
|
||||||
**API Endpoints Needed**:
|
**API Endpoints Implemented**:
|
||||||
```
|
```
|
||||||
POST /api/files/{id}/close
|
✅ POST /api/file-management/{file_no}/close
|
||||||
POST /api/files/{id}/reopen
|
✅ POST /api/file-management/{file_no}/reopen
|
||||||
POST /api/files/bulk-status-update
|
✅ POST /api/file-management/bulk-status-update
|
||||||
POST /api/files/{id}/transfer
|
✅ POST /api/file-management/{file_no}/transfer
|
||||||
|
✅ POST /api/file-management/{file_no}/archive
|
||||||
|
✅ POST /api/file-management/{file_no}/change-status
|
||||||
|
✅ GET /api/file-management/{file_no}/status-history
|
||||||
|
✅ GET /api/file-management/{file_no}/transfer-history
|
||||||
|
✅ GET /api/file-management/closure-candidates
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🟡 6. Advanced Printer Management
|
### 🟡 6. Advanced Printer Management
|
||||||
|
|||||||
Reference in New Issue
Block a user