maybe good
This commit is contained in:
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API package
|
||||
1432
app/api/admin.py
Normal file
1432
app/api/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
99
app/api/auth.py
Normal file
99
app/api/auth.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Authentication API endpoints
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models.user import User
|
||||
from app.auth.security import (
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
get_password_hash,
|
||||
get_current_user,
|
||||
get_admin_user
|
||||
)
|
||||
from app.auth.schemas import (
|
||||
Token,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
LoginRequest
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""Login endpoint"""
|
||||
user = authenticate_user(db, login_data.username, login_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user) # Only admins can create users
|
||||
):
|
||||
"""Register new user (admin only)"""
|
||||
# Check if username or email already exists
|
||||
existing_user = db.query(User).filter(
|
||||
(User.username == user_data.username) | (User.email == user_data.email)
|
||||
).first()
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username or email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
hashed_password=hashed_password
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user info"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
users = db.query(User).all()
|
||||
return users
|
||||
387
app/api/customers.py
Normal file
387
app/api/customers.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Customer (Rolodex) API endpoints
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_, func
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models.rolodex import Rolodex, Phone
|
||||
from app.models.user import User
|
||||
from app.auth.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas for request/response
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import date
|
||||
|
||||
|
||||
class PhoneCreate(BaseModel):
|
||||
location: Optional[str] = None
|
||||
phone: str
|
||||
|
||||
|
||||
class PhoneResponse(BaseModel):
|
||||
id: int
|
||||
location: Optional[str]
|
||||
phone: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CustomerBase(BaseModel):
|
||||
id: str
|
||||
last: str
|
||||
first: Optional[str] = None
|
||||
middle: Optional[str] = None
|
||||
prefix: Optional[str] = None
|
||||
suffix: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
group: Optional[str] = None
|
||||
a1: Optional[str] = None
|
||||
a2: Optional[str] = None
|
||||
a3: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
abrev: Optional[str] = None
|
||||
zip: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
dob: Optional[date] = None
|
||||
ss_number: Optional[str] = None
|
||||
legal_status: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerCreate(CustomerBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
last: Optional[str] = None
|
||||
first: Optional[str] = None
|
||||
middle: Optional[str] = None
|
||||
prefix: Optional[str] = None
|
||||
suffix: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
group: Optional[str] = None
|
||||
a1: Optional[str] = None
|
||||
a2: Optional[str] = None
|
||||
a3: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
abrev: Optional[str] = None
|
||||
zip: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
dob: Optional[date] = None
|
||||
ss_number: Optional[str] = None
|
||||
legal_status: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerResponse(CustomerBase):
|
||||
phone_numbers: List[PhoneResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/search/phone")
|
||||
async def search_by_phone(
|
||||
phone: str = Query(..., description="Phone number to search for"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Search customers by phone number (legacy phone search feature)"""
|
||||
phones = db.query(Phone).join(Rolodex).filter(
|
||||
Phone.phone.contains(phone)
|
||||
).options(joinedload(Phone.rolodex)).all()
|
||||
|
||||
results = []
|
||||
for phone_record in phones:
|
||||
results.append({
|
||||
"phone": phone_record.phone,
|
||||
"location": phone_record.location,
|
||||
"customer": {
|
||||
"id": phone_record.rolodex.id,
|
||||
"name": f"{phone_record.rolodex.first or ''} {phone_record.rolodex.last}".strip(),
|
||||
"city": phone_record.rolodex.city,
|
||||
"state": phone_record.rolodex.abrev
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def get_customer_groups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get list of customer groups for filtering"""
|
||||
groups = db.query(Rolodex.group).filter(
|
||||
Rolodex.group.isnot(None),
|
||||
Rolodex.group != ""
|
||||
).distinct().all()
|
||||
|
||||
return [{"group": group[0]} for group in groups if group[0]]
|
||||
|
||||
|
||||
@router.get("/states")
|
||||
async def get_states(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get list of states used in the database"""
|
||||
states = db.query(Rolodex.abrev).filter(
|
||||
Rolodex.abrev.isnot(None),
|
||||
Rolodex.abrev != ""
|
||||
).distinct().all()
|
||||
|
||||
return [{"state": state[0]} for state in states if state[0]]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_customer_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get customer database statistics"""
|
||||
total_customers = db.query(Rolodex).count()
|
||||
total_phones = db.query(Phone).count()
|
||||
customers_with_email = db.query(Rolodex).filter(
|
||||
Rolodex.email.isnot(None),
|
||||
Rolodex.email != ""
|
||||
).count()
|
||||
|
||||
# Group breakdown
|
||||
group_stats = db.query(Rolodex.group, func.count(Rolodex.id)).filter(
|
||||
Rolodex.group.isnot(None),
|
||||
Rolodex.group != ""
|
||||
).group_by(Rolodex.group).all()
|
||||
|
||||
return {
|
||||
"total_customers": total_customers,
|
||||
"total_phone_numbers": total_phones,
|
||||
"customers_with_email": customers_with_email,
|
||||
"group_breakdown": [{"group": group, "count": count} for group, count in group_stats]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CustomerResponse])
|
||||
async def list_customers(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List customers with pagination and search"""
|
||||
query = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Rolodex.id.contains(search),
|
||||
Rolodex.last.contains(search),
|
||||
Rolodex.first.contains(search),
|
||||
Rolodex.city.contains(search),
|
||||
Rolodex.email.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
customers = query.offset(skip).limit(limit).all()
|
||||
return customers
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerResponse)
|
||||
async def get_customer(
|
||||
customer_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get specific customer by ID"""
|
||||
customer = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers)).filter(
|
||||
Rolodex.id == customer_id
|
||||
).first()
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Customer not found"
|
||||
)
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
@router.post("/", response_model=CustomerResponse)
|
||||
async def create_customer(
|
||||
customer_data: CustomerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create new customer"""
|
||||
# Check if ID already exists
|
||||
existing = db.query(Rolodex).filter(Rolodex.id == customer_data.id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Customer ID already exists"
|
||||
)
|
||||
|
||||
customer = Rolodex(**customer_data.model_dump())
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerResponse)
|
||||
async def update_customer(
|
||||
customer_id: str,
|
||||
customer_data: CustomerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update customer"""
|
||||
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Customer not found"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in customer_data.model_dump(exclude_unset=True).items():
|
||||
setattr(customer, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
@router.delete("/{customer_id}")
|
||||
async def delete_customer(
|
||||
customer_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete customer"""
|
||||
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Customer not found"
|
||||
)
|
||||
|
||||
db.delete(customer)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Customer deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/phones", response_model=List[PhoneResponse])
|
||||
async def get_customer_phones(
|
||||
customer_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get customer phone numbers"""
|
||||
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Customer not found"
|
||||
)
|
||||
|
||||
phones = db.query(Phone).filter(Phone.rolodex_id == customer_id).all()
|
||||
return phones
|
||||
|
||||
|
||||
@router.post("/{customer_id}/phones", response_model=PhoneResponse)
|
||||
async def add_customer_phone(
|
||||
customer_id: str,
|
||||
phone_data: PhoneCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add phone number to customer"""
|
||||
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Customer not found"
|
||||
)
|
||||
|
||||
phone = Phone(
|
||||
rolodex_id=customer_id,
|
||||
location=phone_data.location,
|
||||
phone=phone_data.phone
|
||||
)
|
||||
|
||||
db.add(phone)
|
||||
db.commit()
|
||||
db.refresh(phone)
|
||||
|
||||
return phone
|
||||
|
||||
|
||||
@router.put("/{customer_id}/phones/{phone_id}", response_model=PhoneResponse)
|
||||
async def update_customer_phone(
|
||||
customer_id: str,
|
||||
phone_id: int,
|
||||
phone_data: PhoneCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update customer phone number"""
|
||||
phone = db.query(Phone).filter(
|
||||
Phone.id == phone_id,
|
||||
Phone.rolodex_id == customer_id
|
||||
).first()
|
||||
|
||||
if not phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Phone number not found"
|
||||
)
|
||||
|
||||
phone.location = phone_data.location
|
||||
phone.phone = phone_data.phone
|
||||
|
||||
db.commit()
|
||||
db.refresh(phone)
|
||||
|
||||
return phone
|
||||
|
||||
|
||||
@router.delete("/{customer_id}/phones/{phone_id}")
|
||||
async def delete_customer_phone(
|
||||
customer_id: str,
|
||||
phone_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete customer phone number"""
|
||||
phone = db.query(Phone).filter(
|
||||
Phone.id == phone_id,
|
||||
Phone.rolodex_id == customer_id
|
||||
).first()
|
||||
|
||||
if not phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Phone number not found"
|
||||
)
|
||||
|
||||
db.delete(phone)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Phone number deleted successfully"}
|
||||
665
app/api/documents.py
Normal file
665
app/api/documents.py
Normal file
@@ -0,0 +1,665 @@
|
||||
"""
|
||||
Document Management API endpoints - QDROs, Templates, and General Documents
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_, func, and_, desc, asc, text
|
||||
from datetime import date, datetime
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models.qdro import QDRO
|
||||
from app.models.files import File as FileModel
|
||||
from app.models.rolodex import Rolodex
|
||||
from app.models.lookups import FormIndex, FormList, Footer, Employee
|
||||
from app.models.user import User
|
||||
from app.auth.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class QDROBase(BaseModel):
|
||||
file_no: str
|
||||
version: str = "01"
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
status: str = "DRAFT"
|
||||
created_date: Optional[date] = None
|
||||
approved_date: Optional[date] = None
|
||||
filed_date: Optional[date] = None
|
||||
participant_name: Optional[str] = None
|
||||
spouse_name: Optional[str] = None
|
||||
plan_name: Optional[str] = None
|
||||
plan_administrator: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class QDROCreate(QDROBase):
|
||||
pass
|
||||
|
||||
|
||||
class QDROUpdate(BaseModel):
|
||||
version: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
created_date: Optional[date] = None
|
||||
approved_date: Optional[date] = None
|
||||
filed_date: Optional[date] = None
|
||||
participant_name: Optional[str] = None
|
||||
spouse_name: Optional[str] = None
|
||||
plan_name: Optional[str] = None
|
||||
plan_administrator: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class QDROResponse(QDROBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/qdros/{file_no}", response_model=List[QDROResponse])
|
||||
async def get_file_qdros(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get QDROs for specific file"""
|
||||
qdros = db.query(QDRO).filter(QDRO.file_no == file_no).order_by(QDRO.version).all()
|
||||
return qdros
|
||||
|
||||
|
||||
@router.get("/qdros/", response_model=List[QDROResponse])
|
||||
async def list_qdros(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
status_filter: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List all QDROs with filtering"""
|
||||
query = db.query(QDRO)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(QDRO.status == status_filter)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
QDRO.file_no.contains(search),
|
||||
QDRO.title.contains(search),
|
||||
QDRO.participant_name.contains(search),
|
||||
QDRO.spouse_name.contains(search),
|
||||
QDRO.plan_name.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
qdros = query.offset(skip).limit(limit).all()
|
||||
return qdros
|
||||
|
||||
|
||||
@router.post("/qdros/", response_model=QDROResponse)
|
||||
async def create_qdro(
|
||||
qdro_data: QDROCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create new QDRO"""
|
||||
qdro = QDRO(**qdro_data.model_dump())
|
||||
|
||||
if not qdro.created_date:
|
||||
qdro.created_date = date.today()
|
||||
|
||||
db.add(qdro)
|
||||
db.commit()
|
||||
db.refresh(qdro)
|
||||
|
||||
return qdro
|
||||
|
||||
|
||||
@router.get("/qdros/{file_no}/{qdro_id}", response_model=QDROResponse)
|
||||
async def get_qdro(
|
||||
file_no: str,
|
||||
qdro_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get specific QDRO"""
|
||||
qdro = db.query(QDRO).filter(
|
||||
QDRO.id == qdro_id,
|
||||
QDRO.file_no == file_no
|
||||
).first()
|
||||
|
||||
if not qdro:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="QDRO not found"
|
||||
)
|
||||
|
||||
return qdro
|
||||
|
||||
|
||||
@router.put("/qdros/{file_no}/{qdro_id}", response_model=QDROResponse)
|
||||
async def update_qdro(
|
||||
file_no: str,
|
||||
qdro_id: int,
|
||||
qdro_data: QDROUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update QDRO"""
|
||||
qdro = db.query(QDRO).filter(
|
||||
QDRO.id == qdro_id,
|
||||
QDRO.file_no == file_no
|
||||
).first()
|
||||
|
||||
if not qdro:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="QDRO not found"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in qdro_data.model_dump(exclude_unset=True).items():
|
||||
setattr(qdro, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(qdro)
|
||||
|
||||
return qdro
|
||||
|
||||
|
||||
@router.delete("/qdros/{file_no}/{qdro_id}")
|
||||
async def delete_qdro(
|
||||
file_no: str,
|
||||
qdro_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete QDRO"""
|
||||
qdro = db.query(QDRO).filter(
|
||||
QDRO.id == qdro_id,
|
||||
QDRO.file_no == file_no
|
||||
).first()
|
||||
|
||||
if not qdro:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="QDRO not found"
|
||||
)
|
||||
|
||||
db.delete(qdro)
|
||||
db.commit()
|
||||
|
||||
return {"message": "QDRO deleted successfully"}
|
||||
|
||||
|
||||
# Enhanced Document Management Endpoints
|
||||
|
||||
# Template Management Schemas
|
||||
class TemplateBase(BaseModel):
|
||||
"""Base template schema"""
|
||||
form_id: str
|
||||
form_name: str
|
||||
category: str = "GENERAL"
|
||||
content: str = ""
|
||||
variables: Optional[Dict[str, str]] = None
|
||||
|
||||
class TemplateCreate(TemplateBase):
|
||||
pass
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
form_name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
variables: Optional[Dict[str, str]] = None
|
||||
|
||||
class TemplateResponse(TemplateBase):
|
||||
active: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Document Generation Schema
|
||||
class DocumentGenerateRequest(BaseModel):
|
||||
"""Request to generate document from template"""
|
||||
template_id: str
|
||||
file_no: str
|
||||
output_format: str = "PDF" # PDF, DOCX, HTML
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
"""Generated document response"""
|
||||
document_id: str
|
||||
file_name: str
|
||||
file_path: str
|
||||
size: int
|
||||
created_at: datetime
|
||||
|
||||
# Document Statistics
|
||||
class DocumentStats(BaseModel):
|
||||
"""Document system statistics"""
|
||||
total_templates: int
|
||||
total_qdros: int
|
||||
templates_by_category: Dict[str, int]
|
||||
recent_activity: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@router.get("/templates/", response_model=List[TemplateResponse])
|
||||
async def list_templates(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
category: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
active_only: bool = Query(True),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List available document templates"""
|
||||
query = db.query(FormIndex)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(FormIndex.active == True)
|
||||
|
||||
if category:
|
||||
query = query.filter(FormIndex.category == category)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
FormIndex.form_name.contains(search),
|
||||
FormIndex.form_id.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
templates = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Enhanced response with template content
|
||||
results = []
|
||||
for template in templates:
|
||||
template_lines = db.query(FormList).filter(
|
||||
FormList.form_id == template.form_id
|
||||
).order_by(FormList.line_number).all()
|
||||
|
||||
content = "\n".join([line.content or "" for line in template_lines])
|
||||
|
||||
results.append({
|
||||
"form_id": template.form_id,
|
||||
"form_name": template.form_name,
|
||||
"category": template.category,
|
||||
"content": content,
|
||||
"active": template.active,
|
||||
"created_at": template.created_at,
|
||||
"variables": _extract_variables_from_content(content)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/templates/", response_model=TemplateResponse)
|
||||
async def create_template(
|
||||
template_data: TemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create new document template"""
|
||||
# Check if template already exists
|
||||
existing = db.query(FormIndex).filter(FormIndex.form_id == template_data.form_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Template with this ID already exists"
|
||||
)
|
||||
|
||||
# Create form index entry
|
||||
form_index = FormIndex(
|
||||
form_id=template_data.form_id,
|
||||
form_name=template_data.form_name,
|
||||
category=template_data.category,
|
||||
active=True
|
||||
)
|
||||
db.add(form_index)
|
||||
|
||||
# Create form content lines
|
||||
content_lines = template_data.content.split('\n')
|
||||
for i, line in enumerate(content_lines, 1):
|
||||
form_line = FormList(
|
||||
form_id=template_data.form_id,
|
||||
line_number=i,
|
||||
content=line
|
||||
)
|
||||
db.add(form_line)
|
||||
|
||||
db.commit()
|
||||
db.refresh(form_index)
|
||||
|
||||
return {
|
||||
"form_id": form_index.form_id,
|
||||
"form_name": form_index.form_name,
|
||||
"category": form_index.category,
|
||||
"content": template_data.content,
|
||||
"active": form_index.active,
|
||||
"created_at": form_index.created_at,
|
||||
"variables": template_data.variables or {}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get specific template with content"""
|
||||
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found"
|
||||
)
|
||||
|
||||
# Get template content
|
||||
template_lines = db.query(FormList).filter(
|
||||
FormList.form_id == template_id
|
||||
).order_by(FormList.line_number).all()
|
||||
|
||||
content = "\n".join([line.content or "" for line in template_lines])
|
||||
|
||||
return {
|
||||
"form_id": template.form_id,
|
||||
"form_name": template.form_name,
|
||||
"category": template.category,
|
||||
"content": content,
|
||||
"active": template.active,
|
||||
"created_at": template.created_at,
|
||||
"variables": _extract_variables_from_content(content)
|
||||
}
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def update_template(
|
||||
template_id: str,
|
||||
template_data: TemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update document template"""
|
||||
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found"
|
||||
)
|
||||
|
||||
# Update form index
|
||||
if template_data.form_name:
|
||||
template.form_name = template_data.form_name
|
||||
if template_data.category:
|
||||
template.category = template_data.category
|
||||
|
||||
# Update content if provided
|
||||
if template_data.content is not None:
|
||||
# Delete existing content lines
|
||||
db.query(FormList).filter(FormList.form_id == template_id).delete()
|
||||
|
||||
# Add new content lines
|
||||
content_lines = template_data.content.split('\n')
|
||||
for i, line in enumerate(content_lines, 1):
|
||||
form_line = FormList(
|
||||
form_id=template_id,
|
||||
line_number=i,
|
||||
content=line
|
||||
)
|
||||
db.add(form_line)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
# Get updated content
|
||||
template_lines = db.query(FormList).filter(
|
||||
FormList.form_id == template_id
|
||||
).order_by(FormList.line_number).all()
|
||||
|
||||
content = "\n".join([line.content or "" for line in template_lines])
|
||||
|
||||
return {
|
||||
"form_id": template.form_id,
|
||||
"form_name": template.form_name,
|
||||
"category": template.category,
|
||||
"content": content,
|
||||
"active": template.active,
|
||||
"created_at": template.created_at,
|
||||
"variables": _extract_variables_from_content(content)
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete document template"""
|
||||
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found"
|
||||
)
|
||||
|
||||
# Delete content lines
|
||||
db.query(FormList).filter(FormList.form_id == template_id).delete()
|
||||
|
||||
# Delete template
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Template deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/generate/{template_id}")
|
||||
async def generate_document(
|
||||
template_id: str,
|
||||
request: DocumentGenerateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Generate document from template"""
|
||||
# Get template
|
||||
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
# Get file information
|
||||
file_obj = db.query(FileModel).options(
|
||||
joinedload(FileModel.owner)
|
||||
).filter(FileModel.file_no == request.file_no).first()
|
||||
|
||||
if not file_obj:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Get template content
|
||||
template_lines = db.query(FormList).filter(
|
||||
FormList.form_id == template_id
|
||||
).order_by(FormList.line_number).all()
|
||||
|
||||
template_content = "\n".join([line.content or "" for line in template_lines])
|
||||
|
||||
# Prepare merge variables
|
||||
merge_vars = {
|
||||
"FILE_NO": file_obj.file_no,
|
||||
"CLIENT_FIRST": file_obj.owner.first if file_obj.owner else "",
|
||||
"CLIENT_LAST": file_obj.owner.last if file_obj.owner else "",
|
||||
"CLIENT_FULL": f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip() if file_obj.owner else "",
|
||||
"MATTER": file_obj.regarding or "",
|
||||
"OPENED": file_obj.opened.strftime("%B %d, %Y") if file_obj.opened else "",
|
||||
"ATTORNEY": file_obj.empl_num or "",
|
||||
"TODAY": date.today().strftime("%B %d, %Y")
|
||||
}
|
||||
|
||||
# Add any custom variables from the request
|
||||
if request.variables:
|
||||
merge_vars.update(request.variables)
|
||||
|
||||
# Perform variable substitution
|
||||
merged_content = _merge_template_variables(template_content, merge_vars)
|
||||
|
||||
# Generate document file
|
||||
document_id = str(uuid.uuid4())
|
||||
file_name = f"{template.form_name}_{file_obj.file_no}_{date.today().isoformat()}"
|
||||
|
||||
if request.output_format.upper() == "PDF":
|
||||
file_path = f"/app/exports/{document_id}.pdf"
|
||||
file_name += ".pdf"
|
||||
# Here you would implement PDF generation
|
||||
# For now, create a simple text file
|
||||
with open(f"/app/exports/{document_id}.txt", "w") as f:
|
||||
f.write(merged_content)
|
||||
file_path = f"/app/exports/{document_id}.txt"
|
||||
elif request.output_format.upper() == "DOCX":
|
||||
file_path = f"/app/exports/{document_id}.docx"
|
||||
file_name += ".docx"
|
||||
# Implement DOCX generation
|
||||
with open(f"/app/exports/{document_id}.txt", "w") as f:
|
||||
f.write(merged_content)
|
||||
file_path = f"/app/exports/{document_id}.txt"
|
||||
else: # HTML
|
||||
file_path = f"/app/exports/{document_id}.html"
|
||||
file_name += ".html"
|
||||
html_content = f"<html><body><pre>{merged_content}</pre></body></html>"
|
||||
with open(file_path, "w") as f:
|
||||
f.write(html_content)
|
||||
|
||||
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"file_name": file_name,
|
||||
"file_path": file_path,
|
||||
"size": file_size,
|
||||
"created_at": datetime.now()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/categories/")
|
||||
async def get_template_categories(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available template categories"""
|
||||
categories = db.query(FormIndex.category).distinct().all()
|
||||
return [cat[0] for cat in categories if cat[0]]
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_document_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get document system statistics"""
|
||||
# Template statistics
|
||||
total_templates = db.query(FormIndex).filter(FormIndex.active == True).count()
|
||||
total_qdros = db.query(QDRO).count()
|
||||
|
||||
# Templates by category
|
||||
category_stats = db.query(
|
||||
FormIndex.category,
|
||||
func.count(FormIndex.form_id)
|
||||
).filter(FormIndex.active == True).group_by(FormIndex.category).all()
|
||||
|
||||
categories_dict = {cat[0] or "Uncategorized": cat[1] for cat in category_stats}
|
||||
|
||||
# Recent QDRO activity
|
||||
recent_qdros = db.query(QDRO).order_by(desc(QDRO.updated_at)).limit(5).all()
|
||||
recent_activity = [
|
||||
{
|
||||
"type": "QDRO",
|
||||
"file_no": qdro.file_no,
|
||||
"status": qdro.status,
|
||||
"updated_at": qdro.updated_at.isoformat() if qdro.updated_at else None
|
||||
}
|
||||
for qdro in recent_qdros
|
||||
]
|
||||
|
||||
return {
|
||||
"total_templates": total_templates,
|
||||
"total_qdros": total_qdros,
|
||||
"templates_by_category": categories_dict,
|
||||
"recent_activity": recent_activity
|
||||
}
|
||||
|
||||
|
||||
@router.get("/file/{file_no}/documents")
|
||||
async def get_file_documents(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all documents associated with a specific file"""
|
||||
# Get QDROs for this file
|
||||
qdros = db.query(QDRO).filter(QDRO.file_no == file_no).order_by(desc(QDRO.updated_at)).all()
|
||||
|
||||
# Format response
|
||||
documents = [
|
||||
{
|
||||
"id": qdro.id,
|
||||
"type": "QDRO",
|
||||
"title": f"QDRO v{qdro.version}",
|
||||
"status": qdro.status,
|
||||
"created_date": qdro.created_date.isoformat() if qdro.created_date else None,
|
||||
"updated_at": qdro.updated_at.isoformat() if qdro.updated_at else None,
|
||||
"file_no": qdro.file_no
|
||||
}
|
||||
for qdro in qdros
|
||||
]
|
||||
|
||||
return {
|
||||
"file_no": file_no,
|
||||
"documents": documents,
|
||||
"total_count": len(documents)
|
||||
}
|
||||
|
||||
|
||||
def _extract_variables_from_content(content: str) -> Dict[str, str]:
|
||||
"""Extract variable placeholders from template content"""
|
||||
import re
|
||||
variables = {}
|
||||
|
||||
# Find variables in format {{VARIABLE_NAME}}
|
||||
matches = re.findall(r'\{\{([^}]+)\}\}', content)
|
||||
for match in matches:
|
||||
var_name = match.strip()
|
||||
variables[var_name] = f"Placeholder for {var_name}"
|
||||
|
||||
# Find variables in format ^VARIABLE
|
||||
matches = re.findall(r'\^([A-Z_]+)', content)
|
||||
for match in matches:
|
||||
variables[match] = f"Placeholder for {match}"
|
||||
|
||||
return variables
|
||||
|
||||
|
||||
def _merge_template_variables(content: str, variables: Dict[str, Any]) -> str:
|
||||
"""Replace template variables with actual values"""
|
||||
merged = content
|
||||
|
||||
# Replace {{VARIABLE}} format
|
||||
for var_name, value in variables.items():
|
||||
merged = merged.replace(f"{{{{{var_name}}}}}", str(value or ""))
|
||||
merged = merged.replace(f"^{var_name}", str(value or ""))
|
||||
|
||||
return merged
|
||||
493
app/api/files.py
Normal file
493
app/api/files.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
File Management API endpoints
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_, func, and_, desc
|
||||
from datetime import date, datetime
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models.files import File
|
||||
from app.models.rolodex import Rolodex
|
||||
from app.models.ledger import Ledger
|
||||
from app.models.lookups import Employee, FileType, FileStatus
|
||||
from app.models.user import User
|
||||
from app.auth.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FileBase(BaseModel):
|
||||
file_no: str
|
||||
id: str # Rolodex ID (file owner)
|
||||
regarding: Optional[str] = None
|
||||
empl_num: str
|
||||
file_type: str
|
||||
opened: date
|
||||
closed: Optional[date] = None
|
||||
status: str
|
||||
footer_code: Optional[str] = None
|
||||
opposing: Optional[str] = None
|
||||
rate_per_hour: float
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class FileCreate(FileBase):
|
||||
pass
|
||||
|
||||
|
||||
class FileUpdate(BaseModel):
|
||||
id: Optional[str] = None
|
||||
regarding: Optional[str] = None
|
||||
empl_num: Optional[str] = None
|
||||
file_type: Optional[str] = None
|
||||
opened: Optional[date] = None
|
||||
closed: Optional[date] = None
|
||||
status: Optional[str] = None
|
||||
footer_code: Optional[str] = None
|
||||
opposing: Optional[str] = None
|
||||
rate_per_hour: Optional[float] = None
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class FileResponse(FileBase):
|
||||
# Financial balances
|
||||
trust_bal: float = 0.0
|
||||
hours: float = 0.0
|
||||
hourly_fees: float = 0.0
|
||||
flat_fees: float = 0.0
|
||||
disbursements: float = 0.0
|
||||
credit_bal: float = 0.0
|
||||
total_charges: float = 0.0
|
||||
amount_owing: float = 0.0
|
||||
transferable: float = 0.0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[FileResponse])
|
||||
async def list_files(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None),
|
||||
employee_filter: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List files with pagination and filtering"""
|
||||
query = db.query(File)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
File.file_no.contains(search),
|
||||
File.id.contains(search),
|
||||
File.regarding.contains(search),
|
||||
File.file_type.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(File.status == status_filter)
|
||||
|
||||
if employee_filter:
|
||||
query = query.filter(File.empl_num == employee_filter)
|
||||
|
||||
files = query.offset(skip).limit(limit).all()
|
||||
return files
|
||||
|
||||
|
||||
@router.get("/{file_no}", response_model=FileResponse)
|
||||
async def get_file(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get specific file by file number"""
|
||||
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"
|
||||
)
|
||||
|
||||
return file_obj
|
||||
|
||||
|
||||
@router.post("/", response_model=FileResponse)
|
||||
async def create_file(
|
||||
file_data: FileCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create new file"""
|
||||
# Check if file number already exists
|
||||
existing = db.query(File).filter(File.file_no == file_data.file_no).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File number already exists"
|
||||
)
|
||||
|
||||
file_obj = File(**file_data.model_dump())
|
||||
db.add(file_obj)
|
||||
db.commit()
|
||||
db.refresh(file_obj)
|
||||
|
||||
return file_obj
|
||||
|
||||
|
||||
@router.put("/{file_no}", response_model=FileResponse)
|
||||
async def update_file(
|
||||
file_no: str,
|
||||
file_data: FileUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update 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"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in file_data.model_dump(exclude_unset=True).items():
|
||||
setattr(file_obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(file_obj)
|
||||
|
||||
return file_obj
|
||||
|
||||
|
||||
@router.delete("/{file_no}")
|
||||
async def delete_file(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete 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"
|
||||
)
|
||||
|
||||
db.delete(file_obj)
|
||||
db.commit()
|
||||
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_file_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get file statistics and summary"""
|
||||
total_files = db.query(File).count()
|
||||
active_files = db.query(File).filter(File.status == "ACTIVE").count()
|
||||
|
||||
# Get status breakdown
|
||||
status_stats = db.query(
|
||||
File.status,
|
||||
func.count(File.file_no).label('count')
|
||||
).group_by(File.status).all()
|
||||
|
||||
# Get file type breakdown
|
||||
type_stats = db.query(
|
||||
File.file_type,
|
||||
func.count(File.file_no).label('count')
|
||||
).group_by(File.file_type).all()
|
||||
|
||||
# Get employee breakdown
|
||||
employee_stats = db.query(
|
||||
File.empl_num,
|
||||
func.count(File.file_no).label('count')
|
||||
).group_by(File.empl_num).all()
|
||||
|
||||
# Financial summary
|
||||
financial_summary = db.query(
|
||||
func.sum(File.total_charges).label('total_charges'),
|
||||
func.sum(File.amount_owing).label('total_owing'),
|
||||
func.sum(File.trust_bal).label('total_trust'),
|
||||
func.sum(File.hours).label('total_hours')
|
||||
).first()
|
||||
|
||||
return {
|
||||
"total_files": total_files,
|
||||
"active_files": active_files,
|
||||
"status_breakdown": [{"status": s[0], "count": s[1]} for s in status_stats],
|
||||
"type_breakdown": [{"type": t[0], "count": t[1]} for t in type_stats],
|
||||
"employee_breakdown": [{"employee": e[0], "count": e[1]} for e in employee_stats],
|
||||
"financial_summary": {
|
||||
"total_charges": float(financial_summary.total_charges or 0),
|
||||
"total_owing": float(financial_summary.total_owing or 0),
|
||||
"total_trust": float(financial_summary.total_trust or 0),
|
||||
"total_hours": float(financial_summary.total_hours or 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lookups/file-types")
|
||||
async def get_file_types(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available file types"""
|
||||
file_types = db.query(FileType).filter(FileType.active == True).all()
|
||||
return [{"code": ft.type_code, "description": ft.description} for ft in file_types]
|
||||
|
||||
|
||||
@router.get("/lookups/file-statuses")
|
||||
async def get_file_statuses(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available file statuses"""
|
||||
statuses = db.query(FileStatus).filter(FileStatus.active == True).order_by(FileStatus.sort_order).all()
|
||||
return [{"code": s.status_code, "description": s.description} for s in statuses]
|
||||
|
||||
|
||||
@router.get("/lookups/employees")
|
||||
async def get_employees(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get active employees"""
|
||||
employees = db.query(Employee).filter(Employee.active == True).all()
|
||||
return [{"code": e.empl_num, "name": f"{e.first_name} {e.last_name}"} for e in employees]
|
||||
|
||||
|
||||
@router.get("/{file_no}/financial-summary")
|
||||
async def get_file_financial_summary(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get detailed financial summary 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"
|
||||
)
|
||||
|
||||
# Get recent ledger entries
|
||||
recent_entries = db.query(Ledger)\
|
||||
.filter(Ledger.file_no == file_no)\
|
||||
.order_by(desc(Ledger.date))\
|
||||
.limit(10)\
|
||||
.all()
|
||||
|
||||
# Get unbilled entries
|
||||
unbilled_entries = db.query(Ledger)\
|
||||
.filter(and_(Ledger.file_no == file_no, Ledger.billed == "N"))\
|
||||
.all()
|
||||
|
||||
unbilled_total = sum(entry.amount for entry in unbilled_entries)
|
||||
|
||||
return {
|
||||
"file_no": file_no,
|
||||
"financial_data": {
|
||||
"trust_balance": file_obj.trust_bal,
|
||||
"hours_total": file_obj.hours,
|
||||
"hourly_fees": file_obj.hourly_fees,
|
||||
"flat_fees": file_obj.flat_fees,
|
||||
"disbursements": file_obj.disbursements,
|
||||
"total_charges": file_obj.total_charges,
|
||||
"amount_owing": file_obj.amount_owing,
|
||||
"credit_balance": file_obj.credit_bal,
|
||||
"transferable": file_obj.transferable,
|
||||
"unbilled_amount": unbilled_total
|
||||
},
|
||||
"recent_entries": [
|
||||
{
|
||||
"date": entry.date.isoformat() if entry.date else None,
|
||||
"amount": entry.amount,
|
||||
"description": entry.note,
|
||||
"billed": entry.billed == "Y"
|
||||
} for entry in recent_entries
|
||||
],
|
||||
"unbilled_count": len(unbilled_entries)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{file_no}/client-info")
|
||||
async def get_file_client_info(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get client information for a file"""
|
||||
file_obj = db.query(File)\
|
||||
.options(joinedload(File.owner))\
|
||||
.filter(File.file_no == file_no)\
|
||||
.first()
|
||||
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
client = file_obj.owner
|
||||
if not client:
|
||||
return {"file_no": file_no, "client": None}
|
||||
|
||||
return {
|
||||
"file_no": file_no,
|
||||
"client": {
|
||||
"id": client.id,
|
||||
"name": f"{client.first or ''} {client.last}".strip(),
|
||||
"email": client.email,
|
||||
"city": client.city,
|
||||
"state": client.abrev,
|
||||
"group": client.group
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{file_no}/close")
|
||||
async def close_file(
|
||||
file_no: str,
|
||||
close_date: Optional[date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Close 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"
|
||||
)
|
||||
|
||||
file_obj.closed = close_date or date.today()
|
||||
file_obj.status = "CLOSED"
|
||||
|
||||
db.commit()
|
||||
db.refresh(file_obj)
|
||||
|
||||
return {"message": f"File {file_no} closed successfully", "closed_date": file_obj.closed}
|
||||
|
||||
|
||||
@router.post("/{file_no}/reopen")
|
||||
async def reopen_file(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Reopen a closed 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"
|
||||
)
|
||||
|
||||
file_obj.closed = None
|
||||
file_obj.status = "ACTIVE"
|
||||
|
||||
db.commit()
|
||||
db.refresh(file_obj)
|
||||
|
||||
return {"message": f"File {file_no} reopened successfully"}
|
||||
|
||||
|
||||
@router.get("/search/advanced")
|
||||
async def advanced_file_search(
|
||||
file_no: Optional[str] = Query(None),
|
||||
client_name: Optional[str] = Query(None),
|
||||
regarding: Optional[str] = Query(None),
|
||||
file_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
employee: Optional[str] = Query(None),
|
||||
opened_after: Optional[date] = Query(None),
|
||||
opened_before: Optional[date] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Advanced file search with multiple criteria"""
|
||||
query = db.query(File).options(joinedload(File.owner))
|
||||
|
||||
if file_no:
|
||||
query = query.filter(File.file_no.contains(file_no))
|
||||
|
||||
if client_name:
|
||||
query = query.join(Rolodex).filter(
|
||||
or_(
|
||||
Rolodex.first.contains(client_name),
|
||||
Rolodex.last.contains(client_name),
|
||||
func.concat(Rolodex.first, ' ', Rolodex.last).contains(client_name)
|
||||
)
|
||||
)
|
||||
|
||||
if regarding:
|
||||
query = query.filter(File.regarding.contains(regarding))
|
||||
|
||||
if file_type:
|
||||
query = query.filter(File.file_type == file_type)
|
||||
|
||||
if status:
|
||||
query = query.filter(File.status == status)
|
||||
|
||||
if employee:
|
||||
query = query.filter(File.empl_num == employee)
|
||||
|
||||
if opened_after:
|
||||
query = query.filter(File.opened >= opened_after)
|
||||
|
||||
if opened_before:
|
||||
query = query.filter(File.opened <= opened_before)
|
||||
|
||||
# Get total count for pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
files = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Format results with client names
|
||||
results = []
|
||||
for file_obj in files:
|
||||
client = file_obj.owner
|
||||
client_name = f"{client.first or ''} {client.last}".strip() if client else "Unknown"
|
||||
|
||||
results.append({
|
||||
"file_no": file_obj.file_no,
|
||||
"client_id": file_obj.id,
|
||||
"client_name": client_name,
|
||||
"regarding": file_obj.regarding,
|
||||
"file_type": file_obj.file_type,
|
||||
"status": file_obj.status,
|
||||
"employee": file_obj.empl_num,
|
||||
"opened": file_obj.opened.isoformat() if file_obj.opened else None,
|
||||
"closed": file_obj.closed.isoformat() if file_obj.closed else None,
|
||||
"amount_owing": file_obj.amount_owing,
|
||||
"total_charges": file_obj.total_charges
|
||||
})
|
||||
|
||||
return {
|
||||
"files": results,
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
863
app/api/financial.py
Normal file
863
app/api/financial.py
Normal file
@@ -0,0 +1,863 @@
|
||||
"""
|
||||
Financial/Ledger API endpoints
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_, func, and_, desc, asc, text
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from app.database.base import get_db
|
||||
from app.models.ledger import Ledger
|
||||
from app.models.files import File
|
||||
from app.models.rolodex import Rolodex
|
||||
from app.models.lookups import Employee, TransactionType, TransactionCode
|
||||
from app.models.user import User
|
||||
from app.auth.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LedgerBase(BaseModel):
|
||||
file_no: str
|
||||
date: date
|
||||
t_code: str
|
||||
t_type: str
|
||||
t_type_l: Optional[str] = None
|
||||
empl_num: str
|
||||
quantity: float = 0.0
|
||||
rate: float = 0.0
|
||||
amount: float
|
||||
billed: str = "N"
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class LedgerCreate(LedgerBase):
|
||||
pass
|
||||
|
||||
|
||||
class LedgerUpdate(BaseModel):
|
||||
date: Optional[date] = None
|
||||
t_code: Optional[str] = None
|
||||
t_type: Optional[str] = None
|
||||
t_type_l: Optional[str] = None
|
||||
empl_num: Optional[str] = None
|
||||
quantity: Optional[float] = None
|
||||
rate: Optional[float] = None
|
||||
amount: Optional[float] = None
|
||||
billed: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class LedgerResponse(LedgerBase):
|
||||
id: int
|
||||
item_no: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FinancialSummary(BaseModel):
|
||||
"""Financial summary for a file"""
|
||||
file_no: str
|
||||
total_hours: float
|
||||
total_hourly_fees: float
|
||||
total_flat_fees: float
|
||||
total_disbursements: float
|
||||
total_credits: float
|
||||
total_charges: float
|
||||
amount_owing: float
|
||||
unbilled_amount: float
|
||||
billed_amount: float
|
||||
|
||||
|
||||
@router.get("/ledger/{file_no}", response_model=List[LedgerResponse])
|
||||
async def get_file_ledger(
|
||||
file_no: str,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
billed_only: Optional[bool] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get ledger entries for specific file"""
|
||||
query = db.query(Ledger).filter(Ledger.file_no == file_no).order_by(Ledger.date.desc())
|
||||
|
||||
if billed_only is not None:
|
||||
billed_filter = "Y" if billed_only else "N"
|
||||
query = query.filter(Ledger.billed == billed_filter)
|
||||
|
||||
entries = query.offset(skip).limit(limit).all()
|
||||
return entries
|
||||
|
||||
|
||||
@router.post("/ledger/", response_model=LedgerResponse)
|
||||
async def create_ledger_entry(
|
||||
entry_data: LedgerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create new ledger entry"""
|
||||
# Verify file exists
|
||||
file_obj = db.query(File).filter(File.file_no == entry_data.file_no).first()
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Get next item number for this file
|
||||
max_item = db.query(func.max(Ledger.item_no)).filter(
|
||||
Ledger.file_no == entry_data.file_no
|
||||
).scalar() or 0
|
||||
|
||||
entry = Ledger(
|
||||
**entry_data.model_dump(),
|
||||
item_no=max_item + 1
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
# Update file balances (simplified version)
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@router.put("/ledger/{entry_id}", response_model=LedgerResponse)
|
||||
async def update_ledger_entry(
|
||||
entry_id: int,
|
||||
entry_data: LedgerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update ledger entry"""
|
||||
entry = db.query(Ledger).filter(Ledger.id == entry_id).first()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ledger entry not found"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in entry_data.model_dump(exclude_unset=True).items():
|
||||
setattr(entry, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
# Update file balances
|
||||
file_obj = db.query(File).filter(File.file_no == entry.file_no).first()
|
||||
if file_obj:
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/ledger/{entry_id}")
|
||||
async def delete_ledger_entry(
|
||||
entry_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete ledger entry"""
|
||||
entry = db.query(Ledger).filter(Ledger.id == entry_id).first()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ledger entry not found"
|
||||
)
|
||||
|
||||
file_no = entry.file_no
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
|
||||
# Update file balances
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if file_obj:
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return {"message": "Ledger entry deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/reports/{file_no}", response_model=FinancialSummary)
|
||||
async def get_financial_report(
|
||||
file_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get financial summary report for 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"
|
||||
)
|
||||
|
||||
# Calculate totals from ledger entries
|
||||
ledger_entries = db.query(Ledger).filter(Ledger.file_no == file_no).all()
|
||||
|
||||
total_hours = 0.0
|
||||
total_hourly_fees = 0.0
|
||||
total_flat_fees = 0.0
|
||||
total_disbursements = 0.0
|
||||
total_credits = 0.0
|
||||
unbilled_amount = 0.0
|
||||
billed_amount = 0.0
|
||||
|
||||
for entry in ledger_entries:
|
||||
if entry.t_type == "2": # Hourly fees
|
||||
total_hours += entry.quantity
|
||||
total_hourly_fees += entry.amount
|
||||
elif entry.t_type == "3": # Flat fees
|
||||
total_flat_fees += entry.amount
|
||||
elif entry.t_type == "4": # Disbursements
|
||||
total_disbursements += entry.amount
|
||||
elif entry.t_type == "5": # Credits
|
||||
total_credits += entry.amount
|
||||
|
||||
if entry.billed == "Y":
|
||||
billed_amount += entry.amount
|
||||
else:
|
||||
unbilled_amount += entry.amount
|
||||
|
||||
total_charges = total_hourly_fees + total_flat_fees + total_disbursements
|
||||
amount_owing = total_charges - total_credits
|
||||
|
||||
return FinancialSummary(
|
||||
file_no=file_no,
|
||||
total_hours=total_hours,
|
||||
total_hourly_fees=total_hourly_fees,
|
||||
total_flat_fees=total_flat_fees,
|
||||
total_disbursements=total_disbursements,
|
||||
total_credits=total_credits,
|
||||
total_charges=total_charges,
|
||||
amount_owing=amount_owing,
|
||||
unbilled_amount=unbilled_amount,
|
||||
billed_amount=billed_amount
|
||||
)
|
||||
|
||||
|
||||
async def _update_file_balances(file_obj: File, db: Session):
|
||||
"""Update file balance totals (simplified version of Tally_Ledger)"""
|
||||
ledger_entries = db.query(Ledger).filter(Ledger.file_no == file_obj.file_no).all()
|
||||
|
||||
# Reset balances
|
||||
file_obj.trust_bal = 0.0
|
||||
file_obj.hours = 0.0
|
||||
file_obj.hourly_fees = 0.0
|
||||
file_obj.flat_fees = 0.0
|
||||
file_obj.disbursements = 0.0
|
||||
file_obj.credit_bal = 0.0
|
||||
|
||||
# Calculate totals
|
||||
for entry in ledger_entries:
|
||||
if entry.t_type == "1": # Trust
|
||||
file_obj.trust_bal += entry.amount
|
||||
elif entry.t_type == "2": # Hourly fees
|
||||
file_obj.hours += entry.quantity
|
||||
file_obj.hourly_fees += entry.amount
|
||||
elif entry.t_type == "3": # Flat fees
|
||||
file_obj.flat_fees += entry.amount
|
||||
elif entry.t_type == "4": # Disbursements
|
||||
file_obj.disbursements += entry.amount
|
||||
elif entry.t_type == "5": # Credits
|
||||
file_obj.credit_bal += entry.amount
|
||||
|
||||
file_obj.total_charges = file_obj.hourly_fees + file_obj.flat_fees + file_obj.disbursements
|
||||
file_obj.amount_owing = file_obj.total_charges - file_obj.credit_bal
|
||||
|
||||
# Calculate transferable amount
|
||||
if file_obj.amount_owing > 0 and file_obj.trust_bal > 0:
|
||||
if file_obj.trust_bal >= file_obj.amount_owing:
|
||||
file_obj.transferable = file_obj.amount_owing
|
||||
else:
|
||||
file_obj.transferable = file_obj.trust_bal
|
||||
else:
|
||||
file_obj.transferable = 0.0
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
# Additional Financial Management Endpoints
|
||||
|
||||
@router.get("/time-entries/recent")
|
||||
async def get_recent_time_entries(
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
employee: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent time entries across all files"""
|
||||
cutoff_date = date.today() - timedelta(days=days)
|
||||
|
||||
query = db.query(Ledger)\
|
||||
.options(joinedload(Ledger.file).joinedload(File.owner))\
|
||||
.filter(and_(
|
||||
Ledger.date >= cutoff_date,
|
||||
Ledger.t_type == "2" # Time entries
|
||||
))\
|
||||
.order_by(desc(Ledger.date))
|
||||
|
||||
if employee:
|
||||
query = query.filter(Ledger.empl_num == employee)
|
||||
|
||||
entries = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Format results with file and client information
|
||||
results = []
|
||||
for entry in entries:
|
||||
file_obj = entry.file
|
||||
client = file_obj.owner if file_obj else None
|
||||
|
||||
results.append({
|
||||
"id": entry.id,
|
||||
"date": entry.date.isoformat(),
|
||||
"file_no": entry.file_no,
|
||||
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
|
||||
"matter": file_obj.regarding if file_obj else "",
|
||||
"employee": entry.empl_num,
|
||||
"hours": entry.quantity,
|
||||
"rate": entry.rate,
|
||||
"amount": entry.amount,
|
||||
"description": entry.note,
|
||||
"billed": entry.billed == "Y"
|
||||
})
|
||||
|
||||
return {"entries": results, "total_entries": len(results)}
|
||||
|
||||
|
||||
@router.post("/time-entry/quick")
|
||||
async def create_quick_time_entry(
|
||||
file_no: str,
|
||||
hours: float,
|
||||
description: str,
|
||||
entry_date: Optional[date] = None,
|
||||
employee: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Quick time entry creation"""
|
||||
# Verify file exists and get default rate
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if not file_obj:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Use file's default rate and employee if not provided
|
||||
rate = file_obj.rate_per_hour
|
||||
empl_num = employee or file_obj.empl_num
|
||||
entry_date = entry_date or date.today()
|
||||
|
||||
# Get next item number
|
||||
max_item = db.query(func.max(Ledger.item_no)).filter(
|
||||
Ledger.file_no == file_no
|
||||
).scalar() or 0
|
||||
|
||||
# Create time entry
|
||||
entry = Ledger(
|
||||
file_no=file_no,
|
||||
item_no=max_item + 1,
|
||||
date=entry_date,
|
||||
t_code="TIME",
|
||||
t_type="2",
|
||||
t_type_l="D",
|
||||
empl_num=empl_num,
|
||||
quantity=hours,
|
||||
rate=rate,
|
||||
amount=hours * rate,
|
||||
billed="N",
|
||||
note=description
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
# Update file balances
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return {
|
||||
"id": entry.id,
|
||||
"message": f"Time entry created: {hours} hours @ ${rate}/hr = ${entry.amount}",
|
||||
"entry": {
|
||||
"date": entry.date.isoformat(),
|
||||
"hours": hours,
|
||||
"rate": rate,
|
||||
"amount": entry.amount,
|
||||
"description": description
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unbilled-entries")
|
||||
async def get_unbilled_entries(
|
||||
file_no: Optional[str] = Query(None),
|
||||
employee: Optional[str] = Query(None),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all unbilled entries for billing preparation"""
|
||||
query = db.query(Ledger)\
|
||||
.options(joinedload(Ledger.file).joinedload(File.owner))\
|
||||
.filter(Ledger.billed == "N")\
|
||||
.order_by(Ledger.file_no, Ledger.date)
|
||||
|
||||
if file_no:
|
||||
query = query.filter(Ledger.file_no == file_no)
|
||||
|
||||
if employee:
|
||||
query = query.filter(Ledger.empl_num == employee)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(Ledger.date >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(Ledger.date <= end_date)
|
||||
|
||||
entries = query.all()
|
||||
|
||||
# Group by file for easier billing
|
||||
files_data = {}
|
||||
total_unbilled = 0.0
|
||||
|
||||
for entry in entries:
|
||||
file_no = entry.file_no
|
||||
if file_no not in files_data:
|
||||
file_obj = entry.file
|
||||
client = file_obj.owner if file_obj else None
|
||||
files_data[file_no] = {
|
||||
"file_no": file_no,
|
||||
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
|
||||
"client_id": file_obj.id if file_obj else "",
|
||||
"matter": file_obj.regarding if file_obj else "",
|
||||
"entries": [],
|
||||
"total_amount": 0.0,
|
||||
"total_hours": 0.0
|
||||
}
|
||||
|
||||
entry_data = {
|
||||
"id": entry.id,
|
||||
"date": entry.date.isoformat(),
|
||||
"type": entry.t_code,
|
||||
"employee": entry.empl_num,
|
||||
"description": entry.note,
|
||||
"quantity": entry.quantity,
|
||||
"rate": entry.rate,
|
||||
"amount": entry.amount
|
||||
}
|
||||
|
||||
files_data[file_no]["entries"].append(entry_data)
|
||||
files_data[file_no]["total_amount"] += entry.amount
|
||||
if entry.t_type == "2": # Time entries
|
||||
files_data[file_no]["total_hours"] += entry.quantity
|
||||
|
||||
total_unbilled += entry.amount
|
||||
|
||||
return {
|
||||
"files": list(files_data.values()),
|
||||
"total_unbilled_amount": total_unbilled,
|
||||
"total_files": len(files_data)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bill-entries")
|
||||
async def mark_entries_as_billed(
|
||||
entry_ids: List[int],
|
||||
bill_date: Optional[date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Mark multiple entries as billed"""
|
||||
bill_date = bill_date or date.today()
|
||||
|
||||
# Get entries to bill
|
||||
entries = db.query(Ledger).filter(Ledger.id.in_(entry_ids)).all()
|
||||
|
||||
if not entries:
|
||||
raise HTTPException(status_code=404, detail="No entries found")
|
||||
|
||||
# Update entries to billed status
|
||||
billed_amount = 0.0
|
||||
affected_files = set()
|
||||
|
||||
for entry in entries:
|
||||
entry.billed = "Y"
|
||||
billed_amount += entry.amount
|
||||
affected_files.add(entry.file_no)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Update file balances for affected files
|
||||
for file_no in affected_files:
|
||||
file_obj = db.query(File).filter(File.file_no == file_no).first()
|
||||
if file_obj:
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return {
|
||||
"message": f"Marked {len(entries)} entries as billed",
|
||||
"billed_amount": billed_amount,
|
||||
"bill_date": bill_date.isoformat(),
|
||||
"affected_files": list(affected_files)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/financial-dashboard")
|
||||
async def get_financial_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get financial dashboard summary"""
|
||||
# Total financial metrics
|
||||
total_charges = db.query(func.sum(File.total_charges)).scalar() or 0
|
||||
total_owing = db.query(func.sum(File.amount_owing)).scalar() or 0
|
||||
total_trust = db.query(func.sum(File.trust_bal)).scalar() or 0
|
||||
total_hours = db.query(func.sum(File.hours)).scalar() or 0
|
||||
|
||||
# Unbilled amounts
|
||||
unbilled_total = db.query(func.sum(Ledger.amount))\
|
||||
.filter(Ledger.billed == "N").scalar() or 0
|
||||
|
||||
# Recent activity (last 30 days)
|
||||
thirty_days_ago = date.today() - timedelta(days=30)
|
||||
recent_entries = db.query(func.count(Ledger.id))\
|
||||
.filter(Ledger.date >= thirty_days_ago).scalar() or 0
|
||||
|
||||
recent_amount = db.query(func.sum(Ledger.amount))\
|
||||
.filter(Ledger.date >= thirty_days_ago).scalar() or 0
|
||||
|
||||
# Top files by balance
|
||||
top_files = db.query(File.file_no, File.amount_owing, File.total_charges)\
|
||||
.filter(File.amount_owing > 0)\
|
||||
.order_by(desc(File.amount_owing))\
|
||||
.limit(10).all()
|
||||
|
||||
# Employee activity
|
||||
employee_stats = db.query(
|
||||
Ledger.empl_num,
|
||||
func.sum(Ledger.quantity).label('total_hours'),
|
||||
func.sum(Ledger.amount).label('total_amount'),
|
||||
func.count(Ledger.id).label('entry_count')
|
||||
).filter(
|
||||
and_(
|
||||
Ledger.date >= thirty_days_ago,
|
||||
Ledger.t_type == "2" # Time entries only
|
||||
)
|
||||
).group_by(Ledger.empl_num).all()
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"total_charges": float(total_charges),
|
||||
"total_owing": float(total_owing),
|
||||
"total_trust": float(total_trust),
|
||||
"total_hours": float(total_hours),
|
||||
"unbilled_amount": float(unbilled_total)
|
||||
},
|
||||
"recent_activity": {
|
||||
"entries_count": recent_entries,
|
||||
"total_amount": float(recent_amount),
|
||||
"period_days": 30
|
||||
},
|
||||
"top_files": [
|
||||
{
|
||||
"file_no": f[0],
|
||||
"amount_owing": float(f[1]),
|
||||
"total_charges": float(f[2])
|
||||
} for f in top_files
|
||||
],
|
||||
"employee_stats": [
|
||||
{
|
||||
"employee": stat[0],
|
||||
"hours": float(stat[1] or 0),
|
||||
"amount": float(stat[2] or 0),
|
||||
"entries": stat[3]
|
||||
} for stat in employee_stats
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lookups/transaction-codes")
|
||||
async def get_transaction_codes(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available transaction codes"""
|
||||
codes = db.query(TransactionCode).filter(TransactionCode.active == True).all()
|
||||
return [
|
||||
{
|
||||
"code": c.t_code,
|
||||
"description": c.description,
|
||||
"type": c.t_type,
|
||||
"default_rate": c.default_rate
|
||||
} for c in codes
|
||||
]
|
||||
|
||||
|
||||
@router.get("/lookups/transaction-types")
|
||||
async def get_transaction_types(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get available transaction types"""
|
||||
types = db.query(TransactionType).filter(TransactionType.active == True).all()
|
||||
return [
|
||||
{
|
||||
"type": t.t_type,
|
||||
"description": t.description,
|
||||
"debit_credit": t.debit_credit
|
||||
} for t in types
|
||||
]
|
||||
|
||||
|
||||
@router.get("/reports/time-summary")
|
||||
async def get_time_summary_report(
|
||||
start_date: date = Query(...),
|
||||
end_date: date = Query(...),
|
||||
employee: Optional[str] = Query(None),
|
||||
file_no: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Generate time summary report"""
|
||||
query = db.query(Ledger)\
|
||||
.options(joinedload(Ledger.file).joinedload(File.owner))\
|
||||
.filter(and_(
|
||||
Ledger.date >= start_date,
|
||||
Ledger.date <= end_date,
|
||||
Ledger.t_type == "2" # Time entries only
|
||||
))\
|
||||
.order_by(Ledger.date)
|
||||
|
||||
if employee:
|
||||
query = query.filter(Ledger.empl_num == employee)
|
||||
|
||||
if file_no:
|
||||
query = query.filter(Ledger.file_no == file_no)
|
||||
|
||||
entries = query.all()
|
||||
|
||||
# Summarize by employee and file
|
||||
summary = {}
|
||||
total_hours = 0.0
|
||||
total_amount = 0.0
|
||||
|
||||
for entry in entries:
|
||||
emp = entry.empl_num
|
||||
file_no = entry.file_no
|
||||
|
||||
if emp not in summary:
|
||||
summary[emp] = {
|
||||
"employee": emp,
|
||||
"files": {},
|
||||
"total_hours": 0.0,
|
||||
"total_amount": 0.0
|
||||
}
|
||||
|
||||
if file_no not in summary[emp]["files"]:
|
||||
file_obj = entry.file
|
||||
client = file_obj.owner if file_obj else None
|
||||
summary[emp]["files"][file_no] = {
|
||||
"file_no": file_no,
|
||||
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
|
||||
"matter": file_obj.regarding if file_obj else "",
|
||||
"hours": 0.0,
|
||||
"amount": 0.0,
|
||||
"entries": []
|
||||
}
|
||||
|
||||
# Add entry details
|
||||
summary[emp]["files"][file_no]["entries"].append({
|
||||
"date": entry.date.isoformat(),
|
||||
"hours": entry.quantity,
|
||||
"rate": entry.rate,
|
||||
"amount": entry.amount,
|
||||
"description": entry.note,
|
||||
"billed": entry.billed == "Y"
|
||||
})
|
||||
|
||||
# Update totals
|
||||
summary[emp]["files"][file_no]["hours"] += entry.quantity
|
||||
summary[emp]["files"][file_no]["amount"] += entry.amount
|
||||
summary[emp]["total_hours"] += entry.quantity
|
||||
summary[emp]["total_amount"] += entry.amount
|
||||
total_hours += entry.quantity
|
||||
total_amount += entry.amount
|
||||
|
||||
# Convert to list format
|
||||
report_data = []
|
||||
for emp_data in summary.values():
|
||||
emp_data["files"] = list(emp_data["files"].values())
|
||||
report_data.append(emp_data)
|
||||
|
||||
return {
|
||||
"report_period": {
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat()
|
||||
},
|
||||
"summary": {
|
||||
"total_hours": total_hours,
|
||||
"total_amount": total_amount,
|
||||
"total_entries": len(entries)
|
||||
},
|
||||
"employees": report_data
|
||||
}
|
||||
|
||||
|
||||
@router.post("/payments/")
|
||||
async def record_payment(
|
||||
file_no: str,
|
||||
amount: float,
|
||||
payment_date: Optional[date] = None,
|
||||
payment_method: str = "CHECK",
|
||||
reference: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
apply_to_trust: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Record a payment against 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=404, detail="File not found")
|
||||
|
||||
payment_date = payment_date or date.today()
|
||||
|
||||
# Get next item number
|
||||
max_item = db.query(func.max(Ledger.item_no)).filter(
|
||||
Ledger.file_no == file_no
|
||||
).scalar() or 0
|
||||
|
||||
# Determine transaction type and code based on whether it goes to trust
|
||||
if apply_to_trust:
|
||||
t_type = "1" # Trust
|
||||
t_code = "TRUST"
|
||||
description = f"Trust deposit - {payment_method}"
|
||||
else:
|
||||
t_type = "5" # Credit/Payment
|
||||
t_code = "PMT"
|
||||
description = f"Payment received - {payment_method}"
|
||||
|
||||
if reference:
|
||||
description += f" - Ref: {reference}"
|
||||
|
||||
if notes:
|
||||
description += f" - {notes}"
|
||||
|
||||
# Create payment entry
|
||||
entry = Ledger(
|
||||
file_no=file_no,
|
||||
item_no=max_item + 1,
|
||||
date=payment_date,
|
||||
t_code=t_code,
|
||||
t_type=t_type,
|
||||
t_type_l="C", # Credit
|
||||
empl_num=file_obj.empl_num,
|
||||
quantity=0.0,
|
||||
rate=0.0,
|
||||
amount=amount,
|
||||
billed="Y", # Payments are automatically considered "billed"
|
||||
note=description
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
# Update file balances
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return {
|
||||
"id": entry.id,
|
||||
"message": f"Payment of ${amount} recorded successfully",
|
||||
"payment": {
|
||||
"amount": amount,
|
||||
"date": payment_date.isoformat(),
|
||||
"method": payment_method,
|
||||
"applied_to": "trust" if apply_to_trust else "balance",
|
||||
"reference": reference
|
||||
},
|
||||
"new_balance": {
|
||||
"amount_owing": file_obj.amount_owing,
|
||||
"trust_balance": file_obj.trust_bal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/expenses/")
|
||||
async def record_expense(
|
||||
file_no: str,
|
||||
amount: float,
|
||||
description: str,
|
||||
expense_date: Optional[date] = None,
|
||||
category: str = "MISC",
|
||||
employee: Optional[str] = None,
|
||||
receipts: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Record an expense/disbursement against 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=404, detail="File not found")
|
||||
|
||||
expense_date = expense_date or date.today()
|
||||
empl_num = employee or file_obj.empl_num
|
||||
|
||||
# Get next item number
|
||||
max_item = db.query(func.max(Ledger.item_no)).filter(
|
||||
Ledger.file_no == file_no
|
||||
).scalar() or 0
|
||||
|
||||
# Add receipt info to description
|
||||
full_description = description
|
||||
if receipts:
|
||||
full_description += " (Receipts on file)"
|
||||
|
||||
# Create expense entry
|
||||
entry = Ledger(
|
||||
file_no=file_no,
|
||||
item_no=max_item + 1,
|
||||
date=expense_date,
|
||||
t_code=category,
|
||||
t_type="4", # Disbursements
|
||||
t_type_l="D", # Debit
|
||||
empl_num=empl_num,
|
||||
quantity=0.0,
|
||||
rate=0.0,
|
||||
amount=amount,
|
||||
billed="N",
|
||||
note=full_description
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
# Update file balances
|
||||
await _update_file_balances(file_obj, db)
|
||||
|
||||
return {
|
||||
"id": entry.id,
|
||||
"message": f"Expense of ${amount} recorded successfully",
|
||||
"expense": {
|
||||
"amount": amount,
|
||||
"date": expense_date.isoformat(),
|
||||
"category": category,
|
||||
"description": description,
|
||||
"employee": empl_num
|
||||
}
|
||||
}
|
||||
661
app/api/import_data.py
Normal file
661
app/api/import_data.py
Normal file
@@ -0,0 +1,661 @@
|
||||
"""
|
||||
Data import API endpoints for CSV file uploads
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as UploadFileForm, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database.base import get_db
|
||||
from app.auth.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models import *
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
|
||||
|
||||
# CSV to Model mapping
|
||||
CSV_MODEL_MAPPING = {
|
||||
"ROLODEX.csv": Rolodex,
|
||||
"PHONE.csv": Phone,
|
||||
"FILES.csv": File,
|
||||
"LEDGER.csv": Ledger,
|
||||
"QDROS.csv": QDRO,
|
||||
"PENSIONS.csv": Pension,
|
||||
"SCHEDULE.csv": PensionSchedule,
|
||||
"MARRIAGE.csv": MarriageHistory,
|
||||
"DEATH.csv": DeathBenefit,
|
||||
"SEPARATE.csv": SeparationAgreement,
|
||||
"LIFETABL.csv": LifeTable,
|
||||
"NUMBERAL.csv": NumberTable,
|
||||
"EMPLOYEE.csv": Employee,
|
||||
"FILETYPE.csv": FileType,
|
||||
"FILESTAT.csv": FileStatus,
|
||||
"TRNSTYPE.csv": TransactionType,
|
||||
"TRNSLKUP.csv": TransactionCode,
|
||||
"STATES.csv": State,
|
||||
"GRUPLKUP.csv": GroupLookup,
|
||||
"FOOTERS.csv": Footer,
|
||||
"PLANINFO.csv": PlanInfo,
|
||||
"FORM_INX.csv": FormIndex,
|
||||
"FORM_LST.csv": FormList,
|
||||
"PRINTERS.csv": PrinterSetup,
|
||||
"SETUP.csv": SystemSetup,
|
||||
# Additional models for complete legacy coverage
|
||||
"DEPOSITS.csv": Deposit,
|
||||
"FILENOTS.csv": FileNote,
|
||||
"FVARLKUP.csv": FormVariable,
|
||||
"RVARLKUP.csv": ReportVariable,
|
||||
"PAYMENTS.csv": Payment,
|
||||
"TRNSACTN.csv": Ledger # Maps to existing Ledger model (same structure)
|
||||
}
|
||||
|
||||
# Field mappings for CSV columns to database fields
|
||||
FIELD_MAPPINGS = {
|
||||
"ROLODEX.csv": {
|
||||
"Id": "id",
|
||||
"Prefix": "prefix",
|
||||
"First": "first",
|
||||
"Middle": "middle",
|
||||
"Last": "last",
|
||||
"Suffix": "suffix",
|
||||
"Title": "title",
|
||||
"A1": "a1",
|
||||
"A2": "a2",
|
||||
"A3": "a3",
|
||||
"City": "city",
|
||||
"Abrev": "abrev",
|
||||
# "St": "st", # Full state name - not mapped (model only has abrev)
|
||||
"Zip": "zip",
|
||||
"Email": "email",
|
||||
"DOB": "dob",
|
||||
"SS#": "ss_number",
|
||||
"Legal_Status": "legal_status",
|
||||
"Group": "group",
|
||||
"Memo": "memo"
|
||||
},
|
||||
"PHONE.csv": {
|
||||
"Id": "rolodex_id",
|
||||
"Phone": "phone",
|
||||
"Location": "location"
|
||||
},
|
||||
"FILES.csv": {
|
||||
"File_No": "file_no",
|
||||
"Id": "id",
|
||||
"File_Type": "file_type",
|
||||
"Regarding": "regarding",
|
||||
"Opened": "opened",
|
||||
"Closed": "closed",
|
||||
"Empl_Num": "empl_num",
|
||||
"Rate_Per_Hour": "rate_per_hour",
|
||||
"Status": "status",
|
||||
"Footer_Code": "footer_code",
|
||||
"Opposing": "opposing",
|
||||
"Hours": "hours",
|
||||
"Hours_P": "hours_p",
|
||||
"Trust_Bal": "trust_bal",
|
||||
"Trust_Bal_P": "trust_bal_p",
|
||||
"Hourly_Fees": "hourly_fees",
|
||||
"Hourly_Fees_P": "hourly_fees_p",
|
||||
"Flat_Fees": "flat_fees",
|
||||
"Flat_Fees_P": "flat_fees_p",
|
||||
"Disbursements": "disbursements",
|
||||
"Disbursements_P": "disbursements_p",
|
||||
"Credit_Bal": "credit_bal",
|
||||
"Credit_Bal_P": "credit_bal_p",
|
||||
"Total_Charges": "total_charges",
|
||||
"Total_Charges_P": "total_charges_p",
|
||||
"Amount_Owing": "amount_owing",
|
||||
"Amount_Owing_P": "amount_owing_p",
|
||||
"Transferable": "transferable",
|
||||
"Memo": "memo"
|
||||
},
|
||||
"LEDGER.csv": {
|
||||
"File_No": "file_no",
|
||||
"Date": "date",
|
||||
"Item_No": "item_no",
|
||||
"Empl_Num": "empl_num",
|
||||
"T_Code": "t_code",
|
||||
"T_Type": "t_type",
|
||||
"T_Type_L": "t_type_l",
|
||||
"Quantity": "quantity",
|
||||
"Rate": "rate",
|
||||
"Amount": "amount",
|
||||
"Billed": "billed",
|
||||
"Note": "note"
|
||||
},
|
||||
"QDROS.csv": {
|
||||
"File_No": "file_no",
|
||||
"Version": "version",
|
||||
"Plan_Id": "plan_id",
|
||||
"^1": "field1",
|
||||
"^2": "field2",
|
||||
"^Part": "part",
|
||||
"^AltP": "altp",
|
||||
"^Pet": "pet",
|
||||
"^Res": "res",
|
||||
"Case_Type": "case_type",
|
||||
"Case_Code": "case_code",
|
||||
"Section": "section",
|
||||
"Case_Number": "case_number",
|
||||
"Judgment_Date": "judgment_date",
|
||||
"Valuation_Date": "valuation_date",
|
||||
"Married_On": "married_on",
|
||||
"Percent_Awarded": "percent_awarded",
|
||||
"Ven_City": "ven_city",
|
||||
"Ven_Cnty": "ven_cnty",
|
||||
"Ven_St": "ven_st",
|
||||
"Draft_Out": "draft_out",
|
||||
"Draft_Apr": "draft_apr",
|
||||
"Final_Out": "final_out",
|
||||
"Judge": "judge",
|
||||
"Form_Name": "form_name"
|
||||
},
|
||||
"PENSIONS.csv": {
|
||||
"File_No": "file_no",
|
||||
"Version": "version",
|
||||
"Plan_Id": "plan_id",
|
||||
"Plan_Name": "plan_name",
|
||||
"Title": "title",
|
||||
"First": "first",
|
||||
"Last": "last",
|
||||
"Birth": "birth",
|
||||
"Race": "race",
|
||||
"Sex": "sex",
|
||||
"Info": "info",
|
||||
"Valu": "valu",
|
||||
"Accrued": "accrued",
|
||||
"Vested_Per": "vested_per",
|
||||
"Start_Age": "start_age",
|
||||
"COLA": "cola",
|
||||
"Max_COLA": "max_cola",
|
||||
"Withdrawal": "withdrawal",
|
||||
"Pre_DR": "pre_dr",
|
||||
"Post_DR": "post_dr",
|
||||
"Tax_Rate": "tax_rate"
|
||||
},
|
||||
"EMPLOYEE.csv": {
|
||||
"Empl_Num": "empl_num",
|
||||
"Rate_Per_Hour": "rate_per_hour"
|
||||
# "Empl_Id": not a field in Employee model, using empl_num as identifier
|
||||
# Model has additional fields (first_name, last_name, title, etc.) not in CSV
|
||||
},
|
||||
"STATES.csv": {
|
||||
"Abrev": "abbreviation",
|
||||
"St": "name"
|
||||
},
|
||||
"GRUPLKUP.csv": {
|
||||
"Code": "group_code",
|
||||
"Description": "description"
|
||||
# "Title": field not present in model, skipping
|
||||
},
|
||||
"TRNSLKUP.csv": {
|
||||
"T_Code": "t_code",
|
||||
"T_Type": "t_type",
|
||||
# "T_Type_L": not a field in TransactionCode model
|
||||
"Amount": "default_rate",
|
||||
"Description": "description"
|
||||
},
|
||||
"TRNSTYPE.csv": {
|
||||
"T_Type": "t_type",
|
||||
"T_Type_L": "description"
|
||||
# "Header": maps to debit_credit but needs data transformation
|
||||
# "Footer": doesn't align with active boolean field
|
||||
# These fields may need custom handling or model updates
|
||||
},
|
||||
"FILETYPE.csv": {
|
||||
"File_Type": "type_code",
|
||||
"Description": "description",
|
||||
"Default_Rate": "default_rate"
|
||||
},
|
||||
"FILESTAT.csv": {
|
||||
"Status_Code": "status_code",
|
||||
"Description": "description",
|
||||
"Sort_Order": "sort_order"
|
||||
},
|
||||
"FOOTERS.csv": {
|
||||
"Footer_Code": "footer_code",
|
||||
"Content": "content",
|
||||
"Description": "description"
|
||||
},
|
||||
"PLANINFO.csv": {
|
||||
"Plan_Id": "plan_id",
|
||||
"Plan_Name": "plan_name",
|
||||
"Plan_Type": "plan_type",
|
||||
"Sponsor": "sponsor",
|
||||
"Administrator": "administrator",
|
||||
"Address1": "address1",
|
||||
"Address2": "address2",
|
||||
"City": "city",
|
||||
"State": "state",
|
||||
"Zip_Code": "zip_code",
|
||||
"Phone": "phone",
|
||||
"Notes": "notes"
|
||||
},
|
||||
"FORM_INX.csv": {
|
||||
"Form_Id": "form_id",
|
||||
"Form_Name": "form_name",
|
||||
"Category": "category"
|
||||
},
|
||||
"FORM_LST.csv": {
|
||||
"Form_Id": "form_id",
|
||||
"Line_Number": "line_number",
|
||||
"Content": "content"
|
||||
},
|
||||
"PRINTERS.csv": {
|
||||
"Printer_Name": "printer_name",
|
||||
"Description": "description",
|
||||
"Driver": "driver",
|
||||
"Port": "port",
|
||||
"Default_Printer": "default_printer"
|
||||
},
|
||||
"SETUP.csv": {
|
||||
"Setting_Key": "setting_key",
|
||||
"Setting_Value": "setting_value",
|
||||
"Description": "description",
|
||||
"Setting_Type": "setting_type"
|
||||
},
|
||||
"SCHEDULE.csv": {
|
||||
"File_No": "file_no",
|
||||
"Version": "version",
|
||||
"Vests_On": "vests_on",
|
||||
"Vests_At": "vests_at"
|
||||
},
|
||||
"MARRIAGE.csv": {
|
||||
"File_No": "file_no",
|
||||
"Version": "version",
|
||||
"Marriage_Date": "marriage_date",
|
||||
"Separation_Date": "separation_date",
|
||||
"Divorce_Date": "divorce_date"
|
||||
},
|
||||
"DEATH.csv": {
|
||||
"File_No": "file_no",
|
||||
"Version": "version",
|
||||
"Benefit_Type": "benefit_type",
|
||||
"Benefit_Amount": "benefit_amount",
|
||||
"Beneficiary": "beneficiary"
|
||||
},
|
||||
"SEPARATE.csv": {
|
||||
"File_No": "file_no",
|
||||
"Version": "version",
|
||||
"Agreement_Date": "agreement_date",
|
||||
"Terms": "terms"
|
||||
},
|
||||
"LIFETABL.csv": {
|
||||
"Age": "age",
|
||||
"Male_Mortality": "male_mortality",
|
||||
"Female_Mortality": "female_mortality"
|
||||
},
|
||||
"NUMBERAL.csv": {
|
||||
"Table_Name": "table_name",
|
||||
"Age": "age",
|
||||
"Value": "value"
|
||||
},
|
||||
# Additional CSV file mappings
|
||||
"DEPOSITS.csv": {
|
||||
"Deposit_Date": "deposit_date",
|
||||
"Total": "total"
|
||||
},
|
||||
"FILENOTS.csv": {
|
||||
"File_No": "file_no",
|
||||
"Memo_Date": "memo_date",
|
||||
"Memo_Note": "memo_note"
|
||||
},
|
||||
"FVARLKUP.csv": {
|
||||
"Identifier": "identifier",
|
||||
"Query": "query",
|
||||
"Response": "response"
|
||||
},
|
||||
"RVARLKUP.csv": {
|
||||
"Identifier": "identifier",
|
||||
"Query": "query"
|
||||
},
|
||||
"PAYMENTS.csv": {
|
||||
"Deposit_Date": "deposit_date",
|
||||
"File_No": "file_no",
|
||||
"Id": "client_id",
|
||||
"Regarding": "regarding",
|
||||
"Amount": "amount",
|
||||
"Note": "note"
|
||||
},
|
||||
"TRNSACTN.csv": {
|
||||
# Maps to Ledger model - same structure as LEDGER.csv
|
||||
"File_No": "file_no",
|
||||
"Date": "date",
|
||||
"Item_No": "item_no",
|
||||
"Empl_Num": "empl_num",
|
||||
"T_Code": "t_code",
|
||||
"T_Type": "t_type",
|
||||
"T_Type_L": "t_type_l",
|
||||
"Quantity": "quantity",
|
||||
"Rate": "rate",
|
||||
"Amount": "amount",
|
||||
"Billed": "billed",
|
||||
"Note": "note"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string in various formats"""
|
||||
if not date_str or date_str.strip() == "":
|
||||
return None
|
||||
|
||||
date_formats = [
|
||||
"%Y-%m-%d",
|
||||
"%m/%d/%Y",
|
||||
"%d/%m/%Y",
|
||||
"%m-%d-%Y",
|
||||
"%d-%m-%Y",
|
||||
"%Y/%m/%d"
|
||||
]
|
||||
|
||||
for fmt in date_formats:
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def convert_value(value: str, field_name: str) -> Any:
|
||||
"""Convert string value to appropriate type based on field name"""
|
||||
if not value or value.strip() == "" or value.strip().lower() in ["null", "none", "n/a"]:
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
|
||||
# Date fields
|
||||
if any(word in field_name.lower() for word in ["date", "dob", "birth", "opened", "closed", "judgment", "valuation", "married", "vests_on"]):
|
||||
parsed_date = parse_date(value)
|
||||
return parsed_date
|
||||
|
||||
# Boolean fields
|
||||
if any(word in field_name.lower() for word in ["active", "default_printer", "billed", "transferable"]):
|
||||
if value.lower() in ["true", "1", "yes", "y", "on", "active"]:
|
||||
return True
|
||||
elif value.lower() in ["false", "0", "no", "n", "off", "inactive"]:
|
||||
return False
|
||||
else:
|
||||
return None
|
||||
|
||||
# Numeric fields (float)
|
||||
if any(word in field_name.lower() for word in ["rate", "hour", "bal", "fee", "amount", "owing", "transfer", "valu", "accrued", "vested", "cola", "tax", "percent", "benefit_amount", "mortality", "value"]):
|
||||
try:
|
||||
# Remove currency symbols and commas
|
||||
cleaned_value = value.replace("$", "").replace(",", "").replace("%", "")
|
||||
return float(cleaned_value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
# Integer fields
|
||||
if any(word in field_name.lower() for word in ["item_no", "age", "start_age", "version", "line_number", "sort_order"]):
|
||||
try:
|
||||
return int(float(value)) # Handle cases like "1.0"
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
# String fields - limit length to prevent database errors
|
||||
if len(value) > 500: # Reasonable limit for most string fields
|
||||
return value[:500]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def validate_foreign_keys(model_data: dict, model_class, db: Session) -> list[str]:
|
||||
"""Validate foreign key relationships before inserting data"""
|
||||
errors = []
|
||||
|
||||
# Check Phone -> Rolodex relationship
|
||||
if model_class == Phone and "rolodex_id" in model_data:
|
||||
rolodex_id = model_data["rolodex_id"]
|
||||
if rolodex_id and not db.query(Rolodex).filter(Rolodex.id == rolodex_id).first():
|
||||
errors.append(f"Rolodex ID '{rolodex_id}' not found")
|
||||
|
||||
# Check File -> Rolodex relationship
|
||||
if model_class == File and "id" in model_data:
|
||||
rolodex_id = model_data["id"]
|
||||
if rolodex_id and not db.query(Rolodex).filter(Rolodex.id == rolodex_id).first():
|
||||
errors.append(f"Owner Rolodex ID '{rolodex_id}' not found")
|
||||
|
||||
# Add more foreign key validations as needed
|
||||
return errors
|
||||
|
||||
|
||||
@router.get("/available-files")
|
||||
async def get_available_csv_files(current_user: User = Depends(get_current_user)):
|
||||
"""Get list of available CSV files for import"""
|
||||
return {
|
||||
"available_files": list(CSV_MODEL_MAPPING.keys()),
|
||||
"descriptions": {
|
||||
"ROLODEX.csv": "Customer/contact information",
|
||||
"PHONE.csv": "Phone numbers linked to customers",
|
||||
"FILES.csv": "Client files and cases",
|
||||
"LEDGER.csv": "Financial transactions per file",
|
||||
"QDROS.csv": "Legal documents and court orders",
|
||||
"PENSIONS.csv": "Pension calculation data",
|
||||
"EMPLOYEE.csv": "Staff and employee information",
|
||||
"STATES.csv": "US States lookup table",
|
||||
"FILETYPE.csv": "File type categories",
|
||||
"FILESTAT.csv": "File status codes",
|
||||
"DEPOSITS.csv": "Daily bank deposit summaries",
|
||||
"FILENOTS.csv": "File notes and case memos",
|
||||
"FVARLKUP.csv": "Form template variables",
|
||||
"RVARLKUP.csv": "Report template variables",
|
||||
"PAYMENTS.csv": "Individual payments within deposits",
|
||||
"TRNSACTN.csv": "Transaction details (maps to Ledger)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload/{file_type}")
|
||||
async def import_csv_data(
|
||||
file_type: str,
|
||||
file: UploadFile = UploadFileForm(...),
|
||||
replace_existing: bool = Form(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Import data from CSV file"""
|
||||
|
||||
# Validate file type
|
||||
if file_type not in CSV_MODEL_MAPPING:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file type: {file_type}. Available types: {list(CSV_MODEL_MAPPING.keys())}"
|
||||
)
|
||||
|
||||
# Validate file extension
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV file")
|
||||
|
||||
model_class = CSV_MODEL_MAPPING[file_type]
|
||||
field_mapping = FIELD_MAPPINGS.get(file_type, {})
|
||||
|
||||
try:
|
||||
# Read CSV content
|
||||
content = await file.read()
|
||||
csv_content = content.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
|
||||
# If replace_existing is True, delete all existing records
|
||||
if replace_existing:
|
||||
db.query(model_class).delete()
|
||||
db.commit()
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 for header row
|
||||
try:
|
||||
# Convert CSV row to model data
|
||||
model_data = {}
|
||||
|
||||
for csv_field, db_field in field_mapping.items():
|
||||
if csv_field in row:
|
||||
converted_value = convert_value(row[csv_field], csv_field)
|
||||
if converted_value is not None:
|
||||
model_data[db_field] = converted_value
|
||||
|
||||
# Skip empty rows
|
||||
if not any(model_data.values()):
|
||||
continue
|
||||
|
||||
# Create model instance
|
||||
instance = model_class(**model_data)
|
||||
db.add(instance)
|
||||
imported_count += 1
|
||||
|
||||
# Commit every 100 records to avoid memory issues
|
||||
if imported_count % 100 == 0:
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
"row": row_num,
|
||||
"error": str(e),
|
||||
"data": row
|
||||
})
|
||||
continue
|
||||
|
||||
# Final commit
|
||||
db.commit()
|
||||
|
||||
result = {
|
||||
"file_type": file_type,
|
||||
"imported_count": imported_count,
|
||||
"errors": errors[:10], # Limit errors to first 10
|
||||
"total_errors": len(errors)
|
||||
}
|
||||
|
||||
if errors:
|
||||
result["warning"] = f"Import completed with {len(errors)} errors"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_import_status(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Get current import status and record counts"""
|
||||
|
||||
status = {}
|
||||
|
||||
for file_type, model_class in CSV_MODEL_MAPPING.items():
|
||||
try:
|
||||
count = db.query(model_class).count()
|
||||
status[file_type] = {
|
||||
"table_name": model_class.__tablename__,
|
||||
"record_count": count
|
||||
}
|
||||
except Exception as e:
|
||||
status[file_type] = {
|
||||
"table_name": model_class.__tablename__,
|
||||
"record_count": 0,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
return status
|
||||
|
||||
|
||||
@router.delete("/clear/{file_type}")
|
||||
async def clear_table_data(
|
||||
file_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Clear all data from a specific table"""
|
||||
|
||||
if file_type not in CSV_MODEL_MAPPING:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown file type: {file_type}")
|
||||
|
||||
model_class = CSV_MODEL_MAPPING[file_type]
|
||||
|
||||
try:
|
||||
deleted_count = db.query(model_class).count()
|
||||
db.query(model_class).delete()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"file_type": file_type,
|
||||
"table_name": model_class.__tablename__,
|
||||
"deleted_count": deleted_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Clear operation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/validate/{file_type}")
|
||||
async def validate_csv_file(
|
||||
file_type: str,
|
||||
file: UploadFile = UploadFileForm(...),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Validate CSV file structure without importing"""
|
||||
|
||||
if file_type not in CSV_MODEL_MAPPING:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file type: {file_type}")
|
||||
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV file")
|
||||
|
||||
field_mapping = FIELD_MAPPINGS.get(file_type, {})
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
csv_content = content.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
# Check headers
|
||||
csv_headers = csv_reader.fieldnames
|
||||
expected_headers = list(field_mapping.keys())
|
||||
|
||||
missing_headers = [h for h in expected_headers if h not in csv_headers]
|
||||
extra_headers = [h for h in csv_headers if h not in expected_headers]
|
||||
|
||||
# Sample data validation
|
||||
sample_rows = []
|
||||
errors = []
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2):
|
||||
if row_num > 12: # Only check first 10 data rows
|
||||
break
|
||||
|
||||
sample_rows.append(row)
|
||||
|
||||
# Check for data type issues
|
||||
for csv_field, db_field in field_mapping.items():
|
||||
if csv_field in row and row[csv_field]:
|
||||
try:
|
||||
convert_value(row[csv_field], csv_field)
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
"row": row_num,
|
||||
"field": csv_field,
|
||||
"value": row[csv_field],
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
"file_type": file_type,
|
||||
"valid": len(missing_headers) == 0 and len(errors) == 0,
|
||||
"headers": {
|
||||
"found": csv_headers,
|
||||
"expected": expected_headers,
|
||||
"missing": missing_headers,
|
||||
"extra": extra_headers
|
||||
},
|
||||
"sample_data": sample_rows,
|
||||
"validation_errors": errors[:5], # First 5 errors only
|
||||
"total_errors": len(errors)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
||||
1120
app/api/search.py
Normal file
1120
app/api/search.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user