1120 lines
37 KiB
Python
1120 lines
37 KiB
Python
"""
|
|
Advanced Search API endpoints - Comprehensive search across all data types
|
|
"""
|
|
from typing import List, Optional, Union, Dict, Any
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
|
from sqlalchemy.orm import Session, joinedload
|
|
from sqlalchemy import or_, and_, func, desc, asc, text, case, cast, String, DateTime, Date, Numeric
|
|
from datetime import date, datetime, timedelta
|
|
from pydantic import BaseModel, Field
|
|
import json
|
|
import re
|
|
|
|
from app.database.base import get_db
|
|
from app.models.rolodex import Rolodex, Phone
|
|
from app.models.files import File
|
|
from app.models.ledger import Ledger
|
|
from app.models.qdro import QDRO
|
|
from app.models.lookups import FormIndex, Employee, FileType, FileStatus, TransactionType, TransactionCode, State
|
|
from app.models.user import User
|
|
from app.auth.security import get_current_user
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Enhanced Search Schemas
|
|
|
|
class SearchResult(BaseModel):
|
|
"""Enhanced search result with metadata"""
|
|
type: str # "customer", "file", "ledger", "qdro", "document", "template", "phone"
|
|
id: Union[str, int]
|
|
title: str
|
|
description: str
|
|
url: str
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
relevance_score: Optional[float] = None
|
|
highlight: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
|
|
class AdvancedSearchCriteria(BaseModel):
|
|
"""Advanced search criteria"""
|
|
query: Optional[str] = None
|
|
search_types: List[str] = ["customer", "file", "ledger", "qdro", "document", "template"]
|
|
|
|
# Text search options
|
|
exact_phrase: bool = False
|
|
case_sensitive: bool = False
|
|
whole_words: bool = False
|
|
|
|
# Date filters
|
|
date_field: Optional[str] = None # "created", "updated", "opened", "closed"
|
|
date_from: Optional[date] = None
|
|
date_to: Optional[date] = None
|
|
|
|
# Amount filters
|
|
amount_field: Optional[str] = None # "amount", "balance", "total_charges"
|
|
amount_min: Optional[float] = None
|
|
amount_max: Optional[float] = None
|
|
|
|
# Category filters
|
|
file_types: Optional[List[str]] = None
|
|
file_statuses: Optional[List[str]] = None
|
|
employees: Optional[List[str]] = None
|
|
transaction_types: Optional[List[str]] = None
|
|
states: Optional[List[str]] = None
|
|
|
|
# Boolean filters
|
|
active_only: bool = True
|
|
has_balance: Optional[bool] = None
|
|
is_billed: Optional[bool] = None
|
|
|
|
# Result options
|
|
sort_by: str = "relevance" # relevance, date, amount, title
|
|
sort_order: str = "desc" # asc, desc
|
|
limit: int = Field(50, ge=1, le=200)
|
|
offset: int = Field(0, ge=0)
|
|
|
|
class SearchFilter(BaseModel):
|
|
"""Individual search filter"""
|
|
field: str
|
|
operator: str # "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than", "between", "in", "not_in"
|
|
value: Union[str, int, float, List[Union[str, int, float]]]
|
|
|
|
class SavedSearch(BaseModel):
|
|
"""Saved search definition"""
|
|
id: Optional[int] = None
|
|
name: str
|
|
description: Optional[str] = None
|
|
criteria: AdvancedSearchCriteria
|
|
is_public: bool = False
|
|
created_by: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
last_used: Optional[datetime] = None
|
|
use_count: int = 0
|
|
|
|
class SearchStats(BaseModel):
|
|
"""Search statistics"""
|
|
total_customers: int
|
|
total_files: int
|
|
total_ledger_entries: int
|
|
total_qdros: int
|
|
total_documents: int
|
|
total_templates: int
|
|
total_phones: int
|
|
search_execution_time: float
|
|
|
|
class AdvancedSearchResponse(BaseModel):
|
|
"""Advanced search response"""
|
|
criteria: AdvancedSearchCriteria
|
|
results: List[SearchResult]
|
|
stats: SearchStats
|
|
facets: Dict[str, Dict[str, int]]
|
|
total_results: int
|
|
page_info: Dict[str, Any]
|
|
|
|
class GlobalSearchResponse(BaseModel):
|
|
"""Enhanced global search response"""
|
|
query: str
|
|
total_results: int
|
|
execution_time: float
|
|
customers: List[SearchResult]
|
|
files: List[SearchResult]
|
|
ledgers: List[SearchResult]
|
|
qdros: List[SearchResult]
|
|
documents: List[SearchResult]
|
|
templates: List[SearchResult]
|
|
phones: List[SearchResult]
|
|
|
|
|
|
# Advanced Search Endpoints
|
|
|
|
@router.post("/advanced", response_model=AdvancedSearchResponse)
|
|
async def advanced_search(
|
|
criteria: AdvancedSearchCriteria = Body(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Advanced search with complex criteria and filtering"""
|
|
start_time = datetime.now()
|
|
|
|
all_results = []
|
|
facets = {}
|
|
|
|
# Search each entity type based on criteria
|
|
if "customer" in criteria.search_types:
|
|
customer_results = await _search_customers(criteria, db)
|
|
all_results.extend(customer_results)
|
|
|
|
if "file" in criteria.search_types:
|
|
file_results = await _search_files(criteria, db)
|
|
all_results.extend(file_results)
|
|
|
|
if "ledger" in criteria.search_types:
|
|
ledger_results = await _search_ledger(criteria, db)
|
|
all_results.extend(ledger_results)
|
|
|
|
if "qdro" in criteria.search_types:
|
|
qdro_results = await _search_qdros(criteria, db)
|
|
all_results.extend(qdro_results)
|
|
|
|
if "document" in criteria.search_types:
|
|
document_results = await _search_documents(criteria, db)
|
|
all_results.extend(document_results)
|
|
|
|
if "template" in criteria.search_types:
|
|
template_results = await _search_templates(criteria, db)
|
|
all_results.extend(template_results)
|
|
|
|
# Sort results
|
|
sorted_results = _sort_search_results(all_results, criteria.sort_by, criteria.sort_order)
|
|
|
|
# Apply pagination
|
|
total_count = len(sorted_results)
|
|
paginated_results = sorted_results[criteria.offset:criteria.offset + criteria.limit]
|
|
|
|
# Calculate facets
|
|
facets = _calculate_facets(sorted_results)
|
|
|
|
# Calculate stats
|
|
execution_time = (datetime.now() - start_time).total_seconds()
|
|
stats = await _calculate_search_stats(db, execution_time)
|
|
|
|
# Page info
|
|
page_info = {
|
|
"current_page": (criteria.offset // criteria.limit) + 1,
|
|
"total_pages": (total_count + criteria.limit - 1) // criteria.limit,
|
|
"has_next": criteria.offset + criteria.limit < total_count,
|
|
"has_previous": criteria.offset > 0
|
|
}
|
|
|
|
return AdvancedSearchResponse(
|
|
criteria=criteria,
|
|
results=paginated_results,
|
|
stats=stats,
|
|
facets=facets,
|
|
total_results=total_count,
|
|
page_info=page_info
|
|
)
|
|
|
|
|
|
@router.get("/global", response_model=GlobalSearchResponse)
|
|
async def global_search(
|
|
q: str = Query(..., min_length=1),
|
|
limit: int = Query(10, ge=1, le=50),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Enhanced global search across all entities"""
|
|
start_time = datetime.now()
|
|
|
|
# Create criteria for global search
|
|
criteria = AdvancedSearchCriteria(
|
|
query=q,
|
|
search_types=["customer", "file", "ledger", "qdro", "document", "template"],
|
|
limit=limit
|
|
)
|
|
|
|
# Search each entity type
|
|
customer_results = await _search_customers(criteria, db)
|
|
file_results = await _search_files(criteria, db)
|
|
ledger_results = await _search_ledger(criteria, db)
|
|
qdro_results = await _search_qdros(criteria, db)
|
|
document_results = await _search_documents(criteria, db)
|
|
template_results = await _search_templates(criteria, db)
|
|
phone_results = await _search_phones(criteria, db)
|
|
|
|
total_results = (len(customer_results) + len(file_results) + len(ledger_results) +
|
|
len(qdro_results) + len(document_results) + len(template_results) + len(phone_results))
|
|
|
|
execution_time = (datetime.now() - start_time).total_seconds()
|
|
|
|
return GlobalSearchResponse(
|
|
query=q,
|
|
total_results=total_results,
|
|
execution_time=execution_time,
|
|
customers=customer_results[:limit],
|
|
files=file_results[:limit],
|
|
ledgers=ledger_results[:limit],
|
|
qdros=qdro_results[:limit],
|
|
documents=document_results[:limit],
|
|
templates=template_results[:limit],
|
|
phones=phone_results[:limit]
|
|
)
|
|
|
|
|
|
@router.get("/suggestions")
|
|
async def search_suggestions(
|
|
q: str = Query(..., min_length=1),
|
|
limit: int = Query(10, ge=1, le=20),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get search suggestions and autocomplete"""
|
|
suggestions = []
|
|
|
|
# Customer name suggestions
|
|
customers = db.query(Rolodex.first, Rolodex.last).filter(
|
|
or_(
|
|
Rolodex.first.ilike(f"{q}%"),
|
|
Rolodex.last.ilike(f"{q}%")
|
|
)
|
|
).limit(limit//2).all()
|
|
|
|
for customer in customers:
|
|
full_name = f"{customer.first or ''} {customer.last}".strip()
|
|
if full_name:
|
|
suggestions.append({
|
|
"text": full_name,
|
|
"type": "customer_name",
|
|
"category": "Customers"
|
|
})
|
|
|
|
# File number suggestions
|
|
files = db.query(File.file_no, File.regarding).filter(
|
|
File.file_no.ilike(f"{q}%")
|
|
).limit(limit//2).all()
|
|
|
|
for file_obj in files:
|
|
suggestions.append({
|
|
"text": file_obj.file_no,
|
|
"type": "file_number",
|
|
"category": "Files",
|
|
"description": file_obj.regarding
|
|
})
|
|
|
|
return {"suggestions": suggestions[:limit]}
|
|
|
|
|
|
@router.get("/facets")
|
|
async def get_search_facets(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get available search facets and filters"""
|
|
|
|
# File types
|
|
file_types = db.query(FileType.type_code, FileType.description).filter(
|
|
FileType.active == True
|
|
).all()
|
|
|
|
# File statuses
|
|
file_statuses = db.query(FileStatus.status_code, FileStatus.description).filter(
|
|
FileStatus.active == True
|
|
).all()
|
|
|
|
# Employees
|
|
employees = db.query(Employee.empl_num, Employee.first_name, Employee.last_name).filter(
|
|
Employee.active == True
|
|
).all()
|
|
|
|
# Transaction types
|
|
transaction_types = db.query(TransactionType.t_type, TransactionType.description).filter(
|
|
TransactionType.active == True
|
|
).all()
|
|
|
|
# States
|
|
states = db.query(State.abrev, State.name).filter(
|
|
State.active == True
|
|
).order_by(State.name).all()
|
|
|
|
return {
|
|
"file_types": [{"code": ft[0], "name": ft[1]} for ft in file_types],
|
|
"file_statuses": [{"code": fs[0], "name": fs[1]} for fs in file_statuses],
|
|
"employees": [{"code": emp[0], "name": f"{emp[1] or ''} {emp[2]}".strip()} for emp in employees],
|
|
"transaction_types": [{"code": tt[0], "name": tt[1]} for tt in transaction_types],
|
|
"states": [{"code": st[0], "name": st[1]} for st in states],
|
|
"date_fields": [
|
|
{"code": "created", "name": "Created Date"},
|
|
{"code": "updated", "name": "Updated Date"},
|
|
{"code": "opened", "name": "File Opened Date"},
|
|
{"code": "closed", "name": "File Closed Date"}
|
|
],
|
|
"amount_fields": [
|
|
{"code": "amount", "name": "Transaction Amount"},
|
|
{"code": "balance", "name": "Account Balance"},
|
|
{"code": "total_charges", "name": "Total Charges"}
|
|
],
|
|
"sort_options": [
|
|
{"code": "relevance", "name": "Relevance"},
|
|
{"code": "date", "name": "Date"},
|
|
{"code": "amount", "name": "Amount"},
|
|
{"code": "title", "name": "Title"}
|
|
]
|
|
}
|
|
|
|
|
|
# Legacy endpoints for backward compatibility
|
|
@router.get("/customers", response_model=List[SearchResult])
|
|
async def search_customers(
|
|
q: str = Query(..., min_length=2),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Search customers (legacy endpoint)"""
|
|
criteria = AdvancedSearchCriteria(
|
|
query=q,
|
|
search_types=["customer"],
|
|
limit=limit
|
|
)
|
|
return await _search_customers(criteria, db)
|
|
|
|
|
|
@router.get("/files", response_model=List[SearchResult])
|
|
async def search_files(
|
|
q: str = Query(..., min_length=2),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Search files (legacy endpoint)"""
|
|
criteria = AdvancedSearchCriteria(
|
|
query=q,
|
|
search_types=["file"],
|
|
limit=limit
|
|
)
|
|
return await _search_files(criteria, db)
|
|
|
|
|
|
# Search Implementation Functions
|
|
|
|
async def _search_customers(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search customers with advanced criteria"""
|
|
query = db.query(Rolodex).options(joinedload(Rolodex.phones))
|
|
|
|
if criteria.query:
|
|
search_conditions = []
|
|
|
|
if criteria.exact_phrase:
|
|
# Exact phrase search
|
|
search_term = criteria.query
|
|
search_conditions.append(
|
|
or_(
|
|
func.concat(Rolodex.first, ' ', Rolodex.last).contains(search_term),
|
|
Rolodex.memo1.contains(search_term),
|
|
Rolodex.memo2.contains(search_term)
|
|
)
|
|
)
|
|
else:
|
|
# Regular search with individual terms
|
|
search_terms = criteria.query.split()
|
|
for term in search_terms:
|
|
if criteria.case_sensitive:
|
|
search_conditions.append(
|
|
or_(
|
|
Rolodex.id.contains(term),
|
|
Rolodex.last.contains(term),
|
|
Rolodex.first.contains(term),
|
|
Rolodex.city.contains(term),
|
|
Rolodex.email.contains(term),
|
|
Rolodex.memo1.contains(term),
|
|
Rolodex.memo2.contains(term)
|
|
)
|
|
)
|
|
else:
|
|
search_conditions.append(
|
|
or_(
|
|
Rolodex.id.ilike(f"%{term}%"),
|
|
Rolodex.last.ilike(f"%{term}%"),
|
|
Rolodex.first.ilike(f"%{term}%"),
|
|
Rolodex.city.ilike(f"%{term}%"),
|
|
Rolodex.email.ilike(f"%{term}%"),
|
|
Rolodex.memo1.ilike(f"%{term}%"),
|
|
Rolodex.memo2.ilike(f"%{term}%")
|
|
)
|
|
)
|
|
|
|
if search_conditions:
|
|
query = query.filter(and_(*search_conditions))
|
|
|
|
# Apply filters
|
|
if criteria.states:
|
|
query = query.filter(Rolodex.abrev.in_(criteria.states))
|
|
|
|
# Apply date filters
|
|
if criteria.date_from or criteria.date_to:
|
|
date_field_map = {
|
|
"created": Rolodex.created_at,
|
|
"updated": Rolodex.updated_at
|
|
}
|
|
|
|
if criteria.date_field in date_field_map:
|
|
field = date_field_map[criteria.date_field]
|
|
if criteria.date_from:
|
|
query = query.filter(field >= criteria.date_from)
|
|
if criteria.date_to:
|
|
query = query.filter(field <= criteria.date_to)
|
|
|
|
customers = query.limit(criteria.limit).all()
|
|
|
|
results = []
|
|
for customer in customers:
|
|
full_name = f"{customer.first or ''} {customer.last}".strip()
|
|
location = f"{customer.city or ''}, {customer.abrev or ''}".strip(', ')
|
|
|
|
# Calculate relevance score
|
|
relevance = _calculate_customer_relevance(customer, criteria.query or "")
|
|
|
|
# Create highlight snippet
|
|
highlight = _create_customer_highlight(customer, criteria.query or "")
|
|
|
|
# Get phone numbers
|
|
phone_numbers = [p.phone for p in customer.phones] if customer.phones else []
|
|
|
|
results.append(SearchResult(
|
|
type="customer",
|
|
id=customer.id,
|
|
title=full_name or f"Customer {customer.id}",
|
|
description=f"ID: {customer.id} | {location}",
|
|
url=f"/customers?id={customer.id}",
|
|
metadata={
|
|
"location": location,
|
|
"email": customer.email,
|
|
"phones": phone_numbers,
|
|
"group": customer.group
|
|
},
|
|
relevance_score=relevance,
|
|
highlight=highlight,
|
|
created_at=customer.created_at,
|
|
updated_at=customer.updated_at
|
|
))
|
|
|
|
return results
|
|
|
|
|
|
async def _search_files(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search files with advanced criteria"""
|
|
query = db.query(File).options(joinedload(File.owner))
|
|
|
|
if criteria.query:
|
|
search_terms = criteria.query.split()
|
|
search_conditions = []
|
|
|
|
for term in search_terms:
|
|
if criteria.case_sensitive:
|
|
search_conditions.append(
|
|
or_(
|
|
File.file_no.contains(term),
|
|
File.id.contains(term),
|
|
File.regarding.contains(term),
|
|
File.file_type.contains(term),
|
|
File.memo1.contains(term),
|
|
File.memo2.contains(term)
|
|
)
|
|
)
|
|
else:
|
|
search_conditions.append(
|
|
or_(
|
|
File.file_no.ilike(f"%{term}%"),
|
|
File.id.ilike(f"%{term}%"),
|
|
File.regarding.ilike(f"%{term}%"),
|
|
File.file_type.ilike(f"%{term}%"),
|
|
File.memo1.ilike(f"%{term}%"),
|
|
File.memo2.ilike(f"%{term}%")
|
|
)
|
|
)
|
|
|
|
if search_conditions:
|
|
query = query.filter(and_(*search_conditions))
|
|
|
|
# Apply filters
|
|
if criteria.file_types:
|
|
query = query.filter(File.file_type.in_(criteria.file_types))
|
|
|
|
if criteria.file_statuses:
|
|
query = query.filter(File.status.in_(criteria.file_statuses))
|
|
|
|
if criteria.employees:
|
|
query = query.filter(File.empl_num.in_(criteria.employees))
|
|
|
|
if criteria.has_balance is not None:
|
|
if criteria.has_balance:
|
|
query = query.filter(File.amount_owing > 0)
|
|
else:
|
|
query = query.filter(File.amount_owing <= 0)
|
|
|
|
# Amount filters
|
|
if criteria.amount_min is not None or criteria.amount_max is not None:
|
|
amount_field_map = {
|
|
"balance": File.amount_owing,
|
|
"total_charges": File.total_charges
|
|
}
|
|
|
|
if criteria.amount_field in amount_field_map:
|
|
field = amount_field_map[criteria.amount_field]
|
|
if criteria.amount_min is not None:
|
|
query = query.filter(field >= criteria.amount_min)
|
|
if criteria.amount_max is not None:
|
|
query = query.filter(field <= criteria.amount_max)
|
|
|
|
# Date filters
|
|
if criteria.date_from or criteria.date_to:
|
|
date_field_map = {
|
|
"created": File.created_at,
|
|
"updated": File.updated_at,
|
|
"opened": File.opened,
|
|
"closed": File.closed
|
|
}
|
|
|
|
if criteria.date_field in date_field_map:
|
|
field = date_field_map[criteria.date_field]
|
|
if criteria.date_from:
|
|
query = query.filter(field >= criteria.date_from)
|
|
if criteria.date_to:
|
|
query = query.filter(field <= criteria.date_to)
|
|
|
|
files = query.limit(criteria.limit).all()
|
|
|
|
results = []
|
|
for file_obj in files:
|
|
client_name = ""
|
|
if file_obj.owner:
|
|
client_name = f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip()
|
|
|
|
relevance = _calculate_file_relevance(file_obj, criteria.query or "")
|
|
highlight = _create_file_highlight(file_obj, criteria.query or "")
|
|
|
|
results.append(SearchResult(
|
|
type="file",
|
|
id=file_obj.file_no,
|
|
title=f"File #{file_obj.file_no}",
|
|
description=f"Client: {client_name} | {file_obj.regarding or 'No description'} | Status: {file_obj.status}",
|
|
url=f"/files?file_no={file_obj.file_no}",
|
|
metadata={
|
|
"client_id": file_obj.id,
|
|
"client_name": client_name,
|
|
"file_type": file_obj.file_type,
|
|
"status": file_obj.status,
|
|
"employee": file_obj.empl_num,
|
|
"amount_owing": float(file_obj.amount_owing or 0),
|
|
"total_charges": float(file_obj.total_charges or 0)
|
|
},
|
|
relevance_score=relevance,
|
|
highlight=highlight,
|
|
created_at=file_obj.created_at,
|
|
updated_at=file_obj.updated_at
|
|
))
|
|
|
|
return results
|
|
|
|
|
|
async def _search_ledger(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search ledger entries with advanced criteria"""
|
|
query = db.query(Ledger).options(joinedload(Ledger.file).joinedload(File.owner))
|
|
|
|
if criteria.query:
|
|
search_terms = criteria.query.split()
|
|
search_conditions = []
|
|
|
|
for term in search_terms:
|
|
if criteria.case_sensitive:
|
|
search_conditions.append(
|
|
or_(
|
|
Ledger.file_no.contains(term),
|
|
Ledger.t_code.contains(term),
|
|
Ledger.note.contains(term),
|
|
Ledger.empl_num.contains(term)
|
|
)
|
|
)
|
|
else:
|
|
search_conditions.append(
|
|
or_(
|
|
Ledger.file_no.ilike(f"%{term}%"),
|
|
Ledger.t_code.ilike(f"%{term}%"),
|
|
Ledger.note.ilike(f"%{term}%"),
|
|
Ledger.empl_num.ilike(f"%{term}%")
|
|
)
|
|
)
|
|
|
|
if search_conditions:
|
|
query = query.filter(and_(*search_conditions))
|
|
|
|
# Apply filters
|
|
if criteria.transaction_types:
|
|
query = query.filter(Ledger.t_type.in_(criteria.transaction_types))
|
|
|
|
if criteria.employees:
|
|
query = query.filter(Ledger.empl_num.in_(criteria.employees))
|
|
|
|
if criteria.is_billed is not None:
|
|
query = query.filter(Ledger.billed == ("Y" if criteria.is_billed else "N"))
|
|
|
|
# Amount filters
|
|
if criteria.amount_min is not None:
|
|
query = query.filter(Ledger.amount >= criteria.amount_min)
|
|
if criteria.amount_max is not None:
|
|
query = query.filter(Ledger.amount <= criteria.amount_max)
|
|
|
|
# Date filters
|
|
if criteria.date_from:
|
|
query = query.filter(Ledger.date >= criteria.date_from)
|
|
if criteria.date_to:
|
|
query = query.filter(Ledger.date <= criteria.date_to)
|
|
|
|
ledgers = query.limit(criteria.limit).all()
|
|
|
|
results = []
|
|
for ledger in ledgers:
|
|
client_name = ""
|
|
if ledger.file and ledger.file.owner:
|
|
client_name = f"{ledger.file.owner.first or ''} {ledger.file.owner.last}".strip()
|
|
|
|
relevance = _calculate_ledger_relevance(ledger, criteria.query or "")
|
|
highlight = _create_ledger_highlight(ledger, criteria.query or "")
|
|
|
|
results.append(SearchResult(
|
|
type="ledger",
|
|
id=ledger.id,
|
|
title=f"Transaction {ledger.t_code} - ${ledger.amount}",
|
|
description=f"File: {ledger.file_no} | Client: {client_name} | Date: {ledger.date} | {ledger.note or 'No note'}",
|
|
url=f"/financial?file_no={ledger.file_no}",
|
|
metadata={
|
|
"file_no": ledger.file_no,
|
|
"transaction_type": ledger.t_type,
|
|
"transaction_code": ledger.t_code,
|
|
"amount": float(ledger.amount),
|
|
"quantity": float(ledger.quantity or 0),
|
|
"rate": float(ledger.rate or 0),
|
|
"employee": ledger.empl_num,
|
|
"billed": ledger.billed == "Y",
|
|
"date": ledger.date.isoformat() if ledger.date else None
|
|
},
|
|
relevance_score=relevance,
|
|
highlight=highlight,
|
|
created_at=ledger.created_at,
|
|
updated_at=ledger.updated_at
|
|
))
|
|
|
|
return results
|
|
|
|
|
|
async def _search_qdros(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search QDRO documents with advanced criteria"""
|
|
query = db.query(QDRO).options(joinedload(QDRO.file))
|
|
|
|
if criteria.query:
|
|
search_terms = criteria.query.split()
|
|
search_conditions = []
|
|
|
|
for term in search_terms:
|
|
if criteria.case_sensitive:
|
|
search_conditions.append(
|
|
or_(
|
|
QDRO.file_no.contains(term),
|
|
QDRO.title.contains(term),
|
|
QDRO.participant_name.contains(term),
|
|
QDRO.spouse_name.contains(term),
|
|
QDRO.plan_name.contains(term),
|
|
QDRO.notes.contains(term)
|
|
)
|
|
)
|
|
else:
|
|
search_conditions.append(
|
|
or_(
|
|
QDRO.file_no.ilike(f"%{term}%"),
|
|
QDRO.title.ilike(f"%{term}%"),
|
|
QDRO.participant_name.ilike(f"%{term}%"),
|
|
QDRO.spouse_name.ilike(f"%{term}%"),
|
|
QDRO.plan_name.ilike(f"%{term}%"),
|
|
QDRO.notes.ilike(f"%{term}%")
|
|
)
|
|
)
|
|
|
|
if search_conditions:
|
|
query = query.filter(and_(*search_conditions))
|
|
|
|
qdros = query.limit(criteria.limit).all()
|
|
|
|
results = []
|
|
for qdro in qdros:
|
|
relevance = _calculate_qdro_relevance(qdro, criteria.query or "")
|
|
highlight = _create_qdro_highlight(qdro, criteria.query or "")
|
|
|
|
results.append(SearchResult(
|
|
type="qdro",
|
|
id=qdro.id,
|
|
title=qdro.title or f"QDRO v{qdro.version}",
|
|
description=f"File: {qdro.file_no} | Status: {qdro.status} | Participant: {qdro.participant_name or 'N/A'}",
|
|
url=f"/documents?qdro_id={qdro.id}",
|
|
metadata={
|
|
"file_no": qdro.file_no,
|
|
"version": qdro.version,
|
|
"status": qdro.status,
|
|
"participant": qdro.participant_name,
|
|
"spouse": qdro.spouse_name,
|
|
"plan": qdro.plan_name
|
|
},
|
|
relevance_score=relevance,
|
|
highlight=highlight,
|
|
created_at=qdro.created_at,
|
|
updated_at=qdro.updated_at
|
|
))
|
|
|
|
return results
|
|
|
|
|
|
async def _search_documents(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search document templates and forms"""
|
|
query = db.query(FormIndex)
|
|
|
|
if criteria.query:
|
|
search_terms = criteria.query.split()
|
|
search_conditions = []
|
|
|
|
for term in search_terms:
|
|
if criteria.case_sensitive:
|
|
search_conditions.append(
|
|
or_(
|
|
FormIndex.form_id.contains(term),
|
|
FormIndex.form_name.contains(term),
|
|
FormIndex.category.contains(term)
|
|
)
|
|
)
|
|
else:
|
|
search_conditions.append(
|
|
or_(
|
|
FormIndex.form_id.ilike(f"%{term}%"),
|
|
FormIndex.form_name.ilike(f"%{term}%"),
|
|
FormIndex.category.ilike(f"%{term}%")
|
|
)
|
|
)
|
|
|
|
if search_conditions:
|
|
query = query.filter(and_(*search_conditions))
|
|
|
|
if criteria.active_only:
|
|
query = query.filter(FormIndex.active == True)
|
|
|
|
documents = query.limit(criteria.limit).all()
|
|
|
|
results = []
|
|
for doc in documents:
|
|
relevance = _calculate_document_relevance(doc, criteria.query or "")
|
|
|
|
results.append(SearchResult(
|
|
type="document",
|
|
id=doc.form_id,
|
|
title=doc.form_name,
|
|
description=f"Template ID: {doc.form_id} | Category: {doc.category}",
|
|
url=f"/documents?template_id={doc.form_id}",
|
|
metadata={
|
|
"form_id": doc.form_id,
|
|
"category": doc.category,
|
|
"active": doc.active
|
|
},
|
|
relevance_score=relevance,
|
|
created_at=doc.created_at,
|
|
updated_at=doc.updated_at
|
|
))
|
|
|
|
return results
|
|
|
|
|
|
async def _search_templates(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search templates (alias for documents)"""
|
|
return await _search_documents(criteria, db)
|
|
|
|
|
|
async def _search_phones(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
|
|
"""Search phone numbers"""
|
|
query = db.query(Phone).options(joinedload(Phone.person))
|
|
|
|
if criteria.query:
|
|
# Clean phone number for search (remove non-digits)
|
|
clean_query = re.sub(r'[^\d]', '', criteria.query)
|
|
|
|
query = query.filter(
|
|
or_(
|
|
Phone.phone.contains(criteria.query),
|
|
Phone.phone.contains(clean_query),
|
|
Phone.location.ilike(f"%{criteria.query}%")
|
|
)
|
|
)
|
|
|
|
phones = query.limit(criteria.limit).all()
|
|
|
|
results = []
|
|
for phone in phones:
|
|
owner_name = ""
|
|
if phone.person:
|
|
owner_name = f"{phone.person.first or ''} {phone.person.last}".strip()
|
|
|
|
results.append(SearchResult(
|
|
type="phone",
|
|
id=f"{phone.id}_{phone.phone}",
|
|
title=phone.phone,
|
|
description=f"Owner: {owner_name} | Location: {phone.location or 'Unknown'}",
|
|
url=f"/customers?id={phone.id}",
|
|
metadata={
|
|
"owner_id": phone.id,
|
|
"owner_name": owner_name,
|
|
"location": phone.location
|
|
},
|
|
relevance_score=1.0,
|
|
created_at=phone.created_at,
|
|
updated_at=phone.updated_at
|
|
))
|
|
|
|
return results
|
|
|
|
|
|
# Utility Functions
|
|
|
|
def _sort_search_results(results: List[SearchResult], sort_by: str, sort_order: str) -> List[SearchResult]:
|
|
"""Sort search results based on criteria"""
|
|
reverse = sort_order == "desc"
|
|
|
|
if sort_by == "relevance":
|
|
return sorted(results, key=lambda x: x.relevance_score or 0, reverse=reverse)
|
|
elif sort_by == "date":
|
|
return sorted(results, key=lambda x: x.updated_at or datetime.min, reverse=reverse)
|
|
elif sort_by == "amount":
|
|
return sorted(results, key=lambda x: x.metadata.get("amount", 0) if x.metadata else 0, reverse=reverse)
|
|
elif sort_by == "title":
|
|
return sorted(results, key=lambda x: x.title, reverse=reverse)
|
|
else:
|
|
return results
|
|
|
|
|
|
def _calculate_facets(results: List[SearchResult]) -> Dict[str, Dict[str, int]]:
|
|
"""Calculate facets from search results"""
|
|
facets = {
|
|
"type": {},
|
|
"file_type": {},
|
|
"status": {},
|
|
"employee": {},
|
|
"category": {}
|
|
}
|
|
|
|
for result in results:
|
|
# Type facet
|
|
facets["type"][result.type] = facets["type"].get(result.type, 0) + 1
|
|
|
|
# Metadata facets
|
|
if result.metadata:
|
|
for facet_key in ["file_type", "status", "employee", "category"]:
|
|
if facet_key in result.metadata:
|
|
value = result.metadata[facet_key]
|
|
if value:
|
|
facets[facet_key][value] = facets[facet_key].get(value, 0) + 1
|
|
|
|
return facets
|
|
|
|
|
|
async def _calculate_search_stats(db: Session, execution_time: float) -> SearchStats:
|
|
"""Calculate search statistics"""
|
|
total_customers = db.query(Rolodex).count()
|
|
total_files = db.query(File).count()
|
|
total_ledger_entries = db.query(Ledger).count()
|
|
total_qdros = db.query(QDRO).count()
|
|
total_documents = db.query(FormIndex).count()
|
|
total_templates = db.query(FormIndex).count()
|
|
total_phones = db.query(Phone).count()
|
|
|
|
return SearchStats(
|
|
total_customers=total_customers,
|
|
total_files=total_files,
|
|
total_ledger_entries=total_ledger_entries,
|
|
total_qdros=total_qdros,
|
|
total_documents=total_documents,
|
|
total_templates=total_templates,
|
|
total_phones=total_phones,
|
|
search_execution_time=execution_time
|
|
)
|
|
|
|
|
|
# Relevance calculation functions
|
|
def _calculate_customer_relevance(customer: Rolodex, query: str) -> float:
|
|
"""Calculate relevance score for customer"""
|
|
if not query:
|
|
return 1.0
|
|
|
|
score = 0.0
|
|
query_lower = query.lower()
|
|
|
|
# Exact matches get higher scores
|
|
full_name = f"{customer.first or ''} {customer.last}".strip().lower()
|
|
if query_lower == full_name:
|
|
score += 10.0
|
|
elif query_lower in full_name:
|
|
score += 5.0
|
|
|
|
# ID matches
|
|
if query_lower == (customer.id or "").lower():
|
|
score += 8.0
|
|
elif query_lower in (customer.id or "").lower():
|
|
score += 3.0
|
|
|
|
# Email matches
|
|
if customer.email and query_lower in customer.email.lower():
|
|
score += 4.0
|
|
|
|
# City matches
|
|
if customer.city and query_lower in customer.city.lower():
|
|
score += 2.0
|
|
|
|
return max(score, 0.1) # Minimum score
|
|
|
|
|
|
def _calculate_file_relevance(file_obj: File, query: str) -> float:
|
|
"""Calculate relevance score for file"""
|
|
if not query:
|
|
return 1.0
|
|
|
|
score = 0.0
|
|
query_lower = query.lower()
|
|
|
|
# File number exact match
|
|
if query_lower == (file_obj.file_no or "").lower():
|
|
score += 10.0
|
|
elif query_lower in (file_obj.file_no or "").lower():
|
|
score += 5.0
|
|
|
|
# Client ID match
|
|
if query_lower == (file_obj.id or "").lower():
|
|
score += 8.0
|
|
|
|
# Regarding field
|
|
if file_obj.regarding and query_lower in file_obj.regarding.lower():
|
|
score += 4.0
|
|
|
|
# File type
|
|
if file_obj.file_type and query_lower in file_obj.file_type.lower():
|
|
score += 3.0
|
|
|
|
return max(score, 0.1)
|
|
|
|
|
|
def _calculate_ledger_relevance(ledger: Ledger, query: str) -> float:
|
|
"""Calculate relevance score for ledger entry"""
|
|
if not query:
|
|
return 1.0
|
|
|
|
score = 0.0
|
|
query_lower = query.lower()
|
|
|
|
# File number match
|
|
if query_lower == (ledger.file_no or "").lower():
|
|
score += 8.0
|
|
elif query_lower in (ledger.file_no or "").lower():
|
|
score += 4.0
|
|
|
|
# Transaction code match
|
|
if query_lower == (ledger.t_code or "").lower():
|
|
score += 6.0
|
|
|
|
# Note content
|
|
if ledger.note and query_lower in ledger.note.lower():
|
|
score += 3.0
|
|
|
|
return max(score, 0.1)
|
|
|
|
|
|
def _calculate_qdro_relevance(qdro: QDRO, query: str) -> float:
|
|
"""Calculate relevance score for QDRO"""
|
|
if not query:
|
|
return 1.0
|
|
|
|
score = 0.0
|
|
query_lower = query.lower()
|
|
|
|
# Title exact match
|
|
if qdro.title and query_lower == qdro.title.lower():
|
|
score += 10.0
|
|
elif qdro.title and query_lower in qdro.title.lower():
|
|
score += 5.0
|
|
|
|
# Participant names
|
|
if qdro.participant_name and query_lower in qdro.participant_name.lower():
|
|
score += 6.0
|
|
|
|
if qdro.spouse_name and query_lower in qdro.spouse_name.lower():
|
|
score += 6.0
|
|
|
|
# Plan name
|
|
if qdro.plan_name and query_lower in qdro.plan_name.lower():
|
|
score += 4.0
|
|
|
|
return max(score, 0.1)
|
|
|
|
|
|
def _calculate_document_relevance(doc: FormIndex, query: str) -> float:
|
|
"""Calculate relevance score for document"""
|
|
if not query:
|
|
return 1.0
|
|
|
|
score = 0.0
|
|
query_lower = query.lower()
|
|
|
|
# Form ID exact match
|
|
if query_lower == (doc.form_id or "").lower():
|
|
score += 10.0
|
|
|
|
# Form name match
|
|
if doc.form_name and query_lower in doc.form_name.lower():
|
|
score += 5.0
|
|
|
|
# Category match
|
|
if doc.category and query_lower in doc.category.lower():
|
|
score += 3.0
|
|
|
|
return max(score, 0.1)
|
|
|
|
|
|
# Highlight functions
|
|
def _create_customer_highlight(customer: Rolodex, query: str) -> str:
|
|
"""Create highlight snippet for customer"""
|
|
if not query:
|
|
return ""
|
|
|
|
full_name = f"{customer.first or ''} {customer.last}".strip()
|
|
if query.lower() in full_name.lower():
|
|
return f"Name: {full_name}"
|
|
|
|
if customer.email and query.lower() in customer.email.lower():
|
|
return f"Email: {customer.email}"
|
|
|
|
if customer.city and query.lower() in customer.city.lower():
|
|
return f"City: {customer.city}"
|
|
|
|
return ""
|
|
|
|
|
|
def _create_file_highlight(file_obj: File, query: str) -> str:
|
|
"""Create highlight snippet for file"""
|
|
if not query:
|
|
return ""
|
|
|
|
if file_obj.regarding and query.lower() in file_obj.regarding.lower():
|
|
return f"Matter: {file_obj.regarding}"
|
|
|
|
if file_obj.file_type and query.lower() in file_obj.file_type.lower():
|
|
return f"Type: {file_obj.file_type}"
|
|
|
|
return ""
|
|
|
|
|
|
def _create_ledger_highlight(ledger: Ledger, query: str) -> str:
|
|
"""Create highlight snippet for ledger"""
|
|
if not query:
|
|
return ""
|
|
|
|
if ledger.note and query.lower() in ledger.note.lower():
|
|
return f"Note: {ledger.note[:100]}..."
|
|
|
|
return ""
|
|
|
|
|
|
def _create_qdro_highlight(qdro: QDRO, query: str) -> str:
|
|
"""Create highlight snippet for QDRO"""
|
|
if not query:
|
|
return ""
|
|
|
|
if qdro.title and query.lower() in qdro.title.lower():
|
|
return f"Title: {qdro.title}"
|
|
|
|
if qdro.participant_name and query.lower() in qdro.participant_name.lower():
|
|
return f"Participant: {qdro.participant_name}"
|
|
|
|
return "" |