The in-app page-restriction system (admin-only toggles in Settings) was
nav-hiding only and duplicated functionality already covered by JWT caps
in the main stack auth service. All 9 pages in the restriction list were
already gated in Layout.tsx by existing caps.
Backend: add router-level requireCap() to enforce caps at the API layer:
- reports.py: Depends(require_cap("view"))
- logbook.py: Depends(require_cap("logbook"))
- search.py: Depends(require_cap("invoices"))
Frontend: remove the Access Control settings section entirely:
- Drop pageRestrictions query, restrictedPages/accessSaveMessage state,
savePageRestrictionsMutation, isSectionAccessible helper
- Remove 'access' from SettingsSection type and sidebarItems
- Strip restrictPath from all sidebar items (no longer needed)
Access management is now fully centralised in the main stack auth service.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
744 lines
25 KiB
Python
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"], 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
|
|
)
|