""" CRUD and list endpoints for pension-related legacy tables with basic date filters. Tables: - PensionSchedule (SCHEDULE.csv) - MarriageHistory (MARRIAGE.csv) - DeathBenefit (DEATH.csv) - SeparationAgreement (SEPARATE.csv) """ from typing import List, Optional, Union from datetime import date, datetime, time from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session from sqlalchemy import func from app.api.search_highlight import build_query_tokens from app.services.query_utils import apply_sorting, paginate_with_total, tokenized_ilike_filter from app.database.base import get_db from app.auth.security import get_current_user from app.models.user import User from app.models.pensions import ( PensionSchedule, MarriageHistory, DeathBenefit, SeparationAgreement, Pension, ) router = APIRouter() class ScheduleResponse(BaseModel): id: int file_no: str version: Optional[str] = None vests_on: Optional[date] = None vests_at: Optional[float] = None frequency: Optional[str] = None model_config = { "from_attributes": True, } class PaginatedSchedulesResponse(BaseModel): items: List[ScheduleResponse] total: int @router.get("/schedules", response_model=Union[List[ScheduleResponse], PaginatedSchedulesResponse]) async def list_pension_schedules( file_no: str = Query(..., description="Filter by file number"), start: Optional[date] = Query(None, description="Start date (inclusive) for vests_on"), end: Optional[date] = Query(None, description="End date (inclusive) for vests_on"), version: Optional[str] = Query(None, description="Filter by version"), vests_at_min: Optional[float] = Query(None, description="Minimum vests_at (inclusive)"), vests_at_max: Optional[float] = Query(None, description="Maximum vests_at (inclusive)"), search: Optional[str] = Query(None, description="Tokenized search across version and frequency"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), sort_by: Optional[str] = Query("id", description="Sort by: id, file_no, version, vests_on, vests_at"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): q = db.query(PensionSchedule).filter(PensionSchedule.file_no == file_no) if start is not None: q = q.filter(PensionSchedule.vests_on >= start) if end is not None: q = q.filter(PensionSchedule.vests_on <= end) if version is not None: q = q.filter(PensionSchedule.version == version) if vests_at_min is not None: q = q.filter(PensionSchedule.vests_at >= vests_at_min) if vests_at_max is not None: q = q.filter(PensionSchedule.vests_at <= vests_at_max) if search: tokens = build_query_tokens(search) filter_expr = tokenized_ilike_filter(tokens, [ PensionSchedule.version, PensionSchedule.frequency, ]) if filter_expr is not None: q = q.filter(filter_expr) q = apply_sorting( q, sort_by, sort_dir, allowed={ "id": [PensionSchedule.id], "file_no": [PensionSchedule.file_no], "version": [PensionSchedule.version], "vests_on": [PensionSchedule.vests_on], "vests_at": [PensionSchedule.vests_at], }, ) items, total = paginate_with_total(q, skip, limit, include_total) if include_total: return {"items": items, "total": total or 0} return items class ScheduleCreate(BaseModel): file_no: str version: Optional[str] = "01" vests_on: Optional[date] = None vests_at: Optional[float] = None frequency: Optional[str] = None class ScheduleUpdate(BaseModel): version: Optional[str] = None vests_on: Optional[date] = None vests_at: Optional[float] = None frequency: Optional[str] = None @router.post("/schedules", response_model=ScheduleResponse, status_code=status.HTTP_201_CREATED) async def create_pension_schedule( payload: ScheduleCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = PensionSchedule( file_no=payload.file_no, version=payload.version or "01", vests_on=payload.vests_on, vests_at=payload.vests_at, frequency=payload.frequency, ) db.add(row) db.commit() db.refresh(row) return row @router.get("/schedules/{row_id}", response_model=ScheduleResponse) async def get_pension_schedule( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(PensionSchedule).filter(PensionSchedule.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Schedule not found") return row @router.put("/schedules/{row_id}", response_model=ScheduleResponse) async def update_pension_schedule( row_id: int, payload: ScheduleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(PensionSchedule).filter(PensionSchedule.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Schedule not found") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(row, field, value) db.commit() db.refresh(row) return row @router.delete("/schedules/{row_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_pension_schedule( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(PensionSchedule).filter(PensionSchedule.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Schedule not found") db.delete(row) db.commit() return None class MarriageResponse(BaseModel): id: int file_no: str spouse_name: Optional[str] = None notes: Optional[str] = None married_from: Optional[date] = None married_to: Optional[date] = None married_years: Optional[float] = None service_from: Optional[date] = None service_to: Optional[date] = None service_years: Optional[float] = None marital_percent: Optional[float] = None model_config = { "from_attributes": True, } class PaginatedMarriagesResponse(BaseModel): items: List[MarriageResponse] total: int @router.get("/marriages", response_model=Union[List[MarriageResponse], PaginatedMarriagesResponse]) async def list_marriages( file_no: str = Query(..., description="Filter by file number"), start: Optional[date] = Query(None, description="Start date (inclusive) for married_from"), end: Optional[date] = Query(None, description="End date (inclusive) for married_from"), version: Optional[str] = Query(None, description="Filter by version"), married_years_min: Optional[float] = Query(None, description="Minimum married_years (inclusive)"), married_years_max: Optional[float] = Query(None, description="Maximum married_years (inclusive)"), service_years_min: Optional[float] = Query(None, description="Minimum service_years (inclusive)"), service_years_max: Optional[float] = Query(None, description="Maximum service_years (inclusive)"), marital_percent_min: Optional[float] = Query(None, description="Minimum marital_percent (inclusive)"), marital_percent_max: Optional[float] = Query(None, description="Maximum marital_percent (inclusive)"), search: Optional[str] = Query(None, description="Tokenized search across version and notes/spouse_name when present"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), sort_by: Optional[str] = Query("id", description="Sort by: id, file_no, version, married_from, married_to, marital_percent, service_from, service_to"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): q = db.query(MarriageHistory).filter(MarriageHistory.file_no == file_no) if start is not None: q = q.filter(MarriageHistory.married_from >= start) if end is not None: q = q.filter(MarriageHistory.married_from <= end) if version is not None: q = q.filter(MarriageHistory.version == version) if married_years_min is not None: q = q.filter(MarriageHistory.married_years >= married_years_min) if married_years_max is not None: q = q.filter(MarriageHistory.married_years <= married_years_max) if service_years_min is not None: q = q.filter(MarriageHistory.service_years >= service_years_min) if service_years_max is not None: q = q.filter(MarriageHistory.service_years <= service_years_max) if marital_percent_min is not None: q = q.filter(MarriageHistory.marital_percent >= marital_percent_min) if marital_percent_max is not None: q = q.filter(MarriageHistory.marital_percent <= marital_percent_max) if search: tokens = build_query_tokens(search) filter_expr = tokenized_ilike_filter(tokens, [ MarriageHistory.version, MarriageHistory.spouse_name, MarriageHistory.notes, ]) if filter_expr is not None: q = q.filter(filter_expr) q = apply_sorting( q, sort_by, sort_dir, allowed={ "id": [MarriageHistory.id], "file_no": [MarriageHistory.file_no], "version": [MarriageHistory.version], "married_from": [MarriageHistory.married_from], "married_to": [MarriageHistory.married_to], "marital_percent": [MarriageHistory.marital_percent], "service_from": [MarriageHistory.service_from], "service_to": [MarriageHistory.service_to], }, ) items, total = paginate_with_total(q, skip, limit, include_total) if include_total: return {"items": items, "total": total or 0} return items class MarriageCreate(BaseModel): file_no: str version: Optional[str] = "01" spouse_name: Optional[str] = None notes: Optional[str] = None married_from: Optional[date] = None married_to: Optional[date] = None married_years: Optional[float] = None service_from: Optional[date] = None service_to: Optional[date] = None service_years: Optional[float] = None marital_percent: Optional[float] = None class MarriageUpdate(BaseModel): version: Optional[str] = None spouse_name: Optional[str] = None notes: Optional[str] = None married_from: Optional[date] = None married_to: Optional[date] = None married_years: Optional[float] = None service_from: Optional[date] = None service_to: Optional[date] = None service_years: Optional[float] = None marital_percent: Optional[float] = None @router.post("/marriages", response_model=MarriageResponse, status_code=status.HTTP_201_CREATED) async def create_marriage( payload: MarriageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = MarriageHistory(**payload.model_dump(exclude_unset=True)) db.add(row) db.commit() db.refresh(row) return row @router.get("/marriages/{row_id}", response_model=MarriageResponse) async def get_marriage( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(MarriageHistory).filter(MarriageHistory.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Marriage not found") return row @router.put("/marriages/{row_id}", response_model=MarriageResponse) async def update_marriage( row_id: int, payload: MarriageUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(MarriageHistory).filter(MarriageHistory.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Marriage not found") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(row, field, value) db.commit() db.refresh(row) return row @router.delete("/marriages/{row_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_marriage( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(MarriageHistory).filter(MarriageHistory.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Marriage not found") db.delete(row) db.commit() return None class DeathResponse(BaseModel): id: int file_no: str beneficiary_name: Optional[str] = None benefit_type: Optional[str] = None notes: Optional[str] = None lump1: Optional[float] = None lump2: Optional[float] = None growth1: Optional[float] = None growth2: Optional[float] = None disc1: Optional[float] = None disc2: Optional[float] = None model_config = { "from_attributes": True, } class PaginatedDeathResponse(BaseModel): items: List[DeathResponse] total: int @router.get("/death-benefits", response_model=Union[List[DeathResponse], PaginatedDeathResponse]) async def list_death_benefits( file_no: str = Query(..., description="Filter by file number"), start: Optional[date] = Query(None, description="Start date (inclusive) for created_at"), end: Optional[date] = Query(None, description="End date (inclusive) for created_at"), version: Optional[str] = Query(None, description="Filter by version"), lump1_min: Optional[float] = Query(None, description="Minimum lump1 (inclusive)"), lump1_max: Optional[float] = Query(None, description="Maximum lump1 (inclusive)"), lump2_min: Optional[float] = Query(None, description="Minimum lump2 (inclusive)"), lump2_max: Optional[float] = Query(None, description="Maximum lump2 (inclusive)"), growth1_min: Optional[float] = Query(None, description="Minimum growth1 (inclusive)"), growth1_max: Optional[float] = Query(None, description="Maximum growth1 (inclusive)"), growth2_min: Optional[float] = Query(None, description="Minimum growth2 (inclusive)"), growth2_max: Optional[float] = Query(None, description="Maximum growth2 (inclusive)"), disc1_min: Optional[float] = Query(None, description="Minimum disc1 (inclusive)"), disc1_max: Optional[float] = Query(None, description="Maximum disc1 (inclusive)"), disc2_min: Optional[float] = Query(None, description="Minimum disc2 (inclusive)"), disc2_max: Optional[float] = Query(None, description="Maximum disc2 (inclusive)"), search: Optional[str] = Query(None, description="Tokenized search across version, beneficiary_name, benefit_type, notes"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), sort_by: Optional[str] = Query("id", description="Sort by: id, file_no, version, lump1, lump2, growth1, growth2, disc1, disc2, created"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): q = db.query(DeathBenefit).filter(DeathBenefit.file_no == file_no) if start is not None: q = q.filter(func.date(DeathBenefit.created_at) >= start) if end is not None: q = q.filter(func.date(DeathBenefit.created_at) <= end) if version is not None: q = q.filter(DeathBenefit.version == version) if lump1_min is not None: q = q.filter(DeathBenefit.lump1 >= lump1_min) if lump1_max is not None: q = q.filter(DeathBenefit.lump1 <= lump1_max) if lump2_min is not None: q = q.filter(DeathBenefit.lump2 >= lump2_min) if lump2_max is not None: q = q.filter(DeathBenefit.lump2 <= lump2_max) if growth1_min is not None: q = q.filter(DeathBenefit.growth1 >= growth1_min) if growth1_max is not None: q = q.filter(DeathBenefit.growth1 <= growth1_max) if growth2_min is not None: q = q.filter(DeathBenefit.growth2 >= growth2_min) if growth2_max is not None: q = q.filter(DeathBenefit.growth2 <= growth2_max) if disc1_min is not None: q = q.filter(DeathBenefit.disc1 >= disc1_min) if disc1_max is not None: q = q.filter(DeathBenefit.disc1 <= disc1_max) if disc2_min is not None: q = q.filter(DeathBenefit.disc2 >= disc2_min) if disc2_max is not None: q = q.filter(DeathBenefit.disc2 <= disc2_max) if search: tokens = build_query_tokens(search) filter_expr = tokenized_ilike_filter(tokens, [ DeathBenefit.version, DeathBenefit.beneficiary_name, DeathBenefit.benefit_type, DeathBenefit.notes, ]) if filter_expr is not None: q = q.filter(filter_expr) q = apply_sorting( q, sort_by, sort_dir, allowed={ "id": [DeathBenefit.id], "file_no": [DeathBenefit.file_no], "version": [DeathBenefit.version], "lump1": [DeathBenefit.lump1], "lump2": [DeathBenefit.lump2], "growth1": [DeathBenefit.growth1], "growth2": [DeathBenefit.growth2], "disc1": [DeathBenefit.disc1], "disc2": [DeathBenefit.disc2], "created": [DeathBenefit.created_at], }, ) items, total = paginate_with_total(q, skip, limit, include_total) if include_total: return {"items": items, "total": total or 0} return items class DeathCreate(BaseModel): file_no: str version: Optional[str] = "01" beneficiary_name: Optional[str] = None benefit_type: Optional[str] = None notes: Optional[str] = None lump1: Optional[float] = None lump2: Optional[float] = None growth1: Optional[float] = None growth2: Optional[float] = None disc1: Optional[float] = None disc2: Optional[float] = None class DeathUpdate(BaseModel): version: Optional[str] = None beneficiary_name: Optional[str] = None benefit_type: Optional[str] = None notes: Optional[str] = None lump1: Optional[float] = None lump2: Optional[float] = None growth1: Optional[float] = None growth2: Optional[float] = None disc1: Optional[float] = None disc2: Optional[float] = None @router.post("/death-benefits", response_model=DeathResponse, status_code=status.HTTP_201_CREATED) async def create_death_benefit( payload: DeathCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = DeathBenefit(**payload.model_dump(exclude_unset=True)) db.add(row) db.commit() db.refresh(row) return row @router.get("/death-benefits/{row_id}", response_model=DeathResponse) async def get_death_benefit( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(DeathBenefit).filter(DeathBenefit.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Death benefit not found") return row @router.put("/death-benefits/{row_id}", response_model=DeathResponse) async def update_death_benefit( row_id: int, payload: DeathUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(DeathBenefit).filter(DeathBenefit.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Death benefit not found") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(row, field, value) db.commit() db.refresh(row) return row @router.delete("/death-benefits/{row_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_death_benefit( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(DeathBenefit).filter(DeathBenefit.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Death benefit not found") db.delete(row) db.commit() return None class SeparationResponse(BaseModel): id: int file_no: str agreement_date: Optional[date] = None terms: Optional[str] = None notes: Optional[str] = None model_config = { "from_attributes": True, } class PaginatedSeparationsResponse(BaseModel): items: List[SeparationResponse] total: int @router.get("/separations", response_model=Union[List[SeparationResponse], PaginatedSeparationsResponse]) async def list_separations( file_no: str = Query(..., description="Filter by file number"), start: Optional[date] = Query(None, description="Start date (inclusive) for agreement_date"), end: Optional[date] = Query(None, description="End date (inclusive) for agreement_date"), version: Optional[str] = Query(None, description="Filter by version"), search: Optional[str] = Query(None, description="Tokenized search across version, terms, and notes"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), sort_by: Optional[str] = Query("id", description="Sort by: id, file_no, version, agreement_date"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): q = db.query(SeparationAgreement).filter(SeparationAgreement.file_no == file_no) if start is not None: q = q.filter(SeparationAgreement.agreement_date >= start) if end is not None: q = q.filter(SeparationAgreement.agreement_date <= end) if version is not None: q = q.filter(SeparationAgreement.version == version) if search: tokens = build_query_tokens(search) filter_expr = tokenized_ilike_filter(tokens, [ SeparationAgreement.version, SeparationAgreement.terms, SeparationAgreement.notes, ]) if filter_expr is not None: q = q.filter(filter_expr) q = apply_sorting( q, sort_by, sort_dir, allowed={ "id": [SeparationAgreement.id], "file_no": [SeparationAgreement.file_no], "version": [SeparationAgreement.version], "agreement_date": [SeparationAgreement.agreement_date], }, ) items, total = paginate_with_total(q, skip, limit, include_total) if include_total: return {"items": items, "total": total or 0} return items # ----------------------------- # Pension detail with nested lists # ----------------------------- class PensionResponse(BaseModel): id: Optional[int] = None file_no: str version: Optional[str] = None plan_id: Optional[str] = None plan_name: Optional[str] = None title: Optional[str] = None first: Optional[str] = None last: Optional[str] = None birth: Optional[date] = None race: Optional[str] = None sex: Optional[str] = None info: Optional[str] = None valu: Optional[float] = None accrued: Optional[float] = None vested_per: Optional[float] = None start_age: Optional[int] = None cola: Optional[float] = None max_cola: Optional[float] = None withdrawal: Optional[str] = None pre_dr: Optional[float] = None post_dr: Optional[float] = None tax_rate: Optional[float] = None model_config = {"from_attributes": True} class PensionDetailResponse(BaseModel): pension: Optional[PensionResponse] = None schedules: PaginatedSchedulesResponse marriages: PaginatedMarriagesResponse death_benefits: PaginatedDeathResponse separations: PaginatedSeparationsResponse @router.get("/{file_no}/detail", response_model=PensionDetailResponse) async def get_pension_detail( file_no: str, # Schedules controls s_start: Optional[date] = Query(None), s_end: Optional[date] = Query(None), s_version: Optional[str] = Query(None), s_search: Optional[str] = Query(None), s_skip: int = Query(0, ge=0), s_limit: int = Query(50, ge=1, le=200), s_sort_by: Optional[str] = Query("vests_on"), s_sort_dir: Optional[str] = Query("asc"), # Marriages controls m_start: Optional[date] = Query(None), m_end: Optional[date] = Query(None), m_version: Optional[str] = Query(None), m_search: Optional[str] = Query(None), m_skip: int = Query(0, ge=0), m_limit: int = Query(50, ge=1, le=200), m_sort_by: Optional[str] = Query("id"), m_sort_dir: Optional[str] = Query("asc"), # Death benefits controls d_start: Optional[date] = Query(None), d_end: Optional[date] = Query(None), d_version: Optional[str] = Query(None), d_search: Optional[str] = Query(None), d_skip: int = Query(0, ge=0), d_limit: int = Query(50, ge=1, le=200), d_sort_by: Optional[str] = Query("id"), d_sort_dir: Optional[str] = Query("asc"), # Separations controls sep_start: Optional[date] = Query(None), sep_end: Optional[date] = Query(None), sep_version: Optional[str] = Query(None), sep_search: Optional[str] = Query(None), sep_skip: int = Query(0, ge=0), sep_limit: int = Query(50, ge=1, le=200), sep_sort_by: Optional[str] = Query("id"), sep_sort_dir: Optional[str] = Query("asc"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): # Load a representative pension record for the file (latest by updated_at/id) pension_row = ( db.query(Pension) .filter(Pension.file_no == file_no) .order_by(Pension.updated_at.desc().nullslast(), Pension.id.desc()) .first() ) # Schedules sq = db.query(PensionSchedule).filter(PensionSchedule.file_no == file_no) if s_start is not None: sq = sq.filter(PensionSchedule.vests_on >= s_start) if s_end is not None: sq = sq.filter(PensionSchedule.vests_on <= s_end) if s_version is not None: sq = sq.filter(PensionSchedule.version == s_version) if s_search: tokens = build_query_tokens(s_search) fexpr = tokenized_ilike_filter(tokens, [PensionSchedule.version, PensionSchedule.frequency]) if fexpr is not None: sq = sq.filter(fexpr) sq = apply_sorting( sq, s_sort_by, s_sort_dir, allowed={ "id": [PensionSchedule.id], "file_no": [PensionSchedule.file_no], "version": [PensionSchedule.version], "vests_on": [PensionSchedule.vests_on], "vests_at": [PensionSchedule.vests_at], }, ) schedules_items, schedules_total = paginate_with_total(sq, s_skip, s_limit, True) # Marriages mq = db.query(MarriageHistory).filter(MarriageHistory.file_no == file_no) if m_start is not None: mq = mq.filter(MarriageHistory.married_from >= m_start) if m_end is not None: mq = mq.filter(MarriageHistory.married_from <= m_end) if m_version is not None: mq = mq.filter(MarriageHistory.version == m_version) if m_search: tokens = build_query_tokens(m_search) fexpr = tokenized_ilike_filter(tokens, [MarriageHistory.version, MarriageHistory.spouse_name, MarriageHistory.notes]) if fexpr is not None: mq = mq.filter(fexpr) mq = apply_sorting( mq, m_sort_by, m_sort_dir, allowed={ "id": [MarriageHistory.id], "file_no": [MarriageHistory.file_no], "version": [MarriageHistory.version], "married_from": [MarriageHistory.married_from], "married_to": [MarriageHistory.married_to], "marital_percent": [MarriageHistory.marital_percent], "service_from": [MarriageHistory.service_from], "service_to": [MarriageHistory.service_to], }, ) marriages_items, marriages_total = paginate_with_total(mq, m_skip, m_limit, True) # Death benefits dq = db.query(DeathBenefit).filter(DeathBenefit.file_no == file_no) if d_start is not None: dq = dq.filter(func.date(DeathBenefit.created_at) >= d_start) if d_end is not None: dq = dq.filter(func.date(DeathBenefit.created_at) <= d_end) if d_version is not None: dq = dq.filter(DeathBenefit.version == d_version) if d_search: tokens = build_query_tokens(d_search) fexpr = tokenized_ilike_filter(tokens, [DeathBenefit.version, DeathBenefit.beneficiary_name, DeathBenefit.benefit_type, DeathBenefit.notes]) if fexpr is not None: dq = dq.filter(fexpr) dq = apply_sorting( dq, d_sort_by, d_sort_dir, allowed={ "id": [DeathBenefit.id], "file_no": [DeathBenefit.file_no], "version": [DeathBenefit.version], "lump1": [DeathBenefit.lump1], "lump2": [DeathBenefit.lump2], "growth1": [DeathBenefit.growth1], "growth2": [DeathBenefit.growth2], "disc1": [DeathBenefit.disc1], "disc2": [DeathBenefit.disc2], "created": [DeathBenefit.created_at], }, ) death_items, death_total = paginate_with_total(dq, d_skip, d_limit, True) # Separations spq = db.query(SeparationAgreement).filter(SeparationAgreement.file_no == file_no) if sep_start is not None: spq = spq.filter(SeparationAgreement.agreement_date >= sep_start) if sep_end is not None: spq = spq.filter(SeparationAgreement.agreement_date <= sep_end) if sep_version is not None: spq = spq.filter(SeparationAgreement.version == sep_version) if sep_search: tokens = build_query_tokens(sep_search) fexpr = tokenized_ilike_filter(tokens, [SeparationAgreement.version, SeparationAgreement.terms, SeparationAgreement.notes]) if fexpr is not None: spq = spq.filter(fexpr) spq = apply_sorting( spq, sep_sort_by, sep_sort_dir, allowed={ "id": [SeparationAgreement.id], "file_no": [SeparationAgreement.file_no], "version": [SeparationAgreement.version], "agreement_date": [SeparationAgreement.agreement_date], }, ) sep_items, sep_total = paginate_with_total(spq, sep_skip, sep_limit, True) return { "pension": pension_row, "schedules": {"items": schedules_items, "total": schedules_total or 0}, "marriages": {"items": marriages_items, "total": marriages_total or 0}, "death_benefits": {"items": death_items, "total": death_total or 0}, "separations": {"items": sep_items, "total": sep_total or 0}, } # ----------------------------- # Pension CRUD (main table) # ----------------------------- class PensionCreate(BaseModel): file_no: str version: Optional[str] = "01" plan_id: Optional[str] = None plan_name: Optional[str] = None title: Optional[str] = None first: Optional[str] = None last: Optional[str] = None birth: Optional[date] = None race: Optional[str] = None sex: Optional[str] = None info: Optional[str] = None valu: Optional[float] = Field(default=None, ge=0) accrued: Optional[float] = Field(default=None, ge=0) vested_per: Optional[float] = Field(default=None, ge=0, le=100) start_age: Optional[int] = Field(default=None, ge=0) cola: Optional[float] = Field(default=None, ge=0) max_cola: Optional[float] = Field(default=None, ge=0) withdrawal: Optional[str] = None pre_dr: Optional[float] = Field(default=None, ge=0) post_dr: Optional[float] = Field(default=None, ge=0) tax_rate: Optional[float] = Field(default=None, ge=0, le=100) class PensionUpdate(BaseModel): version: Optional[str] = None plan_id: Optional[str] = None plan_name: Optional[str] = None title: Optional[str] = None first: Optional[str] = None last: Optional[str] = None birth: Optional[date] = None race: Optional[str] = None sex: Optional[str] = None info: Optional[str] = None valu: Optional[float] = Field(default=None, ge=0) accrued: Optional[float] = Field(default=None, ge=0) vested_per: Optional[float] = Field(default=None, ge=0, le=100) start_age: Optional[int] = Field(default=None, ge=0) cola: Optional[float] = Field(default=None, ge=0) max_cola: Optional[float] = Field(default=None, ge=0) withdrawal: Optional[str] = None pre_dr: Optional[float] = Field(default=None, ge=0) post_dr: Optional[float] = Field(default=None, ge=0) tax_rate: Optional[float] = Field(default=None, ge=0, le=100) @router.post("/", response_model=PensionResponse, status_code=status.HTTP_201_CREATED) async def create_pension( payload: PensionCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = Pension(**payload.model_dump(exclude_unset=True)) db.add(row) db.commit() db.refresh(row) return row @router.get("/{pension_id}", response_model=PensionResponse) async def get_pension( pension_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(Pension).filter(Pension.id == pension_id).first() if not row: raise HTTPException(status_code=404, detail="Pension not found") return row @router.put("/{pension_id}", response_model=PensionResponse) async def update_pension( pension_id: int, payload: PensionUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(Pension).filter(Pension.id == pension_id).first() if not row: raise HTTPException(status_code=404, detail="Pension not found") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(row, field, value) db.commit() db.refresh(row) return row @router.delete("/{pension_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_pension( pension_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(Pension).filter(Pension.id == pension_id).first() if not row: raise HTTPException(status_code=404, detail="Pension not found") db.delete(row) db.commit() return None class SeparationCreate(BaseModel): file_no: str version: Optional[str] = "01" agreement_date: Optional[date] = None terms: Optional[str] = None notes: Optional[str] = None class SeparationUpdate(BaseModel): version: Optional[str] = None agreement_date: Optional[date] = None terms: Optional[str] = None notes: Optional[str] = None @router.post("/separations", response_model=SeparationResponse, status_code=status.HTTP_201_CREATED) async def create_separation( payload: SeparationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = SeparationAgreement(**payload.model_dump(exclude_unset=True)) db.add(row) db.commit() db.refresh(row) return row @router.get("/separations/{row_id}", response_model=SeparationResponse) async def get_separation( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(SeparationAgreement).filter(SeparationAgreement.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Separation agreement not found") return row @router.put("/separations/{row_id}", response_model=SeparationResponse) async def update_separation( row_id: int, payload: SeparationUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(SeparationAgreement).filter(SeparationAgreement.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Separation agreement not found") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(row, field, value) db.commit() db.refresh(row) return row @router.delete("/separations/{row_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_separation( row_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): row = db.query(SeparationAgreement).filter(SeparationAgreement.id == row_id).first() if not row: raise HTTPException(status_code=404, detail="Separation agreement not found") db.delete(row) db.commit() return None