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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
843
backend/services/price_history.py
Normal file
843
backend/services/price_history.py
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
"""
|
||||
Price History Service for price change detection and history tracking.
|
||||
|
||||
This service is used by:
|
||||
- Search pages for showing price change indicators
|
||||
- Invoice review for line item price status
|
||||
- History modal for viewing price trends
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Tuple, List
|
||||
from dataclasses import dataclass
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.invoice import Invoice
|
||||
from models.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.acknowledged_price import AcknowledgedPrice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_description(description: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Normalize description by taking only the first line.
|
||||
Many descriptions have newlines and additional details,
|
||||
but for matching purposes we only want the first line.
|
||||
"""
|
||||
if not description:
|
||||
return description
|
||||
# Take everything before the first newline
|
||||
return description.split('\n')[0].strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceHistoryPoint:
|
||||
"""Single point in price history."""
|
||||
date: date
|
||||
price: Decimal
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
quantity: Optional[Decimal] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceStatus:
|
||||
"""Price status result for a line item."""
|
||||
status: str # "consistent", "no_history", "amber", "red", "acknowledged"
|
||||
previous_price: Optional[Decimal] = None
|
||||
change_percent: Optional[float] = None
|
||||
acknowledged_price: Optional[Decimal] = None
|
||||
# Future price info (for old invoices)
|
||||
future_price: Optional[Decimal] = None
|
||||
future_change_percent: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineItemHistory:
|
||||
"""Full history data for a line item."""
|
||||
product_code: Optional[str]
|
||||
description: Optional[str]
|
||||
supplier_id: int
|
||||
supplier_name: Optional[str]
|
||||
# Price history for charting
|
||||
price_history: List[PriceHistoryPoint]
|
||||
# Stats
|
||||
total_occurrences: int
|
||||
total_quantity: Decimal
|
||||
avg_qty_per_invoice: Decimal
|
||||
avg_qty_per_week: Decimal
|
||||
avg_qty_per_month: Decimal
|
||||
# Current status
|
||||
current_price: Optional[Decimal]
|
||||
price_change_status: str
|
||||
|
||||
|
||||
class PriceHistoryService:
|
||||
"""Service for price history and change detection."""
|
||||
|
||||
def __init__(self, db: AsyncSession, kitchen_id: int):
|
||||
self.db = db
|
||||
self.kitchen_id = kitchen_id
|
||||
|
||||
async def _get_settings(self) -> Optional[KitchenSettings]:
|
||||
"""Get kitchen settings for price thresholds."""
|
||||
result = await self.db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kitchen_id == self.kitchen_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_acknowledged_price(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str]
|
||||
) -> Optional[AcknowledgedPrice]:
|
||||
"""Get acknowledged price for a product if it exists."""
|
||||
# Build query - need to handle NULL values in comparison
|
||||
conditions = [
|
||||
AcknowledgedPrice.kitchen_id == self.kitchen_id,
|
||||
AcknowledgedPrice.supplier_id == supplier_id,
|
||||
]
|
||||
|
||||
# Handle product_code - compare with IS NULL for None
|
||||
if product_code:
|
||||
conditions.append(AcknowledgedPrice.product_code == product_code)
|
||||
else:
|
||||
conditions.append(AcknowledgedPrice.product_code.is_(None))
|
||||
|
||||
# Handle description - compare with IS NULL for None
|
||||
if description:
|
||||
conditions.append(AcknowledgedPrice.description == description)
|
||||
else:
|
||||
conditions.append(AcknowledgedPrice.description.is_(None))
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AcknowledgedPrice).where(and_(*conditions))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_previous_prices(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
unit: Optional[str],
|
||||
lookback_days: int,
|
||||
exclude_invoice_id: Optional[int] = None,
|
||||
reference_date: Optional[date] = None
|
||||
) -> List[Tuple[Decimal, date]]:
|
||||
"""Get previous prices for a product within lookback period."""
|
||||
# Use reference_date if provided (invoice date), otherwise use today
|
||||
ref_date = reference_date or date.today()
|
||||
cutoff_date = ref_date - timedelta(days=lookback_days)
|
||||
|
||||
# Build conditions for matching product
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id,
|
||||
Invoice.invoice_date >= cutoff_date,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
]
|
||||
|
||||
# Match by product_code if available, otherwise by description
|
||||
if product_code:
|
||||
conditions.append(LineItem.product_code == product_code)
|
||||
else:
|
||||
conditions.append(LineItem.product_code.is_(None))
|
||||
if description:
|
||||
# Normalize description to first line only for matching
|
||||
normalized_desc = normalize_description(description)
|
||||
# Match against first line of stored descriptions
|
||||
conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == normalized_desc
|
||||
)
|
||||
|
||||
# Match by unit - critical for products sold in different units (Box, Each, Kg, etc.)
|
||||
if unit:
|
||||
conditions.append(LineItem.unit == unit)
|
||||
else:
|
||||
conditions.append(LineItem.unit.is_(None))
|
||||
|
||||
# Exclude current invoice if provided
|
||||
if exclude_invoice_id:
|
||||
conditions.append(Invoice.id != exclude_invoice_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(LineItem.unit_price, Invoice.invoice_date)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
)
|
||||
|
||||
return [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
async def _get_future_prices(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
unit: Optional[str],
|
||||
reference_date: date,
|
||||
exclude_invoice_id: Optional[int] = None
|
||||
) -> List[Tuple[Decimal, date]]:
|
||||
"""Get future prices for a product (after reference_date)."""
|
||||
# Build conditions for matching product
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id,
|
||||
Invoice.invoice_date > reference_date,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
]
|
||||
|
||||
# Match by product_code if available, otherwise by description
|
||||
if product_code:
|
||||
conditions.append(LineItem.product_code == product_code)
|
||||
else:
|
||||
conditions.append(LineItem.product_code.is_(None))
|
||||
if description:
|
||||
normalized_desc = normalize_description(description)
|
||||
conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == normalized_desc
|
||||
)
|
||||
|
||||
# Match by unit
|
||||
if unit:
|
||||
conditions.append(LineItem.unit == unit)
|
||||
else:
|
||||
conditions.append(LineItem.unit.is_(None))
|
||||
|
||||
# Exclude current invoice if provided
|
||||
if exclude_invoice_id:
|
||||
conditions.append(Invoice.id != exclude_invoice_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(LineItem.unit_price, Invoice.invoice_date)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
)
|
||||
|
||||
return [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
async def get_price_status(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
current_price: Decimal,
|
||||
unit: Optional[str] = None,
|
||||
current_invoice_id: Optional[int] = None,
|
||||
reference_date: Optional[date] = None,
|
||||
lookback_days: Optional[int] = None,
|
||||
amber_threshold: Optional[int] = None,
|
||||
red_threshold: Optional[int] = None,
|
||||
) -> PriceStatus:
|
||||
"""
|
||||
Get price status for a line item.
|
||||
|
||||
Returns:
|
||||
PriceStatus with:
|
||||
- "consistent": Price matches history (green tick)
|
||||
- "no_history": First time seeing this item (no icon)
|
||||
- "amber": Small price change within threshold
|
||||
- "red": Large price change above threshold
|
||||
- "acknowledged": Price was flagged but user acknowledged it
|
||||
"""
|
||||
# Get settings if thresholds not provided
|
||||
if lookback_days is None or amber_threshold is None or red_threshold is None:
|
||||
settings = await self._get_settings()
|
||||
lookback_days = lookback_days or (settings.price_change_lookback_days if settings else 30)
|
||||
amber_threshold = amber_threshold or (settings.price_change_amber_threshold if settings else 10)
|
||||
red_threshold = red_threshold or (settings.price_change_red_threshold if settings else 20)
|
||||
|
||||
# Get previous prices
|
||||
previous_prices = await self._get_previous_prices(
|
||||
supplier_id, product_code, description, unit, lookback_days, current_invoice_id, reference_date
|
||||
)
|
||||
|
||||
# Get future prices (if viewing an old invoice)
|
||||
future_price = None
|
||||
future_change_percent = None
|
||||
if reference_date:
|
||||
future_prices = await self._get_future_prices(
|
||||
supplier_id, product_code, description, unit, reference_date, current_invoice_id
|
||||
)
|
||||
if future_prices:
|
||||
future_price = future_prices[0][0] # Most recent future price
|
||||
if future_price and future_price != 0:
|
||||
future_change_percent = float((future_price - current_price) / current_price * 100)
|
||||
|
||||
if not previous_prices:
|
||||
return PriceStatus(
|
||||
status="no_history",
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
# Get most recent previous price
|
||||
previous_price = previous_prices[0][0]
|
||||
|
||||
# Calculate change percentage
|
||||
if previous_price and previous_price != 0:
|
||||
change_percent = float((current_price - previous_price) / previous_price * 100)
|
||||
else:
|
||||
change_percent = 0.0
|
||||
|
||||
# If there's a future price change, suppress regular price change indicator
|
||||
# (only show the grey future price indicator)
|
||||
# But still include previous_price/change_percent so price history button works
|
||||
if future_price and future_change_percent is not None:
|
||||
return PriceStatus(
|
||||
status="consistent", # Hide regular indicator
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
abs_change = abs(change_percent)
|
||||
|
||||
# Check if price is acknowledged
|
||||
acknowledged = await self._get_acknowledged_price(
|
||||
supplier_id, product_code, description
|
||||
)
|
||||
|
||||
if acknowledged and acknowledged.acknowledged_price == current_price:
|
||||
return PriceStatus(
|
||||
status="acknowledged",
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
acknowledged_price=acknowledged.acknowledged_price,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
# Determine status based on change
|
||||
if abs_change <= 0.01: # Essentially no change (floating point tolerance)
|
||||
return PriceStatus(
|
||||
status="consistent",
|
||||
previous_price=previous_price,
|
||||
change_percent=0.0,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
elif abs_change <= red_threshold:
|
||||
return PriceStatus(
|
||||
status="amber", # Any change > 0.01% up to red threshold shows amber
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
else:
|
||||
return PriceStatus(
|
||||
status="red",
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
unit: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
) -> LineItemHistory:
|
||||
"""
|
||||
Get full history for a product including price history and quantity stats.
|
||||
|
||||
Args:
|
||||
supplier_id: Supplier ID
|
||||
product_code: Product code (may be None)
|
||||
description: Description (used if no product_code)
|
||||
unit: Unit (Box, Each, Kg, etc.)
|
||||
date_from: Start date (default: 12 months ago)
|
||||
date_to: End date (default: today)
|
||||
|
||||
Returns:
|
||||
LineItemHistory with price history and stats
|
||||
"""
|
||||
# Default date range: 12 months
|
||||
if date_to is None:
|
||||
date_to = date.today()
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=365)
|
||||
|
||||
# Build conditions for matching product
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id,
|
||||
Invoice.invoice_date >= date_from,
|
||||
Invoice.invoice_date <= date_to,
|
||||
]
|
||||
|
||||
# Match by product_code if available, otherwise by description
|
||||
if product_code:
|
||||
conditions.append(LineItem.product_code == product_code)
|
||||
else:
|
||||
conditions.append(LineItem.product_code.is_(None))
|
||||
if description:
|
||||
# Normalize description to first line only for matching
|
||||
normalized_desc = normalize_description(description)
|
||||
# Match against first line of stored descriptions
|
||||
conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == normalized_desc
|
||||
)
|
||||
|
||||
# Match by unit - critical for products sold in different units
|
||||
if unit:
|
||||
conditions.append(LineItem.unit == unit)
|
||||
else:
|
||||
conditions.append(LineItem.unit.is_(None))
|
||||
|
||||
# Get price history points
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
Invoice.invoice_date,
|
||||
LineItem.unit_price,
|
||||
LineItem.quantity,
|
||||
Invoice.id,
|
||||
Invoice.invoice_number,
|
||||
Invoice.document_type,
|
||||
)
|
||||
.where(and_(*conditions))
|
||||
.order_by(Invoice.invoice_date)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
price_history = []
|
||||
total_qty = Decimal(0)
|
||||
total_occurrences = 0
|
||||
current_price = None
|
||||
|
||||
for row in rows:
|
||||
inv_date, unit_price, qty, inv_id, inv_num, doc_type = row
|
||||
if unit_price is not None:
|
||||
price_history.append(PriceHistoryPoint(
|
||||
date=inv_date,
|
||||
price=unit_price,
|
||||
invoice_id=inv_id,
|
||||
invoice_number=inv_num,
|
||||
quantity=qty
|
||||
))
|
||||
# Only use positive prices from non-credit-notes for current price
|
||||
if unit_price > 0 and doc_type != 'credit_note':
|
||||
current_price = unit_price
|
||||
if qty:
|
||||
total_qty += qty
|
||||
total_occurrences += 1
|
||||
|
||||
# Calculate averages
|
||||
avg_qty_per_invoice = total_qty / total_occurrences if total_occurrences > 0 else Decimal(0)
|
||||
|
||||
# Calculate weeks and months in period
|
||||
days_in_period = (date_to - date_from).days or 1
|
||||
weeks_in_period = max(days_in_period / 7, 1)
|
||||
months_in_period = max(days_in_period / 30, 1)
|
||||
|
||||
avg_qty_per_week = total_qty / Decimal(str(weeks_in_period))
|
||||
avg_qty_per_month = total_qty / Decimal(str(months_in_period))
|
||||
|
||||
# Get supplier name
|
||||
supplier_result = await self.db.execute(
|
||||
select(Supplier.name).where(Supplier.id == supplier_id)
|
||||
)
|
||||
supplier_name = supplier_result.scalar_one_or_none()
|
||||
|
||||
# Determine price change status for current price
|
||||
if current_price and len(price_history) > 1:
|
||||
status = await self.get_price_status(
|
||||
supplier_id, product_code, description, current_price, unit
|
||||
)
|
||||
price_change_status = status.status
|
||||
else:
|
||||
price_change_status = "no_history" if not price_history else "consistent"
|
||||
|
||||
return LineItemHistory(
|
||||
product_code=product_code,
|
||||
description=description,
|
||||
supplier_id=supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
price_history=price_history,
|
||||
total_occurrences=total_occurrences,
|
||||
total_quantity=total_qty,
|
||||
avg_qty_per_invoice=avg_qty_per_invoice,
|
||||
avg_qty_per_week=avg_qty_per_week,
|
||||
avg_qty_per_month=avg_qty_per_month,
|
||||
current_price=current_price,
|
||||
price_change_status=price_change_status
|
||||
)
|
||||
|
||||
async def acknowledge_price(
|
||||
self,
|
||||
user_id: int,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
new_price: Decimal,
|
||||
source_invoice_id: Optional[int] = None,
|
||||
source_line_item_id: Optional[int] = None,
|
||||
) -> AcknowledgedPrice:
|
||||
"""
|
||||
Acknowledge a price change for a product.
|
||||
|
||||
Creates or updates the AcknowledgedPrice record so the price
|
||||
won't be flagged in future.
|
||||
"""
|
||||
# Check if already exists
|
||||
existing = await self._get_acknowledged_price(
|
||||
supplier_id, product_code, description
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing record
|
||||
existing.acknowledged_price = new_price
|
||||
existing.acknowledged_at = datetime.utcnow()
|
||||
existing.acknowledged_by_user_id = user_id
|
||||
existing.source_invoice_id = source_invoice_id
|
||||
existing.source_line_item_id = source_line_item_id
|
||||
await self.db.commit()
|
||||
return existing
|
||||
else:
|
||||
# Create new record
|
||||
acknowledged = AcknowledgedPrice(
|
||||
kitchen_id=self.kitchen_id,
|
||||
supplier_id=supplier_id,
|
||||
product_code=product_code,
|
||||
description=description,
|
||||
acknowledged_price=new_price,
|
||||
acknowledged_by_user_id=user_id,
|
||||
source_invoice_id=source_invoice_id,
|
||||
source_line_item_id=source_line_item_id
|
||||
)
|
||||
self.db.add(acknowledged)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(acknowledged)
|
||||
return acknowledged
|
||||
|
||||
async def get_consolidated_line_items(
|
||||
self,
|
||||
search_query: Optional[str] = None,
|
||||
supplier_id: Optional[int] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Tuple[List[dict], int]:
|
||||
"""
|
||||
Get consolidated line items for search results.
|
||||
|
||||
Groups line items by (product_code OR description) + supplier,
|
||||
returning most recent price, total quantity, occurrence count, etc.
|
||||
|
||||
Returns:
|
||||
(list of consolidated items, total count)
|
||||
"""
|
||||
# Build base conditions — search all history unless dates explicitly provided
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
]
|
||||
|
||||
if date_from is not None:
|
||||
conditions.append(Invoice.invoice_date >= date_from)
|
||||
if date_to is not None:
|
||||
conditions.append(Invoice.invoice_date <= date_to)
|
||||
|
||||
if supplier_id:
|
||||
conditions.append(Invoice.supplier_id == supplier_id)
|
||||
|
||||
if search_query:
|
||||
# Split into words so "Cod Fillet" matches "COD: FILLET 1-2KG SCALED BONED"
|
||||
words = search_query.strip().split()
|
||||
if len(words) == 1:
|
||||
search_pattern = f"%{words[0]}%"
|
||||
conditions.append(or_(
|
||||
LineItem.product_code.ilike(search_pattern),
|
||||
LineItem.description.ilike(search_pattern)
|
||||
))
|
||||
else:
|
||||
# All words must appear in description (or exact phrase matches product_code)
|
||||
word_conditions = []
|
||||
for word in words:
|
||||
word_conditions.append(LineItem.description.ilike(f"%{word}%"))
|
||||
conditions.append(or_(
|
||||
and_(*word_conditions),
|
||||
LineItem.product_code.ilike(f"%{search_query}%")
|
||||
))
|
||||
|
||||
# Build query for consolidated items using subquery
|
||||
# We need to group by product identity and get aggregates
|
||||
|
||||
# First, get the consolidation key and aggregates
|
||||
from sqlalchemy import case, literal_column
|
||||
|
||||
# Create expression for first line of description (reuse this in SELECT and GROUP BY)
|
||||
desc_first_line = func.split_part(LineItem.description, '\n', 1)
|
||||
|
||||
# Create a composite key for grouping
|
||||
# Use first line of description only for grouping
|
||||
consolidation_key = func.concat(
|
||||
func.coalesce(LineItem.product_code, ''),
|
||||
'||',
|
||||
func.coalesce(desc_first_line, ''),
|
||||
'||',
|
||||
func.cast(Invoice.supplier_id, String)
|
||||
)
|
||||
|
||||
# Get aggregated data
|
||||
# Note: We don't group by unit since the same product may have slightly different
|
||||
# unit values across invoices. We'll pick the most recent unit in the detail query.
|
||||
agg_query = (
|
||||
select(
|
||||
LineItem.product_code,
|
||||
desc_first_line.label('description'),
|
||||
Invoice.supplier_id,
|
||||
Supplier.name.label('supplier_name'),
|
||||
func.sum(LineItem.quantity).label('total_quantity'),
|
||||
func.count(LineItem.id).label('occurrence_count'),
|
||||
func.max(Invoice.invoice_date).label('most_recent_date'),
|
||||
)
|
||||
.select_from(LineItem)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.join(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(and_(*conditions))
|
||||
.group_by(
|
||||
LineItem.product_code,
|
||||
desc_first_line,
|
||||
Invoice.supplier_id,
|
||||
Supplier.name,
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_subquery = agg_query.subquery()
|
||||
count_result = await self.db.execute(
|
||||
select(func.count()).select_from(count_subquery)
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Get paginated results
|
||||
agg_result = await self.db.execute(
|
||||
agg_query.order_by(desc('most_recent_date')).limit(limit).offset(offset)
|
||||
)
|
||||
rows = agg_result.fetchall()
|
||||
|
||||
# Build result list with additional data
|
||||
items = []
|
||||
for row in rows:
|
||||
product_code = row.product_code
|
||||
description = row.description
|
||||
supplier_id_val = row.supplier_id
|
||||
|
||||
# Get most recent price, invoice info, and unit
|
||||
# Skip credit notes and zero/negative prices (free replacements)
|
||||
recent_conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id_val,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
LineItem.product_code == product_code if product_code else LineItem.product_code.is_(None),
|
||||
]
|
||||
if description:
|
||||
recent_conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == description
|
||||
)
|
||||
|
||||
recent_query = (
|
||||
select(
|
||||
LineItem.unit_price,
|
||||
Invoice.id,
|
||||
Invoice.invoice_number,
|
||||
LineItem.unit,
|
||||
LineItem.id.label('line_item_id'),
|
||||
LineItem.line_number,
|
||||
LineItem.raw_content,
|
||||
LineItem.pack_quantity.label('li_pack_quantity'),
|
||||
LineItem.unit_size,
|
||||
LineItem.unit_size_type,
|
||||
)
|
||||
.where(and_(*recent_conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(1)
|
||||
)
|
||||
recent_result = await self.db.execute(recent_query)
|
||||
recent_row = recent_result.fetchone()
|
||||
|
||||
most_recent_price = recent_row[0] if recent_row else None
|
||||
most_recent_invoice_id = recent_row[1] if recent_row else None
|
||||
most_recent_invoice_number = recent_row[2] if recent_row else None
|
||||
unit = recent_row[3] if recent_row else None
|
||||
most_recent_line_item_id = recent_row[4] if recent_row else None
|
||||
most_recent_line_number = recent_row[5] if recent_row else None
|
||||
most_recent_raw_content = recent_row[6] if recent_row else None
|
||||
most_recent_pack_quantity = recent_row[7] if recent_row else None
|
||||
most_recent_unit_size = recent_row[8] if recent_row else None
|
||||
most_recent_unit_size_type = recent_row[9] if recent_row else None
|
||||
|
||||
# Get earliest price in period for change detection
|
||||
# Only when date range is provided (skip for undated searches like IngredientModal)
|
||||
price_change_percent = None
|
||||
price_change_status = "no_history"
|
||||
earliest_price = None
|
||||
|
||||
if date_from is not None and date_to is not None:
|
||||
# Match by first line of description only
|
||||
# Skip credit notes and zero/negative prices
|
||||
earliest_conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id_val,
|
||||
Invoice.invoice_date >= date_from,
|
||||
Invoice.invoice_date <= date_to,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
LineItem.product_code == product_code if product_code else LineItem.product_code.is_(None),
|
||||
]
|
||||
if description:
|
||||
earliest_conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == description
|
||||
)
|
||||
|
||||
earliest_query = (
|
||||
select(LineItem.unit_price)
|
||||
.where(and_(*earliest_conditions))
|
||||
.order_by(Invoice.invoice_date)
|
||||
.limit(1)
|
||||
)
|
||||
earliest_result = await self.db.execute(earliest_query)
|
||||
earliest_row = earliest_result.fetchone()
|
||||
earliest_price = earliest_row[0] if earliest_row else None
|
||||
|
||||
if most_recent_price is not None and earliest_price is not None:
|
||||
if earliest_price != 0:
|
||||
price_change_percent = float(
|
||||
(most_recent_price - earliest_price) / earliest_price * 100
|
||||
)
|
||||
|
||||
# Get price status
|
||||
# Extend lookback to ensure we have history beyond the search period
|
||||
if most_recent_price:
|
||||
# Calculate extended lookback: search period + configured lookback days
|
||||
search_period_days = (date_to - date_from).days
|
||||
extended_lookback = search_period_days + 30 # Add configured lookback on top
|
||||
|
||||
status = await self.get_price_status(
|
||||
supplier_id_val, product_code, description, most_recent_price,
|
||||
unit=unit,
|
||||
lookback_days=extended_lookback,
|
||||
current_invoice_id=most_recent_invoice_id # Exclude most recent invoice from comparison
|
||||
)
|
||||
price_change_status = status.status
|
||||
|
||||
# Check if has definition
|
||||
from models.product_definition import ProductDefinition
|
||||
|
||||
# Build conditions for definition lookup
|
||||
def_conditions = [
|
||||
ProductDefinition.kitchen_id == self.kitchen_id,
|
||||
ProductDefinition.supplier_id == supplier_id_val,
|
||||
]
|
||||
|
||||
# Match by product_code (preferred) OR description (fallback), not both
|
||||
if product_code:
|
||||
# Exact match by product_code only
|
||||
def_conditions.append(ProductDefinition.product_code == product_code)
|
||||
else:
|
||||
# Match by description, but ONLY definitions without product_code
|
||||
def_conditions.append(ProductDefinition.product_code.is_(None))
|
||||
if description:
|
||||
def_conditions.append(ProductDefinition.description_pattern == description)
|
||||
|
||||
def_query = (
|
||||
select(ProductDefinition.portions_per_unit, ProductDefinition.pack_quantity)
|
||||
.where(and_(*def_conditions))
|
||||
.limit(1)
|
||||
)
|
||||
def_result = await self.db.execute(def_query)
|
||||
def_row = def_result.fetchone()
|
||||
|
||||
# Look up ingredient source mapping
|
||||
from models.ingredient import Ingredient, IngredientSource
|
||||
|
||||
src_conditions = [
|
||||
IngredientSource.kitchen_id == self.kitchen_id,
|
||||
IngredientSource.supplier_id == supplier_id_val,
|
||||
]
|
||||
|
||||
# Match by product_code (preferred) OR description_pattern (fallback)
|
||||
if product_code:
|
||||
src_conditions.append(IngredientSource.product_code == product_code)
|
||||
else:
|
||||
src_conditions.append(IngredientSource.product_code.is_(None))
|
||||
if description:
|
||||
src_conditions.append(func.lower(IngredientSource.description_pattern) == description.lower())
|
||||
|
||||
src_query = (
|
||||
select(
|
||||
IngredientSource.ingredient_id,
|
||||
Ingredient.name.label('ingredient_name'),
|
||||
Ingredient.standard_unit.label('ingredient_standard_unit'),
|
||||
IngredientSource.price_per_std_unit,
|
||||
)
|
||||
.join(Ingredient, IngredientSource.ingredient_id == Ingredient.id)
|
||||
.where(and_(*src_conditions))
|
||||
.limit(1)
|
||||
)
|
||||
src_result = await self.db.execute(src_query)
|
||||
src_row = src_result.fetchone()
|
||||
|
||||
items.append({
|
||||
'product_code': product_code,
|
||||
'description': description,
|
||||
'supplier_id': supplier_id_val,
|
||||
'supplier_name': row.supplier_name,
|
||||
'unit': unit,
|
||||
'most_recent_price': most_recent_price,
|
||||
'earliest_price_in_period': earliest_price,
|
||||
'price_change_percent': price_change_percent,
|
||||
'price_change_status': price_change_status,
|
||||
'total_quantity': row.total_quantity,
|
||||
'occurrence_count': row.occurrence_count,
|
||||
'most_recent_invoice_id': most_recent_invoice_id,
|
||||
'most_recent_invoice_number': most_recent_invoice_number,
|
||||
'most_recent_date': row.most_recent_date,
|
||||
'has_definition': def_row is not None,
|
||||
'portions_per_unit': def_row[0] if def_row else None,
|
||||
'pack_quantity': def_row[1] if def_row else None,
|
||||
'most_recent_line_item_id': most_recent_line_item_id,
|
||||
'most_recent_line_number': most_recent_line_number,
|
||||
'most_recent_raw_content': most_recent_raw_content,
|
||||
'most_recent_pack_quantity': most_recent_pack_quantity,
|
||||
'most_recent_unit_size': most_recent_unit_size,
|
||||
'most_recent_unit_size_type': most_recent_unit_size_type,
|
||||
'ingredient_id': src_row[0] if src_row else None,
|
||||
'ingredient_name': src_row[1] if src_row else None,
|
||||
'ingredient_standard_unit': src_row[2] if src_row else None,
|
||||
'price_per_std_unit': src_row[3] if src_row else None,
|
||||
})
|
||||
|
||||
return items, total_count
|
||||
|
||||
|
||||
# Import String for cast
|
||||
from sqlalchemy import String
|
||||
Loading…
Add table
Add a link
Reference in a new issue