kitchen/backend/api/search.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

744 lines
25 KiB
Python

"""
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"])
# ============ 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
)