""" Search API endpoints for searching invoices, line items, and product definitions. """ from datetime import date, datetime, timedelta from decimal import Decimal from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_, desc from pydantic import BaseModel from database import get_db from models.user import User from models.invoice import Invoice, InvoiceStatus from models.line_item import LineItem from models.supplier import Supplier from models.product_definition import ProductDefinition from models.settings import KitchenSettings from auth import get_current_user, require_cap from services.price_history import PriceHistoryService router = APIRouter(prefix="/api/search", tags=["search"], dependencies=[Depends(require_cap("invoices"))]) # ============ Response Models ============ class GroupSummary(BaseModel): name: str count: int total: Optional[Decimal] = None class InvoiceSearchItem(BaseModel): id: int invoice_number: Optional[str] invoice_date: Optional[date] total: Optional[Decimal] net_total: Optional[Decimal] supplier_id: Optional[int] supplier_name: Optional[str] vendor_name: Optional[str] status: str document_type: Optional[str] class Config: from_attributes = True class InvoiceSearchResponse(BaseModel): items: List[InvoiceSearchItem] total_count: int grouped_by: Optional[str] groups: Optional[List[GroupSummary]] class LineItemSearchItem(BaseModel): product_code: Optional[str] description: Optional[str] supplier_id: Optional[int] supplier_name: Optional[str] unit: Optional[str] most_recent_price: Optional[Decimal] earliest_price_in_period: Optional[Decimal] price_change_percent: Optional[float] price_change_status: str total_quantity: Optional[Decimal] occurrence_count: int most_recent_invoice_id: Optional[int] most_recent_invoice_number: Optional[str] most_recent_date: Optional[date] has_definition: bool portions_per_unit: Optional[int] pack_quantity: Optional[int] most_recent_line_item_id: Optional[int] = None most_recent_line_number: Optional[int] = None most_recent_raw_content: Optional[str] = None most_recent_pack_quantity: Optional[int] = None most_recent_unit_size: Optional[Decimal] = None most_recent_unit_size_type: Optional[str] = None # Ingredient mapping info ingredient_id: Optional[int] = None ingredient_name: Optional[str] = None ingredient_standard_unit: Optional[str] = None price_per_std_unit: Optional[Decimal] = None class Config: from_attributes = True class LineItemSearchResponse(BaseModel): items: List[LineItemSearchItem] total_count: int grouped_by: Optional[str] groups: Optional[List[GroupSummary]] class DefinitionSearchItem(BaseModel): id: int product_code: Optional[str] description_pattern: Optional[str] supplier_id: Optional[int] supplier_name: Optional[str] pack_quantity: Optional[int] unit_size: Optional[Decimal] unit_size_type: Optional[str] portions_per_unit: Optional[int] portion_description: Optional[str] source_invoice_id: Optional[int] source_invoice_number: Optional[str] most_recent_price: Optional[Decimal] updated_at: datetime class Config: from_attributes = True class DefinitionSearchResponse(BaseModel): items: List[DefinitionSearchItem] total_count: int class PriceHistoryPointResponse(BaseModel): date: date price: Decimal invoice_id: int invoice_number: Optional[str] quantity: Optional[Decimal] class LineItemHistoryResponse(BaseModel): product_code: Optional[str] description: Optional[str] supplier_id: int supplier_name: Optional[str] price_history: List[PriceHistoryPointResponse] total_occurrences: int total_quantity: Decimal avg_qty_per_invoice: Decimal avg_qty_per_week: Decimal avg_qty_per_month: Decimal current_price: Optional[Decimal] price_change_status: str class AcknowledgePriceRequest(BaseModel): product_code: Optional[str] = None description: Optional[str] = None supplier_id: int new_price: Decimal source_invoice_id: Optional[int] = None source_line_item_id: Optional[int] = None class AcknowledgePriceResponse(BaseModel): id: int acknowledged_price: Decimal acknowledged_at: datetime # ============ Invoice Search ============ @router.get("/invoices", response_model=InvoiceSearchResponse) async def search_invoices( q: str = "", include_line_items: bool = False, supplier_id: Optional[int] = None, status: Optional[str] = None, date_from: Optional[date] = None, date_to: Optional[date] = None, group_by: Optional[str] = None, limit: int = Query(default=100, le=500), offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Search invoices with optional filters. - q: Search term (invoice_number, vendor_name) - include_line_items: Also search line item product_code/description - supplier_id: Filter by supplier - status: Filter by status (pending, confirmed, etc.) - date_from/date_to: Date range (default: last 30 days) - group_by: "supplier" or "month" for grouped results """ # Default date range: last 30 days if date_to is None: date_to = date.today() if date_from is None: date_from = date_to - timedelta(days=30) # Build base conditions conditions = [ Invoice.kitchen_id == current_user.kitchen_id, Invoice.invoice_date >= date_from, Invoice.invoice_date <= date_to, ] if supplier_id: conditions.append(Invoice.supplier_id == supplier_id) if status: conditions.append(Invoice.status == status) # Search filter if q: search_pattern = f"%{q}%" search_conditions = [ Invoice.invoice_number.ilike(search_pattern), Invoice.vendor_name.ilike(search_pattern), ] if include_line_items: # Need to join line items and search there too line_item_subquery = ( select(LineItem.invoice_id) .where(or_( LineItem.product_code.ilike(search_pattern), LineItem.description.ilike(search_pattern) )) .distinct() ) search_conditions.append(Invoice.id.in_(line_item_subquery)) conditions.append(or_(*search_conditions)) # Get total count count_query = select(func.count(Invoice.id)).where(and_(*conditions)) count_result = await db.execute(count_query) total_count = count_result.scalar() or 0 # Get invoices with supplier name query = ( select(Invoice, Supplier.name.label('supplier_name')) .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) .where(and_(*conditions)) .order_by(desc(Invoice.invoice_date)) .limit(limit) .offset(offset) ) result = await db.execute(query) rows = result.fetchall() items = [ InvoiceSearchItem( id=row.Invoice.id, invoice_number=row.Invoice.invoice_number, invoice_date=row.Invoice.invoice_date, total=row.Invoice.total, net_total=row.Invoice.net_total, supplier_id=row.Invoice.supplier_id, supplier_name=row.supplier_name, vendor_name=row.Invoice.vendor_name, status=row.Invoice.status.value if isinstance(row.Invoice.status, InvoiceStatus) else row.Invoice.status, document_type=row.Invoice.document_type ) for row in rows ] # Handle grouping groups = None if group_by == "supplier": group_query = ( select( Supplier.name, func.count(Invoice.id).label('count'), func.sum(Invoice.net_total).label('total') ) .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) .where(and_(*conditions)) .group_by(Supplier.name) .order_by(desc('total')) ) group_result = await db.execute(group_query) groups = [ GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2]) for row in group_result.fetchall() ] elif group_by == "month": group_query = ( select( func.to_char(Invoice.invoice_date, 'YYYY-MM').label('month'), func.count(Invoice.id).label('count'), func.sum(Invoice.net_total).label('total') ) .where(and_(*conditions)) .group_by(func.to_char(Invoice.invoice_date, 'YYYY-MM')) .order_by(desc('month')) ) group_result = await db.execute(group_query) groups = [ GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2]) for row in group_result.fetchall() ] return InvoiceSearchResponse( items=items, total_count=total_count, grouped_by=group_by, groups=groups ) # ============ Line Items Search (Consolidated) ============ @router.get("/line-items", response_model=LineItemSearchResponse) async def search_line_items( q: str = "", supplier_id: Optional[int] = None, date_from: Optional[date] = None, date_to: Optional[date] = None, group_by: Optional[str] = None, mapped: Optional[str] = Query(default=None, description="Filter by ingredient mapping: 'yes', 'no'"), limit: int = Query(default=100, le=500), offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Search line items with consolidation. Returns DISTINCT line items by (product_code OR description + supplier), with most recent price, price change status, total quantity, occurrence count. """ price_service = PriceHistoryService(db, current_user.kitchen_id) items_data, total_count = await price_service.get_consolidated_line_items( search_query=q if q else None, supplier_id=supplier_id, date_from=date_from, date_to=date_to, limit=limit, offset=offset ) items = [LineItemSearchItem(**item) for item in items_data] # Filter by ingredient mapping status if mapped == 'yes': items = [i for i in items if i.ingredient_id is not None] total_count = len(items) elif mapped == 'no': items = [i for i in items if i.ingredient_id is None] total_count = len(items) # Handle grouping (for UI display) groups = None if group_by == "supplier": # Group items by supplier supplier_groups = {} for item in items: name = item.supplier_name or "Unknown" if name not in supplier_groups: supplier_groups[name] = {"count": 0, "total": Decimal(0)} supplier_groups[name]["count"] += item.occurrence_count if item.total_quantity: supplier_groups[name]["total"] += item.total_quantity groups = [ GroupSummary(name=name, count=data["count"], total=data["total"]) for name, data in sorted(supplier_groups.items(), key=lambda x: -x[1]["count"]) ] return LineItemSearchResponse( items=items, total_count=total_count, grouped_by=group_by, groups=groups ) # ============ Definitions Search ============ @router.get("/definitions", response_model=DefinitionSearchResponse) async def search_definitions( q: str = "", supplier_id: Optional[int] = None, has_portions: Optional[bool] = None, limit: int = Query(default=100, le=500), offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Search product definitions. - q: Search term (product_code, description_pattern) - supplier_id: Filter by supplier - has_portions: Filter by whether portions_per_unit is set """ conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id] if supplier_id: conditions.append(ProductDefinition.supplier_id == supplier_id) if q: search_pattern = f"%{q}%" conditions.append(or_( ProductDefinition.product_code.ilike(search_pattern), ProductDefinition.description_pattern.ilike(search_pattern) )) if has_portions is not None: if has_portions: conditions.append(ProductDefinition.portions_per_unit.isnot(None)) else: conditions.append(ProductDefinition.portions_per_unit.is_(None)) # Get total count count_query = select(func.count(ProductDefinition.id)).where(and_(*conditions)) count_result = await db.execute(count_query) total_count = count_result.scalar() or 0 # Get definitions with supplier name and source invoice number query = ( select( ProductDefinition, Supplier.name.label('supplier_name'), Invoice.invoice_number.label('source_invoice_number') ) .outerjoin(Supplier, ProductDefinition.supplier_id == Supplier.id) .outerjoin(Invoice, ProductDefinition.source_invoice_id == Invoice.id) .where(and_(*conditions)) .order_by(desc(ProductDefinition.updated_at)) .limit(limit) .offset(offset) ) result = await db.execute(query) rows = result.fetchall() items = [] for row in rows: definition = row.ProductDefinition # Get most recent price from matching line items most_recent_price = None price_conditions = [Invoice.kitchen_id == current_user.kitchen_id] if definition.supplier_id: price_conditions.append(Invoice.supplier_id == definition.supplier_id) if definition.product_code: price_conditions.append(LineItem.product_code == definition.product_code) elif definition.description_pattern: price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%")) if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id price_query = ( select(LineItem.unit_price) .join(Invoice, LineItem.invoice_id == Invoice.id) .where(and_(*price_conditions)) .order_by(desc(Invoice.invoice_date)) .limit(1) ) price_result = await db.execute(price_query) price_row = price_result.scalar_one_or_none() if price_row is not None: most_recent_price = price_row items.append(DefinitionSearchItem( id=definition.id, product_code=definition.product_code, description_pattern=definition.description_pattern, supplier_id=definition.supplier_id, supplier_name=row.supplier_name, pack_quantity=definition.pack_quantity, unit_size=definition.unit_size, unit_size_type=definition.unit_size_type, portions_per_unit=definition.portions_per_unit, portion_description=definition.portion_description, source_invoice_id=definition.source_invoice_id, source_invoice_number=row.source_invoice_number, most_recent_price=most_recent_price, updated_at=definition.updated_at )) return DefinitionSearchResponse(items=items, total_count=total_count) # ============ Line Item History ============ @router.get("/line-items/history", response_model=LineItemHistoryResponse) async def get_line_item_history( supplier_id: int, product_code: Optional[str] = None, description: Optional[str] = None, unit: Optional[str] = None, date_from: Optional[date] = None, date_to: Optional[date] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Get price and quantity history for a specific line item. Used by the history modal to show price chart and stats. """ if not product_code and not description: raise HTTPException( status_code=400, detail="Either product_code or description is required" ) price_service = PriceHistoryService(db, current_user.kitchen_id) history = await price_service.get_history( supplier_id=supplier_id, product_code=product_code, description=description, unit=unit, date_from=date_from, date_to=date_to ) return LineItemHistoryResponse( product_code=history.product_code, description=history.description, supplier_id=history.supplier_id, supplier_name=history.supplier_name, price_history=[ PriceHistoryPointResponse( date=point.date, price=point.price, invoice_id=point.invoice_id, invoice_number=point.invoice_number, quantity=point.quantity ) for point in history.price_history ], total_occurrences=history.total_occurrences, total_quantity=history.total_quantity, avg_qty_per_invoice=history.avg_qty_per_invoice, avg_qty_per_week=history.avg_qty_per_week, avg_qty_per_month=history.avg_qty_per_month, current_price=history.current_price, price_change_status=history.price_change_status ) # ============ Price Acknowledgement ============ @router.post("/line-items/acknowledge-price", response_model=AcknowledgePriceResponse) async def acknowledge_price_change( request: AcknowledgePriceRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Acknowledge a price change for a line item. Creates or updates the AcknowledgedPrice record so the price won't be flagged as changed in future. """ if not request.product_code and not request.description: raise HTTPException( status_code=400, detail="Either product_code or description is required" ) price_service = PriceHistoryService(db, current_user.kitchen_id) acknowledged = await price_service.acknowledge_price( user_id=current_user.id, supplier_id=request.supplier_id, product_code=request.product_code, description=request.description, new_price=request.new_price, source_invoice_id=request.source_invoice_id, source_line_item_id=request.source_line_item_id ) return AcknowledgePriceResponse( id=acknowledged.id, acknowledged_price=acknowledged.acknowledged_price, acknowledged_at=acknowledged.acknowledged_at ) # ============ Search Settings ============ class SearchSettingsResponse(BaseModel): price_change_lookback_days: int price_change_amber_threshold: int price_change_red_threshold: int class SearchSettingsUpdate(BaseModel): price_change_lookback_days: Optional[int] = None price_change_amber_threshold: Optional[int] = None price_change_red_threshold: Optional[int] = None @router.get("/settings", response_model=SearchSettingsResponse) async def get_search_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get search/price change settings.""" result = await db.execute( select(KitchenSettings).where( KitchenSettings.kitchen_id == current_user.kitchen_id ) ) settings = result.scalar_one_or_none() return SearchSettingsResponse( price_change_lookback_days=settings.price_change_lookback_days if settings else 30, price_change_amber_threshold=settings.price_change_amber_threshold if settings else 10, price_change_red_threshold=settings.price_change_red_threshold if settings else 20 ) @router.patch("/settings", response_model=SearchSettingsResponse) async def update_search_settings( update: SearchSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update search/price change settings.""" if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin only") result = await db.execute( select(KitchenSettings).where( KitchenSettings.kitchen_id == current_user.kitchen_id ) ) settings = result.scalar_one_or_none() if not settings: settings = KitchenSettings(kitchen_id=current_user.kitchen_id) db.add(settings) update_data = update.model_dump(exclude_unset=True) for field, value in update_data.items(): if value is not None: setattr(settings, field, value) await db.commit() await db.refresh(settings) return SearchSettingsResponse( price_change_lookback_days=settings.price_change_lookback_days, price_change_amber_threshold=settings.price_change_amber_threshold, price_change_red_threshold=settings.price_change_red_threshold ) # ============ Definition Update ============ class DefinitionUpdateRequest(BaseModel): pack_quantity: Optional[int] = None unit_size: Optional[Decimal] = None unit_size_type: Optional[str] = None portions_per_unit: Optional[int] = None portion_description: Optional[str] = None @router.patch("/definitions/{definition_id}", response_model=DefinitionSearchItem) async def update_definition( definition_id: int, update: DefinitionUpdateRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update a product definition.""" # Fetch the definition result = await db.execute( select(ProductDefinition).where( ProductDefinition.id == definition_id, ProductDefinition.kitchen_id == current_user.kitchen_id ) ) definition = result.scalar_one_or_none() if not definition: raise HTTPException(status_code=404, detail="Definition not found") # Update fields update_data = update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(definition, field, value) # Update saved_by metadata definition.saved_by_user_id = current_user.id await db.commit() await db.refresh(definition) # Get supplier name for response supplier_name = None if definition.supplier_id: supplier_result = await db.execute( select(Supplier.name).where(Supplier.id == definition.supplier_id) ) supplier_name = supplier_result.scalar_one_or_none() # Get source invoice number source_invoice_number = None if definition.source_invoice_id: invoice_result = await db.execute( select(Invoice.invoice_number).where(Invoice.id == definition.source_invoice_id) ) source_invoice_number = invoice_result.scalar_one_or_none() # Get most recent price most_recent_price = None price_conditions = [Invoice.kitchen_id == current_user.kitchen_id] if definition.supplier_id: price_conditions.append(Invoice.supplier_id == definition.supplier_id) if definition.product_code: price_conditions.append(LineItem.product_code == definition.product_code) elif definition.description_pattern: price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%")) if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id price_query = ( select(LineItem.unit_price) .join(Invoice, LineItem.invoice_id == Invoice.id) .where(and_(*price_conditions)) .order_by(desc(Invoice.invoice_date)) .limit(1) ) price_result = await db.execute(price_query) price_row = price_result.scalar_one_or_none() if price_row is not None: most_recent_price = price_row return DefinitionSearchItem( id=definition.id, product_code=definition.product_code, description_pattern=definition.description_pattern, supplier_id=definition.supplier_id, supplier_name=supplier_name, pack_quantity=definition.pack_quantity, unit_size=definition.unit_size, unit_size_type=definition.unit_size_type, portions_per_unit=definition.portions_per_unit, portion_description=definition.portion_description, source_invoice_id=definition.source_invoice_id, source_invoice_number=source_invoice_number, most_recent_price=most_recent_price, updated_at=definition.updated_at )