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:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View 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