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,3 @@
from .duplicate_detector import DuplicateDetector, detect_document_type
__all__ = ["DuplicateDetector", "detect_document_type"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,712 @@
"""
Backup service for database and file backups.
Handles:
- Full PostgreSQL database dump (pg_dump)
- Application data JSON export
- Invoice file archiving
- Backup to local/Nextcloud/SMB destinations
- Retention policy enforcement
- Restore operations
"""
import os
import json
import zipfile
import tempfile
import logging
import shutil
import asyncio
from datetime import datetime
from decimal import Decimal
from typing import Tuple, Optional, List
from urllib.parse import urlparse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.backup import BackupHistory
from models.settings import KitchenSettings
from models.invoice import Invoice
from models.line_item import LineItem
from models.supplier import Supplier
from services.nextcloud_service import NextcloudService
logger = logging.getLogger(__name__)
# Database connection settings - parse from DATABASE_URL if available
_db_url = os.getenv("DATABASE_URL", "")
if _db_url:
_parsed = urlparse(_db_url)
DB_HOST = _parsed.hostname or "db"
DB_PORT = str(_parsed.port or 5432)
DB_NAME = (_parsed.path or "/kitchen_gp").lstrip('/')
DB_USER = _parsed.username or "kitchen"
DB_PASSWORD = _parsed.password or "kitchen_secret"
else:
DB_HOST = os.getenv("DATABASE_HOST", "db")
DB_PORT = os.getenv("DATABASE_PORT", "5432")
DB_NAME = os.getenv("DATABASE_NAME", "kitchen_gp")
DB_USER = os.getenv("DATABASE_USER", "kitchen")
DB_PASSWORD = os.getenv("DATABASE_PASSWORD", "kitchen_secret")
# Local backup directory (inside persisted data volume)
LOCAL_BACKUP_DIR = "/app/data/backups"
class DecimalEncoder(json.JSONEncoder):
"""JSON encoder that handles Decimal types"""
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, datetime):
return obj.isoformat()
if hasattr(obj, 'isoformat'): # date objects
return obj.isoformat()
return super().default(obj)
class BackupService:
"""Service for managing backups"""
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"""
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
return result.scalar_one_or_none()
def _generate_backup_filename(self) -> str:
"""Generate unique backup filename"""
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
return f"backup_kitchen{self.kitchen_id}_{timestamp}.zip"
async def _create_database_export(self, output_path: str) -> bool:
"""
Create JSON export of kitchen data.
Exports invoices, line items, suppliers, and settings.
"""
try:
logger.info(f"Creating database export for kitchen {self.kitchen_id}")
# Export invoices with line items
invoices_result = await self.db.execute(
select(Invoice).options(
selectinload(Invoice.line_items)
).where(Invoice.kitchen_id == self.kitchen_id)
)
invoices = invoices_result.scalars().all()
# Export suppliers
suppliers_result = await self.db.execute(
select(Supplier).where(Supplier.kitchen_id == self.kitchen_id)
)
suppliers = suppliers_result.scalars().all()
# Export settings
settings = await self.get_settings()
export_data = {
"kitchen_id": self.kitchen_id,
"exported_at": datetime.utcnow().isoformat(),
"version": "1.0",
"invoices": [
{
"id": inv.id,
"invoice_number": inv.invoice_number,
"invoice_date": inv.invoice_date,
"total": inv.total,
"net_total": inv.net_total,
"supplier_id": inv.supplier_id,
"vendor_name": inv.vendor_name,
"supplier_match_type": inv.supplier_match_type,
"document_type": inv.document_type,
"order_number": inv.order_number,
"status": inv.status.value if inv.status else None,
"category": inv.category,
"image_path": inv.image_path,
"file_storage_location": inv.file_storage_location,
"nextcloud_path": inv.nextcloud_path,
"original_local_path": inv.original_local_path,
"ocr_confidence": inv.ocr_confidence,
"notes": inv.notes,
"dext_sent_at": inv.dext_sent_at,
"created_at": inv.created_at,
"updated_at": inv.updated_at,
"line_items": [
{
"id": li.id,
"product_code": li.product_code,
"description": li.description,
"unit": li.unit,
"quantity": li.quantity,
"order_quantity": li.order_quantity,
"unit_price": li.unit_price,
"tax_rate": li.tax_rate,
"tax_amount": li.tax_amount,
"amount": li.amount,
"line_number": li.line_number,
"is_non_stock": li.is_non_stock,
"pack_quantity": li.pack_quantity,
"unit_size": li.unit_size,
"unit_size_type": li.unit_size_type,
"portions_per_unit": li.portions_per_unit,
}
for li in inv.line_items
]
}
for inv in invoices
],
"suppliers": [
{
"id": sup.id,
"kitchen_id": sup.kitchen_id,
"name": sup.name,
"aliases": sup.aliases,
"template_config": sup.template_config,
"identifier_config": sup.identifier_config,
"created_at": sup.created_at,
"updated_at": sup.updated_at,
}
for sup in suppliers
],
"settings": {
"currency_symbol": settings.currency_symbol if settings else "£",
"date_format": settings.date_format if settings else "DD/MM/YYYY",
"high_quantity_threshold": settings.high_quantity_threshold if settings else 100,
} if settings else None
}
with open(output_path, 'w') as f:
json.dump(export_data, f, indent=2, cls=DecimalEncoder)
logger.info(f"Database JSON export created: {len(invoices)} invoices, {len(suppliers)} suppliers")
return True
except Exception as e:
logger.error(f"Database JSON export failed: {e}", exc_info=True)
return False
async def _create_postgres_dump(self, output_path: str) -> bool:
"""
Create a full PostgreSQL dump using pg_dump.
This creates a complete SQL backup that can restore the entire database.
"""
try:
logger.info(f"Creating PostgreSQL dump for database {DB_NAME}")
# Build pg_dump command
# Use custom format (-Fc) for compression and flexible restore
# But also create a plain SQL for easy viewing
env = os.environ.copy()
env['PGPASSWORD'] = DB_PASSWORD
# Create plain SQL dump (human readable, can be used with psql)
process = await asyncio.create_subprocess_exec(
'pg_dump',
'-h', DB_HOST,
'-p', DB_PORT,
'-U', DB_USER,
'-d', DB_NAME,
'--no-owner', # Don't dump ownership
'--no-privileges', # Don't dump privileges
'--clean', # Add DROP statements
'--if-exists', # Add IF EXISTS to DROP
'-f', output_path,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
logger.error(f"pg_dump failed with code {process.returncode}: {error_msg}")
return False
# Check file was created and has content
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
size_kb = os.path.getsize(output_path) / 1024
logger.info(f"PostgreSQL dump created: {size_kb:.1f} KB")
return True
else:
logger.error("pg_dump created empty or no file")
return False
except FileNotFoundError:
logger.error("pg_dump command not found - PostgreSQL client tools not installed")
return False
except Exception as e:
logger.error(f"PostgreSQL dump failed: {e}", exc_info=True)
return False
async def _restore_postgres_dump(self, sql_path: str) -> bool:
"""
Restore database from a PostgreSQL SQL dump.
WARNING: This will DROP and recreate all tables!
"""
try:
logger.info(f"Restoring PostgreSQL database from {sql_path}")
env = os.environ.copy()
env['PGPASSWORD'] = DB_PASSWORD
# Use psql to restore the dump
process = await asyncio.create_subprocess_exec(
'psql',
'-h', DB_HOST,
'-p', DB_PORT,
'-U', DB_USER,
'-d', DB_NAME,
'-f', sql_path,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
# Some errors are expected (like "table does not exist" for DROP IF EXISTS)
if "ERROR" in error_msg and "does not exist" not in error_msg:
logger.error(f"psql restore failed: {error_msg}")
return False
logger.info("PostgreSQL database restored successfully")
return True
except FileNotFoundError:
logger.error("psql command not found - PostgreSQL client tools not installed")
return False
except Exception as e:
logger.error(f"PostgreSQL restore failed: {e}", exc_info=True)
return False
async def create_backup(
self,
user_id: Optional[int] = None,
backup_type: str = "manual"
) -> Tuple[bool, str, Optional[BackupHistory]]:
"""
Create a full backup (database + files).
Args:
user_id: User who triggered backup (None for scheduled)
backup_type: "manual" or "scheduled"
Returns:
(success, message, backup_history_record)
"""
settings = await self.get_settings()
destination = settings.backup_destination if settings else "local"
# Create backup history record
backup = BackupHistory(
kitchen_id=self.kitchen_id,
backup_type=backup_type,
destination=destination or "local",
status="running",
filename=self._generate_backup_filename(),
file_path="", # Will be set after upload
triggered_by_user_id=user_id
)
self.db.add(backup)
await self.db.commit()
await self.db.refresh(backup)
try:
# Create temp directory for backup
with tempfile.TemporaryDirectory() as temp_dir:
backup_zip_path = os.path.join(temp_dir, backup.filename)
# Create ZIP file
with zipfile.ZipFile(backup_zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
# 1. Full PostgreSQL dump (for complete recovery)
pg_dump_path = os.path.join(temp_dir, "database.sql")
if await self._create_postgres_dump(pg_dump_path):
zf.write(pg_dump_path, "database.sql")
logger.info("Added database.sql to backup")
else:
logger.warning("PostgreSQL dump failed - backup will not include full database")
# 2. JSON export (for easy viewing/partial restore)
db_export_path = os.path.join(temp_dir, "database.json")
if await self._create_database_export(db_export_path):
zf.write(db_export_path, "database.json")
logger.info("Added database.json to backup")
else:
logger.warning("JSON export failed - backup will not include application data export")
# 3. ALL files in /app/data/ (invoices, disputes, credit notes, etc.)
# This ensures a complete backup for full recovery/transfer
data_dir = "/app/data"
file_count = 0
skipped_dirs = {'backups'} # Don't backup the backups directory
logger.info(f"Scanning {data_dir} for files to backup...")
if os.path.exists(data_dir):
for root, dirs, files in os.walk(data_dir):
# Skip backups directory to avoid recursive backup
dirs[:] = [d for d in dirs if d not in skipped_dirs]
for file in files:
file_path = os.path.join(root, file)
# Get relative path from /app/data/
rel_path = os.path.relpath(file_path, data_dir)
try:
zf.write(file_path, f"files/{rel_path}")
file_count += 1
except Exception as e:
logger.warning(f"Failed to add file {rel_path}: {e}")
logger.info(f"Added {file_count} files to backup from {data_dir}")
# Also count invoices for metadata
result = await self.db.execute(
select(Invoice).where(Invoice.kitchen_id == self.kitchen_id)
)
invoices = result.scalars().all()
backup.invoice_count = len(invoices)
backup.file_count = file_count
# Get file size
backup.file_size_bytes = os.path.getsize(backup_zip_path)
# Upload to destination
if destination == "local":
# Copy to local backup directory
os.makedirs(LOCAL_BACKUP_DIR, exist_ok=True)
final_path = os.path.join(LOCAL_BACKUP_DIR, backup.filename)
shutil.copy2(backup_zip_path, final_path)
backup.file_path = final_path
elif destination == "nextcloud":
# Upload to Nextcloud
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
raise ValueError("Nextcloud not configured for backup")
# Use separate backup path on Nextcloud (not inside invoice archive path)
backup_path = settings.backup_nextcloud_path or "/Backups"
backup_path = backup_path.strip('/')
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
"" # Don't use base_path, use explicit backup_path
)
with open(backup_zip_path, 'rb') as f:
file_content = f.read()
size_mb = len(file_content) / (1024 * 1024)
# 10 min timeout for backups (large ZIP files)
upload_timeout = max(600, size_mb * 10) # At least 10 min, or 10s per MB
logger.info(f"Uploading backup to Nextcloud: {backup.filename} ({size_mb:.1f} MB, timeout={upload_timeout:.0f}s)")
success, result = await nc.upload_file(
file_content,
backup_path,
backup.filename,
timeout=upload_timeout
)
await nc.close()
if not success:
raise ValueError(f"Nextcloud upload failed: {result}")
backup.file_path = f"nextcloud:{result}"
elif destination == "smb":
# SMB backup - placeholder for future implementation
# Would use smbclient or pysmb library
raise NotImplementedError("SMB backup not yet implemented")
# Update backup record
backup.status = "success"
backup.completed_at = datetime.utcnow()
# Update settings with last backup info
if settings:
settings.backup_last_run_at = datetime.utcnow()
settings.backup_last_status = "success"
settings.backup_last_error = None
await self.db.commit()
# Enforce retention policy
await self._enforce_retention(settings)
return (True, f"Backup created: {backup.filename}", backup)
except Exception as e:
logger.error(f"Backup failed: {e}")
backup.status = "failed"
backup.error_message = str(e)
backup.completed_at = datetime.utcnow()
if settings:
settings.backup_last_status = "failed"
settings.backup_last_error = str(e)
await self.db.commit()
return (False, str(e), backup)
async def _enforce_retention(self, settings: Optional[KitchenSettings]):
"""Delete old backups beyond retention count"""
retention = settings.backup_retention_count if settings else 7
# Get all successful backups, ordered by date
result = await self.db.execute(
select(BackupHistory).where(
BackupHistory.kitchen_id == self.kitchen_id,
BackupHistory.status == "success"
).order_by(BackupHistory.started_at.desc())
)
backups = result.scalars().all()
# Delete backups beyond retention
for old_backup in list(backups)[retention:]:
try:
# Delete file
if old_backup.file_path.startswith("nextcloud:"):
# Delete from Nextcloud
if settings and settings.nextcloud_host:
nc_path = old_backup.file_path.replace("nextcloud:", "")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
await nc.delete_file(nc_path)
await nc.close()
elif old_backup.file_path and os.path.exists(old_backup.file_path):
os.remove(old_backup.file_path)
# Delete record
await self.db.delete(old_backup)
logger.info(f"Deleted old backup: {old_backup.filename}")
except Exception as e:
logger.warning(f"Failed to delete old backup {old_backup.filename}: {e}")
await self.db.commit()
async def list_backups(self, limit: int = 50) -> List[BackupHistory]:
"""List all backups for this kitchen"""
result = await self.db.execute(
select(BackupHistory).options(
selectinload(BackupHistory.triggered_by_user)
).where(
BackupHistory.kitchen_id == self.kitchen_id
).order_by(BackupHistory.started_at.desc()).limit(limit)
)
return list(result.scalars().all())
async def get_backup(self, backup_id: int) -> Optional[BackupHistory]:
"""Get a specific backup by ID"""
result = await self.db.execute(
select(BackupHistory).where(
BackupHistory.id == backup_id,
BackupHistory.kitchen_id == self.kitchen_id
)
)
return result.scalar_one_or_none()
async def delete_backup(self, backup_id: int) -> Tuple[bool, str]:
"""Delete a backup"""
backup = await self.get_backup(backup_id)
if not backup:
return (False, "Backup not found")
try:
# Delete file
if backup.file_path:
if backup.file_path.startswith("nextcloud:"):
settings = await self.get_settings()
if settings and settings.nextcloud_host:
nc_path = backup.file_path.replace("nextcloud:", "")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
await nc.delete_file(nc_path)
await nc.close()
elif os.path.exists(backup.file_path):
os.remove(backup.file_path)
# Delete record
await self.db.delete(backup)
await self.db.commit()
return (True, "Backup deleted")
except Exception as e:
logger.error(f"Failed to delete backup: {e}")
return (False, str(e))
async def restore_backup(self, backup_id: int) -> Tuple[bool, str]:
"""
Restore from a backup.
WARNING: This is a complex operation. For now, we just extract the files.
Full database restore would require more careful handling.
Args:
backup_id: ID of backup to restore
Returns:
(success, message)
"""
backup = await self.get_backup(backup_id)
if not backup:
return (False, "Backup not found")
if backup.status != "success":
return (False, "Cannot restore from failed backup")
try:
settings = await self.get_settings()
with tempfile.TemporaryDirectory() as temp_dir:
backup_path = os.path.join(temp_dir, backup.filename)
# Download backup file
if backup.file_path.startswith("nextcloud:"):
# Download from Nextcloud
if not settings or not settings.nextcloud_host:
return (False, "Nextcloud not configured")
nc_path = backup.file_path.replace("nextcloud:", "")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, content = await nc.download_file(nc_path)
await nc.close()
if not success:
return (False, f"Failed to download backup: {content}")
with open(backup_path, 'wb') as f:
f.write(content)
else:
# Local file
if not os.path.exists(backup.file_path):
return (False, "Backup file not found")
shutil.copy2(backup.file_path, backup_path)
# Extract backup
with zipfile.ZipFile(backup_path, 'r') as zf:
zf.extractall(temp_dir)
# Restore files
files_dir = os.path.join(temp_dir, "files")
if os.path.exists(files_dir):
data_dir = "/app/data"
for root, dirs, files in os.walk(files_dir):
for file in files:
src = os.path.join(root, file)
rel_path = os.path.relpath(src, files_dir)
dst = os.path.join(data_dir, rel_path)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if not os.path.exists(dst): # Don't overwrite existing files
shutil.copy2(src, dst)
# Restore database from SQL dump if present
db_sql_path = os.path.join(temp_dir, "database.sql")
db_restored = False
if os.path.exists(db_sql_path):
db_restored = await self._restore_postgres_dump(db_sql_path)
if db_restored:
logger.info("Database restored from SQL dump")
else:
logger.warning("Database restore failed - files restored but database unchanged")
if db_restored:
return (True, f"Fully restored from backup: {backup.filename} (database + files)")
else:
return (True, f"Restored files from backup: {backup.filename} (database restore requires manual import)")
except Exception as e:
logger.error(f"Restore failed: {e}")
return (False, str(e))
async def restore_from_upload(self, file) -> Tuple[bool, str]:
"""
Restore from an uploaded backup file.
Args:
file: UploadFile from FastAPI
Returns:
(success, message)
"""
try:
with tempfile.TemporaryDirectory() as temp_dir:
# Save uploaded file
backup_path = os.path.join(temp_dir, file.filename)
content = await file.read()
with open(backup_path, 'wb') as f:
f.write(content)
# Verify it's a valid ZIP
if not zipfile.is_zipfile(backup_path):
return (False, "Invalid ZIP file")
# Extract backup
with zipfile.ZipFile(backup_path, 'r') as zf:
zf.extractall(temp_dir)
# Check for required database.json
db_json_path = os.path.join(temp_dir, "database.json")
if not os.path.exists(db_json_path):
return (False, "Invalid backup: missing database.json")
# Restore files
files_dir = os.path.join(temp_dir, "files")
restored_count = 0
if os.path.exists(files_dir):
data_dir = "/app/data"
for root, dirs, files_list in os.walk(files_dir):
for file_name in files_list:
src = os.path.join(root, file_name)
rel_path = os.path.relpath(src, files_dir)
dst = os.path.join(data_dir, rel_path)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if not os.path.exists(dst): # Don't overwrite existing files
shutil.copy2(src, dst)
restored_count += 1
# Restore database from SQL dump if present
db_sql_path = os.path.join(temp_dir, "database.sql")
db_restored = False
if os.path.exists(db_sql_path):
db_restored = await self._restore_postgres_dump(db_sql_path)
if db_restored:
logger.info("Database restored from uploaded backup SQL dump")
if db_restored:
return (True, f"Fully restored from uploaded backup: database + {restored_count} files")
else:
return (True, f"Restored {restored_count} files from uploaded backup (database restore requires manual import)")
except zipfile.BadZipFile:
return (False, "Corrupted ZIP file")
except Exception as e:
logger.error(f"Restore from upload failed: {e}")
return (False, str(e))

View file

@ -0,0 +1,130 @@
"""
Brakes (brake.co.uk) product data scraper.
Fetches ingredients list and allergen "Contains" statement from product pages.
URL pattern: https://www.brake.co.uk/p/{product_code}
"""
import re
import logging
import httpx
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class BrakesProduct:
product_name: str = ""
ingredients_text: str = "" # full ingredients list (HTML tags stripped)
contains_allergens: list[str] = field(default_factory=list) # ["Egg", "Milk"]
raw_contains: str = "" # "Egg and Milk" — original text from Contains field
suitable_for: list[str] = field(default_factory=list) # ["Vegetarian", "Vegan"]
def _strip_html_tags(html: str) -> str:
"""Remove HTML tags, collapse whitespace."""
text = re.sub(r"<[^>]+>", "", html)
text = re.sub(r"\s+", " ", text).strip()
return text
def _bold_to_uppercase(html: str) -> str:
"""Convert <strong>text</strong> to UPPERCASE, then strip remaining tags."""
text = re.sub(
r"<strong>(.*?)</strong>",
lambda m: m.group(1).upper(),
html, flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _parse_contains(raw: str) -> list[str]:
"""Parse 'Egg, Milk and Gluten' into ['Egg', 'Milk', 'Gluten']."""
if not raw:
return []
# "None of the 14 Food Allergens" means no allergens
if "none" in raw.lower():
return []
# Split on commas first
parts = [p.strip() for p in raw.split(",")]
# The last part may contain " and " — split that too
expanded = []
for part in parts:
if " and " in part:
expanded.extend(p.strip() for p in part.split(" and ") if p.strip())
else:
if part:
expanded.append(part)
# Title-case each allergen for consistent matching
return [a.strip().title() for a in expanded if a.strip()]
def parse_brakes_html(html: str) -> BrakesProduct:
"""Extract product name, ingredients, and Contains statement from Brakes product page HTML."""
product = BrakesProduct()
# Product name — typically in <h1> or page title
title_match = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.DOTALL | re.IGNORECASE)
if title_match:
product.product_name = _strip_html_tags(title_match.group(1))
# Ingredients — Brakes uses: <p>Ingredients: <p>...actual ingredients...</p></p>
# or sometimes <p>Ingredients: ...text...</p>
ing_match = re.search(
r"<p>\s*Ingredients\s*:\s*(.*?)</p>\s*</p>",
html, re.DOTALL | re.IGNORECASE
)
if not ing_match:
# Fallback: single <p> without nested <p>
ing_match = re.search(
r"<p>\s*Ingredients\s*:\s*(.*?)</p>",
html, re.DOTALL | re.IGNORECASE
)
if ing_match:
product.ingredients_text = _bold_to_uppercase(ing_match.group(1))
# Contains — Brakes uses: <p>Contains : Egg and Milk</p> (note space before colon)
contains_match = re.search(
r"<p>\s*Contains\s*:\s*(.*?)</p>",
html, re.DOTALL | re.IGNORECASE
)
if contains_match:
raw = _strip_html_tags(contains_match.group(1))
product.raw_contains = raw
product.contains_allergens = _parse_contains(raw)
# Dietary suitability — plain text "Suitable for Vegetarians" / "Suitable for Vegans"
page_text = _strip_html_tags(html)
if re.search(r"Suitable\s+for\s+Vegetarians", page_text, re.IGNORECASE):
product.suitable_for.append("Vegetarian")
if re.search(r"Suitable\s+for\s+Vegans", page_text, re.IGNORECASE):
product.suitable_for.append("Vegan")
return product
async def fetch_brakes_product(product_code: str) -> BrakesProduct | None:
"""Fetch product data from brake.co.uk/p/{code}. Returns None on 404/error."""
# Strip OCR artefacts like $ prefix
clean_code = product_code.lstrip("$").strip()
if not clean_code:
return None
url = f"https://www.brake.co.uk/p/{clean_code}"
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url, headers={
"User-Agent": "KitchenApp/1.0 (ingredient-lookup)",
"Accept": "text/html",
})
if response.status_code != 200:
logger.info(f"Brakes lookup {clean_code}: HTTP {response.status_code}")
return None
return parse_brakes_html(response.text)
except httpx.TimeoutException:
logger.warning(f"Brakes lookup {clean_code}: timeout")
return None
except Exception as e:
logger.warning(f"Brakes lookup {clean_code}: {e}")
return None

View file

@ -0,0 +1,452 @@
"""
Service for managing dispute and credit note file archival to Nextcloud.
Handles:
- Dispute attachment storage and archival
- Credit note PDF storage and archival
- File retrieval from Nextcloud
"""
import os
import uuid
import hashlib
import logging
from datetime import datetime
from typing import Tuple, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models.dispute import DisputeAttachment, CreditNote, InvoiceDispute
from models.settings import KitchenSettings
from models.supplier import Supplier
from models.invoice import Invoice
from services.nextcloud_service import NextcloudService
logger = logging.getLogger(__name__)
# Storage paths
DISPUTE_ATTACHMENTS_DIR = "/app/data/disputes"
CREDIT_NOTES_DIR = "/app/data/credit_notes"
class DisputeArchivalService:
"""Service for managing dispute and credit note file archival"""
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"""
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
return result.scalar_one_or_none()
def _sanitize_filename(self, name: str) -> str:
"""Sanitize filename for safe storage"""
# Remove unsafe characters
safe = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in name)
# Collapse multiple spaces/underscores
safe = '_'.join(safe.split())
# Limit length
return safe[:50]
def _generate_descriptive_filename(
self,
supplier_name: str,
date: datetime,
prefix: str,
original_filename: str
) -> str:
"""
Generate descriptive filename for Nextcloud archival.
Format: {date}-{supplier}-{prefix}-{hash}.{ext}
Example: 2026-01-22-Brakes-dispute-5-attachment-a1b2c3d4.jpg
"""
date_str = date.strftime("%Y-%m-%d")
supplier_safe = self._sanitize_filename(supplier_name)[:30]
# Get file extension
ext = os.path.splitext(original_filename)[1].lower() or ".dat"
# Generate short hash for uniqueness
hash_input = f"{supplier_name}{date}{prefix}{original_filename}".encode()
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
return f"{date_str}-{supplier_safe}-{prefix}-{short_hash}{ext}"
async def save_dispute_attachment(
self,
dispute: InvoiceDispute,
file_content: bytes,
filename: str,
file_type: str
) -> Tuple[bool, str]:
"""
Save dispute attachment locally.
Args:
dispute: The dispute
file_content: File bytes
filename: Original filename
file_type: MIME type
Returns:
(success, file_path or error)
"""
try:
# Create storage directory
dispute_dir = os.path.join(DISPUTE_ATTACHMENTS_DIR, str(self.kitchen_id), str(dispute.id))
os.makedirs(dispute_dir, exist_ok=True)
# Generate unique filename
ext = os.path.splitext(filename)[1] or ".dat"
unique_filename = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(dispute_dir, unique_filename)
# Save file
with open(file_path, 'wb') as f:
f.write(file_content)
return (True, file_path)
except Exception as e:
logger.error(f"Failed to save dispute attachment: {e}")
return (False, str(e))
async def save_credit_note(
self,
invoice: Invoice,
file_content: bytes,
filename: str
) -> Tuple[bool, str]:
"""
Save credit note PDF locally.
Args:
invoice: The invoice
file_content: PDF bytes
filename: Original filename
Returns:
(success, file_path or error)
"""
try:
# Create storage directory
cn_dir = os.path.join(CREDIT_NOTES_DIR, str(self.kitchen_id))
os.makedirs(cn_dir, exist_ok=True)
# Generate unique filename
ext = os.path.splitext(filename)[1] or ".pdf"
unique_filename = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(cn_dir, unique_filename)
# Save file
with open(file_path, 'wb') as f:
f.write(file_content)
return (True, file_path)
except Exception as e:
logger.error(f"Failed to save credit note: {e}")
return (False, str(e))
async def archive_dispute_attachment(self, attachment: DisputeAttachment) -> Tuple[bool, str]:
"""
Archive dispute attachment to Nextcloud.
Args:
attachment: Attachment to archive
Returns:
(success, nextcloud_path or error)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not enabled")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud not configured")
if attachment.file_storage_location != "local":
return (False, "Attachment already archived")
if not os.path.exists(attachment.file_path):
return (False, "Local file not found")
try:
# Get dispute and invoice info
result = await self.db.execute(
select(InvoiceDispute).where(InvoiceDispute.id == attachment.dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
return (False, "Dispute not found")
result = await self.db.execute(
select(Invoice).where(Invoice.id == dispute.invoice_id)
)
invoice = result.scalar_one_or_none()
if not invoice:
return (False, "Invoice not found")
# Get supplier name
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
# Read file
with open(attachment.file_path, 'rb') as f:
file_content = f.read()
# Generate descriptive filename
date = attachment.uploaded_at or datetime.utcnow()
prefix = f"dispute-{dispute.id}-{attachment.attachment_type}"
descriptive_filename = self._generate_descriptive_filename(
supplier_name,
date,
prefix,
attachment.file_name
)
# Build Nextcloud path structure
base_path = (settings.nextcloud_base_path or "/Kitchen Invoices").strip('/')
year = date.strftime("%Y")
month = date.strftime("%m")
nextcloud_path = f"{base_path}/Disputes/{supplier_name}/{year}/{month}"
# Upload to Nextcloud
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
"" # No base path, we use explicit path
)
success, result = await nc.upload_file(
file_content,
nextcloud_path,
descriptive_filename
)
await nc.close()
if not success:
return (False, f"Nextcloud upload failed: {result}")
# Update attachment record
attachment.file_storage_location = "nextcloud"
attachment.nextcloud_path = result
attachment.archived_at = datetime.utcnow()
await self.db.commit()
# Optionally delete local file
if settings.nextcloud_delete_local:
try:
os.remove(attachment.file_path)
logger.info(f"Deleted local attachment after archival: {attachment.file_path}")
except Exception as e:
logger.warning(f"Could not delete local file: {e}")
return (True, result)
except Exception as e:
logger.error(f"Failed to archive dispute attachment: {e}")
return (False, str(e))
async def archive_credit_note(self, credit_note: CreditNote) -> Tuple[bool, str]:
"""
Archive credit note to Nextcloud.
Args:
credit_note: Credit note to archive
Returns:
(success, nextcloud_path or error)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not enabled")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud not configured")
if credit_note.file_storage_location != "local":
return (False, "Credit note already archived")
if not os.path.exists(credit_note.file_path):
return (False, "Local file not found")
try:
# Get supplier name
supplier_name = "Unknown"
if credit_note.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == credit_note.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
# Read file
with open(credit_note.file_path, 'rb') as f:
file_content = f.read()
# Generate descriptive filename
date = credit_note.credit_date
date_str = date.strftime("%Y-%m-%d")
supplier_safe = self._sanitize_filename(supplier_name)[:30]
cn_number_safe = self._sanitize_filename(credit_note.credit_note_number)[:20]
amount_str = f"£{credit_note.credit_amount:.2f}".replace('.', '_')
# Short hash for uniqueness
hash_input = f"{supplier_name}{date}{credit_note.credit_note_number}".encode()
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
descriptive_filename = f"{date_str}-{supplier_safe}-CN-{cn_number_safe}-{amount_str}-{short_hash}.pdf"
# Build Nextcloud path structure
base_path = (settings.nextcloud_base_path or "/Kitchen Invoices").strip('/')
year = date.strftime("%Y")
month = date.strftime("%m")
nextcloud_path = f"{base_path}/Credit Notes/{supplier_name}/{year}/{month}"
# Upload to Nextcloud
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, result = await nc.upload_file(
file_content,
nextcloud_path,
descriptive_filename
)
await nc.close()
if not success:
return (False, f"Nextcloud upload failed: {result}")
# Update credit note record
credit_note.file_storage_location = "nextcloud"
credit_note.nextcloud_path = result
credit_note.original_local_path = credit_note.file_path
credit_note.archived_at = datetime.utcnow()
await self.db.commit()
# Optionally delete local file
if settings.nextcloud_delete_local:
try:
os.remove(credit_note.file_path)
logger.info(f"Deleted local credit note after archival: {credit_note.file_path}")
except Exception as e:
logger.warning(f"Could not delete local file: {e}")
return (True, result)
except Exception as e:
logger.error(f"Failed to archive credit note: {e}")
return (False, str(e))
async def get_attachment_content(self, attachment: DisputeAttachment) -> Tuple[bool, bytes]:
"""
Get attachment file content (from local or Nextcloud).
Args:
attachment: Attachment to retrieve
Returns:
(success, file_content or error_message)
"""
try:
# Try local file first
if attachment.file_storage_location == "local" and os.path.exists(attachment.file_path):
with open(attachment.file_path, 'rb') as f:
return (True, f.read())
# Try Nextcloud
if attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, b"Nextcloud not enabled")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, content = await nc.download_file(attachment.nextcloud_path)
await nc.close()
if success:
return (True, content)
else:
return (False, content.encode() if isinstance(content, str) else content)
return (False, b"File not found")
except Exception as e:
logger.error(f"Failed to get attachment content: {e}")
return (False, str(e).encode())
async def get_credit_note_content(self, credit_note: CreditNote) -> Tuple[bool, bytes]:
"""
Get credit note file content (from local or Nextcloud).
Args:
credit_note: Credit note to retrieve
Returns:
(success, file_content or error_message)
"""
try:
# Try local file first
if credit_note.file_storage_location == "local" and os.path.exists(credit_note.file_path):
with open(credit_note.file_path, 'rb') as f:
return (True, f.read())
# Try original local path if different
if credit_note.original_local_path and os.path.exists(credit_note.original_local_path):
with open(credit_note.original_local_path, 'rb') as f:
return (True, f.read())
# Try Nextcloud
if credit_note.file_storage_location == "nextcloud" and credit_note.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, b"Nextcloud not enabled")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, content = await nc.download_file(credit_note.nextcloud_path)
await nc.close()
if success:
return (True, content)
else:
return (False, content.encode() if isinstance(content, str) else content)
return (False, b"File not found")
except Exception as e:
logger.error(f"Failed to get credit note content: {e}")
return (False, str(e).encode())

View file

@ -0,0 +1,214 @@
import logging
from datetime import timedelta
from decimal import Decimal
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from models.invoice import Invoice
logger = logging.getLogger(__name__)
class DuplicateDetector:
"""Service for detecting duplicate and related invoices."""
# Configuration thresholds
DATE_TOLERANCE_DAYS = 3
AMOUNT_TOLERANCE_PERCENT = 5.0
def __init__(self, db: AsyncSession, kitchen_id: int):
self.db = db
self.kitchen_id = kitchen_id
async def check_duplicates(self, invoice: Invoice) -> dict:
"""
Check for duplicates of the given invoice.
Returns:
{
"firm_duplicate": Invoice or None,
"possible_duplicates": list[Invoice],
"related_documents": list[Invoice]
}
"""
logger.info(
f"Checking duplicates for invoice {invoice.id}: "
f"invoice_number={invoice.invoice_number}, "
f"supplier_id={invoice.supplier_id}, "
f"date={invoice.invoice_date}, "
f"total={invoice.total}"
)
result = {
"firm_duplicate": None,
"possible_duplicates": [],
"related_documents": []
}
# 1. FIRM DUPLICATE: Same invoice_number (with same supplier if available)
if invoice.invoice_number:
firm = await self._find_firm_duplicate(invoice)
if firm:
logger.info(f"Found firm duplicate: invoice {firm.id} (number={firm.invoice_number})")
result["firm_duplicate"] = firm
else:
logger.info(f"No firm duplicate found for invoice_number={invoice.invoice_number}")
# 2. FUZZY/POSSIBLE DUPLICATE: Similar date + similar total (same supplier if available)
if invoice.invoice_date and invoice.total:
possible = await self._find_fuzzy_duplicates(invoice)
result["possible_duplicates"] = possible
# 3. RELATED DOCUMENTS: Cross-match by order_number
if invoice.order_number:
related = await self._find_related_documents(invoice)
result["related_documents"] = related
return result
async def _find_firm_duplicate(self, invoice: Invoice) -> Optional[Invoice]:
"""Find exact match by invoice_number (same supplier if available)"""
# Build conditions
conditions = [
Invoice.kitchen_id == self.kitchen_id,
Invoice.invoice_number == invoice.invoice_number,
Invoice.id != invoice.id
]
# If we have supplier_id, require same supplier for firm match
# If no supplier_id, just match by invoice_number alone
if invoice.supplier_id:
conditions.append(Invoice.supplier_id == invoice.supplier_id)
logger.debug(f"Searching for firm duplicate: invoice_number={invoice.invoice_number}, supplier_id={invoice.supplier_id}")
else:
logger.debug(f"Searching for firm duplicate (no supplier): invoice_number={invoice.invoice_number}")
# Order by ID to get the oldest duplicate first, and use first() instead
# of scalar_one_or_none() since there may be multiple duplicates
query = select(Invoice).where(and_(*conditions)).order_by(Invoice.id)
result = await self.db.execute(query)
found = result.scalars().first()
if not found:
# Log what invoices exist with this number for debugging
all_with_number = await self.db.execute(
select(Invoice).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.invoice_number == invoice.invoice_number
)
)
all_matches = list(all_with_number.scalars().all())
logger.debug(f"All invoices with number {invoice.invoice_number}: {[(i.id, i.supplier_id) for i in all_matches]}")
return found
async def _find_fuzzy_duplicates(self, invoice: Invoice) -> list[Invoice]:
"""Find similar invoices: close date + close total (same supplier if available)"""
date_min = invoice.invoice_date - timedelta(days=self.DATE_TOLERANCE_DAYS)
date_max = invoice.invoice_date + timedelta(days=self.DATE_TOLERANCE_DAYS)
# Calculate amount tolerance
amount_tolerance = invoice.total * Decimal(str(self.AMOUNT_TOLERANCE_PERCENT / 100))
amount_min = invoice.total - amount_tolerance
amount_max = invoice.total + amount_tolerance
# Build conditions
conditions = [
Invoice.kitchen_id == self.kitchen_id,
Invoice.id != invoice.id,
Invoice.invoice_date.between(date_min, date_max),
Invoice.total.between(amount_min, amount_max),
# Exclude if it's the same invoice_number (already caught by firm)
or_(
Invoice.invoice_number == None,
Invoice.invoice_number != invoice.invoice_number
)
]
# If we have supplier_id, require same supplier for fuzzy match
if invoice.supplier_id:
conditions.append(Invoice.supplier_id == invoice.supplier_id)
query = select(Invoice).where(and_(*conditions))
result = await self.db.execute(query)
return list(result.scalars().all())
async def _find_related_documents(self, invoice: Invoice) -> list[Invoice]:
"""Find documents with same order_number but different invoice_number"""
query = select(Invoice).where(
and_(
Invoice.kitchen_id == self.kitchen_id,
Invoice.order_number == invoice.order_number,
Invoice.id != invoice.id,
# Must have different invoice_number to be related (not duplicate)
or_(
Invoice.invoice_number == None,
Invoice.invoice_number != invoice.invoice_number
)
)
)
result = await self.db.execute(query)
return list(result.scalars().all())
def detect_document_type(raw_text: str, fields: dict) -> str:
"""
Detect if document is Invoice, Credit Note, or Delivery Note based on OCR text.
Args:
raw_text: Full OCR text
fields: Extracted fields dict from Azure
Returns:
"invoice", "credit_note", or "delivery_note"
"""
if not raw_text:
return "invoice"
text_upper = raw_text.upper()
# Check for credit note FIRST (highest priority)
credit_keywords = [
"CREDIT NOTE", "CREDIT MEMO", "CR NOTE", "C/N",
"CREDIT INVOICE", "CN NO", "CN:", "REFUND"
]
# Check if invoice number contains credit note indicator
invoice_number = fields.get("invoice_number")
if invoice_number:
inv_num_upper = str(invoice_number).upper()
if any(kw in inv_num_upper for kw in ["CREDIT", "CR NOTE", "CN", "C/N"]):
return "credit_note"
# Check for credit note keywords in text
if any(kw in text_upper for kw in credit_keywords):
return "credit_note"
# Check for negative total (strong indicator of credit note)
total = fields.get("total")
net_total = fields.get("net_total")
if (total is not None and total < 0) or (net_total is not None and net_total < 0):
return "credit_note"
# Keywords suggesting delivery note
dn_keywords = [
"DELIVERY NOTE", "DELIVERY DOCKET", "DISPATCH NOTE",
"DELIVERY ADVICE", "PACKING SLIP", "PACKING LIST",
"DN NO", "DN:", "D/N"
]
# Keywords suggesting invoice
inv_keywords = [
"TAX INVOICE", "VAT INVOICE", "INVOICE NO",
"INVOICE DATE", "INVOICE TOTAL", "AMOUNT DUE",
"PAYMENT DUE", "BALANCE DUE"
]
dn_score = sum(1 for kw in dn_keywords if kw in text_upper)
inv_score = sum(1 for kw in inv_keywords if kw in text_upper)
# Also check if there's no total amount (delivery notes often don't have)
if not fields.get("total"):
dn_score += 1
return "delivery_note" if dn_score > inv_score else "invoice"

View file

@ -0,0 +1,312 @@
"""
Email service for sending invoices to Dext via SMTP.
This service handles:
- SMTP connection and authentication
- Sending HTML emails with attachments
- Testing SMTP configuration
- Generating formatted HTML emails for Dext submission
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import logging
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails via SMTP"""
def __init__(self, settings):
"""Initialize email service with kitchen settings
Args:
settings: KitchenSettings object with SMTP configuration
"""
self.host = settings.smtp_host
self.port = settings.smtp_port
self.username = settings.smtp_username
self.password = settings.smtp_password
self.use_tls = settings.smtp_use_tls
self.from_email = settings.smtp_from_email
self.from_name = settings.smtp_from_name or "Kitchen Invoice System"
def send_email(
self,
to_email: str,
subject: str,
html_body: str,
plain_body: str = None,
attachments: list[tuple[str, bytes]] = None
) -> bool:
"""Send email with HTML and plain text versions plus optional attachments
Args:
to_email: Recipient email address
subject: Email subject line
html_body: HTML email body
plain_body: Plain text email body (optional, for clients that prefer plain text)
attachments: List of (filename, file_bytes) tuples
Returns:
True if sent successfully, False otherwise
"""
try:
msg = MIMEMultipart('mixed')
msg['From'] = f"{self.from_name} <{self.from_email}>"
msg['To'] = to_email
msg['Subject'] = subject
# Create alternative part for plain text and HTML
if plain_body:
alt_part = MIMEMultipart('alternative')
plain_part = MIMEText(plain_body, 'plain')
html_part = MIMEText(html_body, 'html')
alt_part.attach(plain_part)
alt_part.attach(html_part)
msg.attach(alt_part)
else:
# Just HTML body
html_part = MIMEText(html_body, 'html')
msg.attach(html_part)
# Attach files
if attachments:
for filename, file_bytes in attachments:
attachment = MIMEApplication(file_bytes)
attachment.add_header(
'Content-Disposition',
'attachment',
filename=filename
)
msg.attach(attachment)
# Connect and send
# Port 465 uses implicit SSL, port 587 uses STARTTLS
if self.port == 465:
# Use SMTP_SSL for implicit SSL/TLS (port 465)
with smtplib.SMTP_SSL(self.host, self.port, timeout=30) as server:
if self.username and self.password:
server.login(self.username, self.password)
server.send_message(msg)
else:
# Use SMTP with STARTTLS for port 587 or others
with smtplib.SMTP(self.host, self.port, timeout=30) as server:
if self.use_tls:
server.starttls()
if self.username and self.password:
server.login(self.username, self.password)
server.send_message(msg)
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email: {e}")
return False
def test_connection(self) -> tuple[bool, str]:
"""Test SMTP connection and authentication
Returns:
(success, message) tuple
"""
try:
# Port 465 uses implicit SSL, port 587 uses STARTTLS
if self.port == 465:
# Use SMTP_SSL for implicit SSL/TLS (port 465)
with smtplib.SMTP_SSL(self.host, self.port, timeout=10) as server:
server.ehlo()
if self.username and self.password:
server.login(self.username, self.password)
else:
# Use SMTP with STARTTLS for port 587 or others
with smtplib.SMTP(self.host, self.port, timeout=10) as server:
server.ehlo()
if self.use_tls:
server.starttls()
server.ehlo()
if self.username and self.password:
server.login(self.username, self.password)
return (True, "SMTP connection successful")
except smtplib.SMTPAuthenticationError:
return (False, "Authentication failed - check username/password")
except smtplib.SMTPException as e:
return (False, f"SMTP error: {str(e)}")
except Exception as e:
return (False, f"Connection error: {str(e)}")
def generate_dext_email_plain(
invoice,
supplier_name: str,
line_items: list,
notes: str | None,
include_notes: bool,
include_non_stock: bool
) -> str:
"""Generate plain text email body for Dext submission
Args:
invoice: Invoice object with invoice_number, invoice_date, total
supplier_name: Supplier name
line_items: List of LineItem objects
notes: Invoice notes
include_notes: Whether to include notes section
include_non_stock: Whether to include non-stock items table
Returns:
Plain text string
"""
lines = []
lines.append("INVOICE SUBMISSION TO DEXT")
lines.append("=" * 40)
lines.append("")
lines.append("INVOICE DETAILS")
lines.append("-" * 20)
lines.append(f"Invoice Number: {invoice.invoice_number or 'N/A'}")
lines.append(f"Supplier: {supplier_name or 'Unknown'}")
lines.append(f"Date: {invoice.invoice_date.strftime('%d/%m/%Y') if invoice.invoice_date else 'N/A'}")
lines.append(f"Total Amount: £{(invoice.total if invoice.total else 0.0):.2f}")
lines.append("")
# Notes section (if enabled and notes exist)
if include_notes and notes:
lines.append("INVOICE NOTES")
lines.append("-" * 20)
lines.append(notes)
lines.append("")
# Non-stock items table (if enabled and non-stock items exist)
if include_non_stock:
non_stock_items = [item for item in line_items if item.is_non_stock]
if non_stock_items:
lines.append("NOTE: The attached PDF has yellow highlights on all non-stock items.")
lines.append("")
lines.append("NON-STOCK ITEMS")
lines.append("-" * 20)
lines.append(f"{'Code':<15} {'Description':<30} {'Qty':<8} {'Price':<10} {'Amount':<10}")
lines.append("-" * 73)
for item in non_stock_items:
code = (item.product_code or '')[:15]
desc = (item.description or '')[:30]
qty = str(item.quantity or '')[:8]
price = f"£{(item.unit_price if item.unit_price else 0.0):.2f}"
amount = f"£{(item.amount if item.amount else 0.0):.2f}"
lines.append(f"{code:<15} {desc:<30} {qty:<8} {price:<10} {amount:<10}")
lines.append("")
return "\n".join(lines)
def generate_dext_email_html(
invoice,
supplier_name: str,
line_items: list,
notes: str | None,
include_notes: bool,
include_non_stock: bool
) -> str:
"""Generate HTML email body for Dext submission
Args:
invoice: Invoice object with invoice_number, invoice_date, total
supplier_name: Supplier name
line_items: List of LineItem objects
notes: Invoice notes
include_notes: Whether to include notes section
include_non_stock: Whether to include non-stock items table
Returns:
HTML string
"""
# Invoice metadata
html = f"""
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; color: #333; }}
.header {{ background-color: #1a1a2e; color: white; padding: 20px; }}
.content {{ padding: 20px; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 10px; }}
th {{ background-color: #f2f2f2; padding: 8px; text-align: left; border: 1px solid #ddd; }}
td {{ padding: 8px; border: 1px solid #ddd; }}
.label {{ font-weight: bold; }}
.notes {{ background-color: #fffbcc; padding: 10px; margin: 10px 0; border-left: 4px solid #ffcc00; }}
</style>
</head>
<body>
<div class="header">
<h2>Invoice Submission to Dext</h2>
</div>
<div class="content">
<h3>Invoice Details</h3>
<p><span class="label">Invoice Number:</span> {invoice.invoice_number or 'N/A'}</p>
<p><span class="label">Supplier:</span> {supplier_name or 'Unknown'}</p>
<p><span class="label">Date:</span> {invoice.invoice_date.strftime('%d/%m/%Y') if invoice.invoice_date else 'N/A'}</p>
<p><span class="label">Total Amount:</span> £{(invoice.total if invoice.total else 0.0):.2f}</p>
"""
# Notes section (if enabled and notes exist)
if include_notes and notes:
# Escape HTML in notes and convert newlines to <br>
escaped_notes = notes.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
escaped_notes = escaped_notes.replace('\n', '<br>')
html += f"""
<div class="notes">
<h4>Invoice Notes:</h4>
<p>{escaped_notes}</p>
</div>
"""
# Non-stock items table (if enabled and non-stock items exist)
if include_non_stock:
non_stock_items = [item for item in line_items if item.is_non_stock]
if non_stock_items:
# Add PDF highlight notification
html += """
<div style="background: #fffbcc; padding: 15px; margin: 20px 0; border-left: 4px solid #ffa500;">
<p style="margin: 0; font-weight: bold;">
📄 The attached PDF has been enhanced with
<span style="background: yellow; padding: 2px 6px;">yellow highlights</span>
on all non-stock items for easy identification.
</p>
</div>
"""
html += """
<h4>Non-Stock Items:</h4>
<table>
<thead>
<tr>
<th>Code</th>
<th>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
"""
for item in non_stock_items:
html += f"""
<tr>
<td>{item.product_code or ''}</td>
<td>{item.description or ''}</td>
<td>{item.quantity or ''}</td>
<td>£{(item.unit_price if item.unit_price else 0.0):.2f}</td>
<td>£{(item.amount if item.amount else 0.0):.2f}</td>
</tr>
"""
html += """
</tbody>
</table>
"""
html += """
</div>
</body>
</html>
"""
return html

View file

@ -0,0 +1,363 @@
"""
Service for managing invoice file archival to Nextcloud.
Handles:
- Automatic archival after invoice confirmation + Dext send
- File retrieval from Nextcloud for re-processing
- Deleted file handling
"""
import os
import hashlib
import logging
from datetime import datetime
from typing import Tuple, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models.invoice import Invoice, InvoiceStatus
from models.settings import KitchenSettings
from models.supplier import Supplier
from services.nextcloud_service import NextcloudService
logger = logging.getLogger(__name__)
class FileArchivalService:
"""Service for managing invoice file archival"""
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"""
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
return result.scalar_one_or_none()
async def is_ready_for_archival(self, invoice: Invoice) -> bool:
"""
Check if invoice is ready to be archived to Nextcloud.
Conditions:
- Status is CONFIRMED
- If Dext is enabled and auto-send is on, must have been sent
- File is still local
"""
if invoice.status != InvoiceStatus.CONFIRMED:
return False
if invoice.file_storage_location != "local":
return False
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return False
# Check Dext requirement - only if auto-send is enabled
if settings.dext_email and settings.dext_auto_send_enabled:
if not invoice.dext_sent_at:
return False
return True
async def archive_invoice_file(self, invoice: Invoice) -> Tuple[bool, str]:
"""
Archive invoice file to Nextcloud.
Args:
invoice: Invoice to archive
Returns:
(success, message or path)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not enabled")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud not configured")
if invoice.file_storage_location != "local":
return (False, "File already archived")
if not os.path.exists(invoice.image_path):
return (False, "Local file not found")
try:
# Get supplier name
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
# Read file
with open(invoice.image_path, 'rb') as f:
file_content = f.read()
# Generate hash from file content
file_hash = hashlib.md5(file_content).hexdigest()
# Initialize Nextcloud service
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
# Generate path and filename
dir_path = nc.generate_path(supplier_name, invoice.invoice_date)
filename = nc.generate_filename(
invoice.invoice_date,
supplier_name,
invoice.invoice_number,
float(invoice.total) if invoice.total else 0,
os.path.basename(invoice.image_path),
file_hash
)
# Upload to Nextcloud
success, result = await nc.upload_file(file_content, dir_path, filename)
await nc.close()
if not success:
return (False, f"Upload failed: {result}")
# Update invoice record
invoice.original_local_path = invoice.image_path
invoice.nextcloud_path = result
invoice.file_storage_location = "nextcloud"
invoice.archived_at = datetime.utcnow()
await self.db.commit()
logger.info(f"Archived invoice {invoice.id} to Nextcloud: {result}")
# Delete local file if setting enabled
if settings.nextcloud_delete_local and os.path.exists(invoice.image_path):
try:
os.remove(invoice.image_path)
logger.info(f"Deleted local file for invoice {invoice.id}: {invoice.image_path}")
except Exception as e:
logger.warning(f"Failed to delete local file {invoice.image_path}: {e}")
return (True, result)
except Exception as e:
logger.error(f"Archival failed for invoice {invoice.id}: {e}")
return (False, str(e))
async def get_file_content(self, invoice: Invoice) -> Tuple[bool, bytes | str]:
"""
Get file content, downloading from Nextcloud if needed.
Used for OCR re-processing or file serving.
Args:
invoice: Invoice to get file for
Returns:
(success, file_bytes or error_message)
"""
# Try local file first (image_path)
if os.path.exists(invoice.image_path):
with open(invoice.image_path, 'rb') as f:
return (True, f.read())
# Try original local path (if file was archived but local copy exists)
if invoice.original_local_path and os.path.exists(invoice.original_local_path):
with open(invoice.original_local_path, 'rb') as f:
return (True, f.read())
# Download from Nextcloud
if invoice.file_storage_location == "nextcloud" and invoice.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not configured")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud credentials not set")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
"" # Path already includes base
)
success, result = await nc.download_file(invoice.nextcloud_path)
await nc.close()
if success and isinstance(result, bytes):
# Cache locally for future use
try:
os.makedirs(os.path.dirname(invoice.image_path), exist_ok=True)
with open(invoice.image_path, 'wb') as f:
f.write(result)
except Exception as e:
logger.warning(f"Failed to cache file locally: {e}")
return (success, result)
return (False, "File not found")
async def handle_invoice_deletion(self, invoice: Invoice) -> Tuple[bool, str]:
"""
Handle file when invoice is being deleted.
If on Nextcloud, copy to deleted folder.
Args:
invoice: Invoice being deleted
Returns:
(success, message)
"""
if invoice.file_storage_location == "nextcloud" and invoice.nextcloud_path:
settings = await self.get_settings()
if settings and settings.nextcloud_enabled:
try:
# Get supplier name for deleted folder
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
# Copy to deleted folder
original_filename = os.path.basename(invoice.nextcloud_path)
success, msg = await nc.copy_to_deleted(
invoice.nextcloud_path,
supplier_name,
original_filename
)
await nc.close()
if not success:
logger.warning(f"Failed to copy to deleted folder: {msg}")
return (True, "File moved to deleted folder")
except Exception as e:
logger.error(f"Error handling file deletion: {e}")
# Local file deletion
if invoice.image_path and os.path.exists(invoice.image_path):
try:
os.remove(invoice.image_path)
except Exception as e:
logger.warning(f"Failed to delete local file: {e}")
# Also try to delete original local path if different
if invoice.original_local_path and invoice.original_local_path != invoice.image_path:
if os.path.exists(invoice.original_local_path):
try:
os.remove(invoice.original_local_path)
except Exception as e:
logger.warning(f"Failed to delete original local file: {e}")
return (True, "Local file deleted")
async def get_archive_stats(self) -> dict:
"""
Get archival statistics for this kitchen.
Returns:
dict with pending_count, archived_count, etc.
"""
settings = await self.get_settings()
# Count invoices ready for archival (confirmed + local)
pending_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.status == InvoiceStatus.CONFIRMED,
Invoice.file_storage_location == "local"
)
pending_result = await self.db.execute(pending_query)
pending_count = pending_result.scalar() or 0
# Count already archived invoices
archived_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.file_storage_location == "nextcloud"
)
archived_result = await self.db.execute(archived_query)
archived_count = archived_result.scalar() or 0
# Count all invoices with local files (any status)
local_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.file_storage_location == "local"
)
local_result = await self.db.execute(local_query)
local_count = local_result.scalar() or 0
return {
"pending_count": pending_count,
"archived_count": archived_count,
"local_count": local_count,
"nextcloud_enabled": settings.nextcloud_enabled if settings else False,
"nextcloud_configured": bool(
settings and settings.nextcloud_host and
settings.nextcloud_username and settings.nextcloud_password
) if settings else False
}
async def archive_all_pending(self) -> Tuple[int, int, list]:
"""
Archive all pending invoices to Nextcloud.
Returns:
(success_count, failed_count, error_messages)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (0, 0, ["Nextcloud not enabled"])
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (0, 0, ["Nextcloud not configured"])
# Get all invoices ready for archival
query = select(Invoice).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.status == InvoiceStatus.CONFIRMED,
Invoice.file_storage_location == "local"
)
result = await self.db.execute(query)
invoices = result.scalars().all()
success_count = 0
failed_count = 0
errors = []
for invoice in invoices:
if await self.is_ready_for_archival(invoice):
try:
success, msg = await self.archive_invoice_file(invoice)
if success:
success_count += 1
else:
failed_count += 1
errors.append(f"Invoice {invoice.id}: {msg}")
except Exception as e:
failed_count += 1
errors.append(f"Invoice {invoice.id}: {str(e)}")
return (success_count, failed_count, errors)

View file

@ -0,0 +1,286 @@
"""
Forecast API Client Service
Handles communication with the external forecasting Docker app to fetch
forecasted revenue, rooms, and covers data for the Spend Budget feature.
API Endpoints: /public/forecast/revenue, /public/forecast/rooms, /public/forecast/covers
Auth: X-API-Key header
"""
import logging
import httpx
from datetime import date
from decimal import Decimal
from typing import Optional
logger = logging.getLogger(__name__)
class ForecastAPIError(Exception):
"""Custom exception for Forecast API errors"""
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class ForecastAPIClient:
"""
Async client for external Forecast API.
Usage:
async with ForecastAPIClient(base_url, api_key) as client:
forecast = await client.get_revenue_forecast(start_date, days=7)
"""
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self._client: httpx.AsyncClient = None
async def __aenter__(self):
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0, connect=15.0),
follow_redirects=True,
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._client:
await self._client.aclose()
async def _request(
self,
endpoint: str,
params: dict = None,
method: str = "GET"
) -> dict:
"""
Make an authenticated request to the Forecast API.
All requests include X-API-Key header for authentication.
"""
url = f"{self.base_url}{endpoint}"
headers = {"X-API-Key": self.api_key}
try:
logger.info(f"Forecast API request: {method} {endpoint}")
if method == "GET":
response = await self._client.get(url, params=params, headers=headers)
else:
response = await self._client.post(url, json=params, headers=headers)
if response.status_code == 401:
raise ForecastAPIError("Authentication failed. Check API key.", 401)
if response.status_code == 403:
raise ForecastAPIError("Access denied. Check API key permissions.", 403)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"Forecast API HTTP error: {e.response.status_code}")
raise ForecastAPIError(f"HTTP {e.response.status_code}: {str(e)}", e.response.status_code)
except httpx.RequestError as e:
logger.error(f"Forecast API request error: {e}")
raise ForecastAPIError(f"Request failed: {str(e)}")
async def test_connection(self) -> tuple[bool, str]:
"""
Test API connection by making a minimal forecast request.
Returns (success, message) tuple.
"""
try:
# Request just 1 day of forecast to test connection
await self.get_revenue_forecast(date.today(), days=1)
return True, "Connection successful"
except ForecastAPIError as e:
return False, str(e.message)
except Exception as e:
return False, f"Connection failed: {str(e)}"
async def get_revenue_forecast(
self,
start_date: date,
days: int = 7,
revenue_type: str = "all"
) -> list[dict]:
"""
Fetch revenue forecast from /public/forecast/revenue
Args:
start_date: Start date for forecast
days: Number of days to fetch (default 7 for a week)
revenue_type: Type filter - "all", "dry", "wet", "total"
Returns list of daily forecasts with:
- date: ISO date string
- day: Day name
- lead_days: Days from today
- dry: {otb, forecast, prior_final, budget}
- wet: {otb, forecast, prior_final, budget}
- total: {otb, forecast, prior_final, budget}
"""
params = {
"start_date": start_date.isoformat(),
"days": days,
}
if revenue_type != "all":
params["type"] = revenue_type
response = await self._request("/public/forecast/revenue", params)
# Response format: {"data": [...], "meta": {...}}
data = response.get("data", [])
logger.info(f"Fetched {len(data)} days of revenue forecast from {start_date}")
return data
def calculate_food_revenue(self, forecast_data: list[dict]) -> tuple[Decimal, Decimal]:
"""
Calculate total food revenue (dry only) from forecast data.
Args:
forecast_data: List of daily forecasts from get_revenue_forecast
Returns tuple of (otb_revenue, forecast_revenue):
- otb_revenue: On The Books (current bookings only) - conservative minimum
- forecast_revenue: Full forecast including expected pickup
"""
total_otb = Decimal("0")
total_forecast = Decimal("0")
for day in forecast_data:
# Get dry values only (wet is beverages, not food cost)
dry = day.get("dry", {})
# OTB is current bookings, forecast includes expected pickup
dry_otb = Decimal(str(dry.get("otb", 0) or 0))
dry_forecast = Decimal(str(dry.get("forecast", 0) or 0))
total_otb += dry_otb
total_forecast += dry_forecast
return total_otb, total_forecast
async def get_rooms_forecast(
self,
start_date: date,
days: int = 7,
) -> list[dict]:
"""
Fetch rooms forecast from /public/forecast/rooms
Returns list of daily data with:
- otb_rooms, pickup_rooms, forecast_rooms
- otb_guests, pickup_guests, forecast_guests
"""
params = {
"start_date": start_date.isoformat(),
"days": days,
}
response = await self._request("/public/forecast/rooms", params)
data = response.get("data", [])
logger.info(f"Fetched {len(data)} days of rooms forecast from {start_date}")
return data
async def get_covers_forecast(
self,
start_date: date,
days: int = 7,
) -> list[dict]:
"""
Fetch covers forecast from /public/forecast/covers
Returns list of daily data with breakfast, lunch, dinner:
- otb, forecast per period
"""
params = {
"start_date": start_date.isoformat(),
"days": days,
}
response = await self._request("/public/forecast/covers", params)
data = response.get("data", [])
logger.info(f"Fetched {len(data)} days of covers forecast from {start_date}")
return data
def aggregate_rooms(self, rooms_data: list[dict]) -> dict:
"""Aggregate weekly room/guest totals from daily rooms forecast."""
totals = {
"otb_rooms": 0, "pickup_rooms": 0, "forecast_rooms": 0,
"otb_guests": 0, "pickup_guests": 0, "forecast_guests": 0,
}
for day in rooms_data:
totals["otb_rooms"] += day.get("otb_rooms", 0) or 0
totals["pickup_rooms"] += day.get("pickup_rooms", 0) or 0
totals["forecast_rooms"] += day.get("forecast_rooms", 0) or 0
totals["otb_guests"] += day.get("otb_guests", 0) or 0
totals["pickup_guests"] += day.get("pickup_guests", 0) or 0
totals["forecast_guests"] += day.get("forecast_guests", 0) or 0
return totals
def aggregate_covers(self, covers_data: list[dict]) -> dict:
"""Aggregate weekly covers totals from daily covers forecast."""
totals = {
"breakfast": {"otb": 0, "pickup": 0, "forecast": 0},
"lunch": {"otb": 0, "pickup": 0, "forecast": 0},
"dinner": {"otb": 0, "pickup": 0, "forecast": 0},
}
for day in covers_data:
for period in ("breakfast", "lunch", "dinner"):
p = day.get(period, {})
otb = p.get("otb", 0) or 0
forecast = p.get("forecast", 0) or 0
totals[period]["otb"] += otb
totals[period]["pickup"] += forecast - otb
totals[period]["forecast"] += forecast
return totals
async def get_spend_rates(self) -> dict:
"""
Fetch spend-per-cover rates from /public/forecast/spend-rates
Returns dict with:
- vat_rate: float
- periods: {breakfast/lunch/dinner: {food_spend_gross, drinks_spend_gross, food_spend_net, drinks_spend_net}}
"""
response = await self._request("/public/forecast/spend-rates")
logger.info("Fetched spend rates from forecast API")
return response
def get_daily_breakdown(self, forecast_data: list[dict]) -> list[dict]:
"""
Process forecast data into daily revenue breakdown for budget tracking.
Args:
forecast_data: List of daily forecasts from get_revenue_forecast
Returns list of daily data with:
- date: ISO date string
- day_name: Day name (Mon, Tue, etc.)
- forecast_revenue: dry + wet forecast
- forecast_dry: dry forecast only
- forecast_wet: wet forecast only
"""
daily = []
for day in forecast_data:
dry = day.get("dry", {})
wet = day.get("wet", {})
dry_forecast = Decimal(str(dry.get("forecast", 0) or 0))
wet_forecast = Decimal(str(wet.get("forecast", 0) or 0))
daily.append({
"date": day.get("date"),
"day_name": day.get("day", ""),
"forecast_revenue": dry_forecast + wet_forecast,
"forecast_dry": dry_forecast,
"forecast_wet": wet_forecast,
})
return daily

View file

@ -0,0 +1,503 @@
"""
IMAP Email Inbox Sync Service
Monitors email inbox for invoice attachments and processes them through
the existing Azure OCR pipeline.
"""
import asyncio
import email
import imaplib
import logging
import os
import uuid
from datetime import datetime
from email.header import decode_header
from email.utils import parsedate_to_datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.settings import KitchenSettings
from models.invoice import Invoice, InvoiceStatus
from models.email_processing import EmailProcessingLog
logger = logging.getLogger(__name__)
class ImapSyncService:
"""Service for syncing invoice emails from IMAP inbox"""
# Only process PDFs to avoid logos and other images getting mixed in
SUPPORTED_EXTENSIONS = {'.pdf'}
SUPPORTED_CONTENT_TYPES = {
'application/pdf'
}
def __init__(self, kitchen_id: int, db: AsyncSession):
self.kitchen_id = kitchen_id
self.db = db
self._settings: Optional[KitchenSettings] = None
async def _get_settings(self) -> KitchenSettings:
"""Fetch and cache kitchen settings"""
if self._settings is None:
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
self._settings = result.scalar_one_or_none()
if not self._settings:
raise ValueError(f"No settings found for kitchen {self.kitchen_id}")
return self._settings
def _connect_imap(self, settings: KitchenSettings) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
"""Create authenticated IMAP connection"""
if settings.imap_use_ssl:
conn = imaplib.IMAP4_SSL(settings.imap_host, settings.imap_port or 993)
else:
conn = imaplib.IMAP4(settings.imap_host, settings.imap_port or 143)
conn.starttls()
conn.login(settings.imap_username, settings.imap_password)
return conn
def _decode_header_value(self, value: str) -> str:
"""Decode email header value"""
if value is None:
return ""
decoded_parts = decode_header(value)
result = []
for part, encoding in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(encoding or 'utf-8', errors='replace'))
else:
result.append(part)
return ''.join(result)
def _get_message_id(self, msg: email.message.Message) -> str:
"""Extract Message-ID from email"""
message_id = msg.get('Message-ID', '')
if not message_id:
# Generate a fallback ID using date and subject
date = msg.get('Date', '')
subject = msg.get('Subject', '')
message_id = f"<fallback-{hash(date + subject)}>"
return message_id.strip()
def _is_supported_attachment(self, filename: str, content_type: str) -> bool:
"""Check if attachment is a supported invoice format"""
if not filename:
return False
ext = os.path.splitext(filename.lower())[1]
if ext in self.SUPPORTED_EXTENSIONS:
return True
content_type_lower = content_type.lower() if content_type else ''
return content_type_lower in self.SUPPORTED_CONTENT_TYPES
def _extract_attachments(self, msg: email.message.Message) -> list[tuple[str, bytes, str]]:
"""
Extract supported attachments from email.
Returns list of (filename, content, content_type)
"""
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition or part.get_filename():
filename = part.get_filename()
if filename:
filename = self._decode_header_value(filename)
content_type = part.get_content_type()
if self._is_supported_attachment(filename, content_type):
content = part.get_payload(decode=True)
if content:
attachments.append((filename, content, content_type))
else:
# Single part email - unlikely to have attachment but check anyway
filename = msg.get_filename()
if filename:
filename = self._decode_header_value(filename)
content_type = msg.get_content_type()
if self._is_supported_attachment(filename, content_type):
content = msg.get_payload(decode=True)
if content:
attachments.append((filename, content, content_type))
return attachments
async def _save_attachment(self, content: bytes, filename: str) -> str:
"""Save attachment to filesystem and return the path"""
# Get file extension
ext = os.path.splitext(filename)[1].lower()
if not ext:
ext = '.pdf' # Default to PDF if no extension
# Generate unique filename
unique_filename = f"{uuid.uuid4()}{ext}"
data_dir = f"/app/data/{self.kitchen_id}"
os.makedirs(data_dir, exist_ok=True)
file_path = os.path.join(data_dir, unique_filename)
# Write file asynchronously
await asyncio.to_thread(self._write_file, file_path, content)
return file_path
def _write_file(self, path: str, content: bytes):
"""Write content to file (blocking operation)"""
with open(path, 'wb') as f:
f.write(content)
async def _process_attachment(
self,
file_path: str,
email_subject: str
) -> tuple[int, float]:
"""
Process attachment through the same pipeline as manual uploads.
Creates invoice record, then runs full OCR processing including
line items, product definitions, and duplicate detection.
Returns (invoice_id, confidence)
"""
from api.invoices import process_invoice_background
# Create invoice record with source tracking
invoice = Invoice(
kitchen_id=self.kitchen_id,
image_path=file_path,
status=InvoiceStatus.PENDING,
source="email",
source_reference=email_subject[:255] if email_subject else None
)
self.db.add(invoice)
await self.db.commit()
await self.db.refresh(invoice)
invoice_id = invoice.id
try:
# Process through the same flow as manual uploads
# This handles OCR, line items, product definitions, and duplicate detection
await process_invoice_background(invoice_id, file_path, self.kitchen_id)
# Use a fresh session to fetch updated invoice data
# (process_invoice_background uses its own session, so we need fresh data)
from database import AsyncSessionLocal
async with AsyncSessionLocal() as fresh_db:
result = await fresh_db.execute(
select(Invoice).where(Invoice.id == invoice_id)
)
processed_invoice = result.scalar_one_or_none()
if processed_invoice:
confidence = float(processed_invoice.ocr_confidence) if processed_invoice.ocr_confidence else 0.0
logger.info(f"Invoice {invoice_id} processed with confidence {confidence}")
return invoice_id, confidence
else:
logger.warning(f"Invoice {invoice_id} not found after processing")
return invoice_id, 0.0
except Exception as e:
logger.error(f"Processing failed for invoice {invoice_id}: {e}")
# Update the invoice to mark it as having an error using fresh session
from database import AsyncSessionLocal
async with AsyncSessionLocal() as error_db:
result = await error_db.execute(
select(Invoice).where(Invoice.id == invoice_id)
)
invoice = result.scalar_one_or_none()
if invoice:
invoice.status = InvoiceStatus.PROCESSED
invoice.ocr_raw_text = f"Processing Error: {str(e)}"
await error_db.commit()
return invoice_id, 0.0
async def _should_mark_read(self, attachment_results: list[tuple[int, float]]) -> bool:
"""
Determine if email should be marked as read.
Returns True if ANY attachment has confidence >= threshold.
"""
settings = await self._get_settings()
threshold = float(settings.imap_confidence_threshold or 0.5)
logger.info(f"Checking mark-as-read: threshold={threshold}, results={attachment_results}")
for invoice_id, confidence in attachment_results:
if confidence is not None and confidence >= threshold:
logger.info(f"Invoice {invoice_id} meets threshold ({confidence} >= {threshold}), will mark as read")
return True
logger.info(f"No invoices met threshold, will NOT mark as read")
return False
def _mark_email_read(self, conn: imaplib.IMAP4, uid: bytes):
"""Mark email as read (add SEEN flag)"""
logger.info(f"Marking email UID {uid} as read")
result = conn.uid('STORE', uid, '+FLAGS', '\\Seen')
logger.info(f"Mark as read result: {result}")
async def _is_already_processed(self, message_id: str) -> bool:
"""Check if email was already processed"""
result = await self.db.execute(
select(EmailProcessingLog).where(
EmailProcessingLog.kitchen_id == self.kitchen_id,
EmailProcessingLog.message_id == message_id
)
)
return result.scalar_one_or_none() is not None
async def _log_processing(
self,
message_id: str,
email_subject: str,
email_from: str,
email_date: datetime,
attachments_count: int,
invoices_created: int,
confident_invoices: int,
marked_as_read: bool,
invoice_ids: list[int],
status: str = "success",
error: str = None
) -> EmailProcessingLog:
"""Create processing log entry"""
# Convert timezone-aware datetime to naive UTC datetime for database storage
naive_email_date = None
if email_date:
if email_date.tzinfo is not None:
# Convert to UTC and remove timezone info
from datetime import timezone
naive_email_date = email_date.astimezone(timezone.utc).replace(tzinfo=None)
else:
naive_email_date = email_date
log = EmailProcessingLog(
kitchen_id=self.kitchen_id,
message_id=message_id,
email_subject=email_subject[:500] if email_subject else None,
email_from=email_from[:255] if email_from else None,
email_date=naive_email_date,
attachments_count=attachments_count,
invoices_created=invoices_created,
confident_invoices=confident_invoices,
marked_as_read=marked_as_read,
processing_status=status,
error_message=error,
invoice_ids=invoice_ids if invoice_ids else None
)
self.db.add(log)
await self.db.commit()
return log
async def process_inbox(self) -> dict:
"""
Main sync method - process all unread emails.
Returns:
dict with:
- emails_checked: int
- emails_processed: int
- emails_skipped: int
- attachments_processed: int
- invoices_created: int
- confident_invoices: int
- emails_marked_read: int
- errors: list[str]
"""
settings = await self._get_settings()
if not settings.imap_host or not settings.imap_password:
raise ValueError("IMAP settings not configured")
results = {
"emails_checked": 0,
"emails_processed": 0,
"emails_skipped": 0,
"attachments_processed": 0,
"invoices_created": 0,
"confident_invoices": 0,
"emails_marked_read": 0,
"errors": []
}
conn = None
try:
# Connect to IMAP in thread pool (blocking operation)
conn = await asyncio.to_thread(self._connect_imap, settings)
# Select folder
folder = settings.imap_folder or "INBOX"
status, _ = await asyncio.to_thread(conn.select, folder)
if status != "OK":
raise ValueError(f"Could not select folder: {folder}")
# Search for unread emails
status, messages = await asyncio.to_thread(conn.uid, 'SEARCH', None, 'UNSEEN')
if status != "OK":
raise ValueError("Could not search for unread emails")
email_uids = messages[0].split()
results["emails_checked"] = len(email_uids)
for uid in email_uids:
try:
# Fetch email
status, msg_data = await asyncio.to_thread(
conn.uid, 'FETCH', uid, '(RFC822)'
)
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Extract metadata
message_id = self._get_message_id(msg)
email_subject = self._decode_header_value(msg.get('Subject', ''))
email_from = self._decode_header_value(msg.get('From', ''))
# Parse date
date_str = msg.get('Date')
email_date = None
if date_str:
try:
email_date = parsedate_to_datetime(date_str)
except Exception:
pass
# Check if already processed
if await self._is_already_processed(message_id):
results["emails_skipped"] += 1
continue
# Extract attachments
attachments = self._extract_attachments(msg)
if not attachments:
# No supported attachments - log and skip
await self._log_processing(
message_id=message_id,
email_subject=email_subject,
email_from=email_from,
email_date=email_date,
attachments_count=0,
invoices_created=0,
confident_invoices=0,
marked_as_read=False,
invoice_ids=[],
status="skipped",
error="No supported attachments found"
)
results["emails_skipped"] += 1
continue
# Process each attachment
attachment_results = []
invoice_ids = []
for filename, content, content_type in attachments:
try:
# Save attachment
file_path = await self._save_attachment(content, filename)
# Process through OCR
invoice_id, confidence = await self._process_attachment(
file_path, email_subject
)
attachment_results.append((invoice_id, confidence))
invoice_ids.append(invoice_id)
results["attachments_processed"] += 1
results["invoices_created"] += 1
# Count confident invoices
threshold = float(settings.imap_confidence_threshold or 0.5)
if confidence >= threshold:
results["confident_invoices"] += 1
except Exception as e:
logger.error(f"Failed to process attachment {filename}: {e}")
results["errors"].append(f"Attachment {filename}: {str(e)}")
# Determine if email should be marked as read
should_mark_read = await self._should_mark_read(attachment_results)
if should_mark_read:
await asyncio.to_thread(self._mark_email_read, conn, uid)
results["emails_marked_read"] += 1
# Log processing
await self._log_processing(
message_id=message_id,
email_subject=email_subject,
email_from=email_from,
email_date=email_date,
attachments_count=len(attachments),
invoices_created=len(invoice_ids),
confident_invoices=sum(1 for _, c in attachment_results if c >= float(settings.imap_confidence_threshold or 0.5)),
marked_as_read=should_mark_read,
invoice_ids=invoice_ids,
status="success"
)
results["emails_processed"] += 1
except Exception as e:
logger.error(f"Failed to process email UID {uid}: {e}")
results["errors"].append(f"Email UID {uid}: {str(e)}")
# Update last sync timestamp
settings.imap_last_sync = datetime.utcnow()
await self.db.commit()
finally:
if conn:
try:
await asyncio.to_thread(conn.close)
await asyncio.to_thread(conn.logout)
except Exception:
pass
return results
async def test_connection(self) -> dict:
"""
Test IMAP connection and return folder list.
Returns: {"success": True, "folders": [...]} or {"success": False, "error": "..."}
"""
settings = await self._get_settings()
if not settings.imap_host or not settings.imap_password:
return {"success": False, "error": "IMAP settings not configured"}
conn = None
try:
conn = await asyncio.to_thread(self._connect_imap, settings)
# List folders
status, folders_data = await asyncio.to_thread(conn.list)
if status != "OK":
return {"success": False, "error": "Could not list folders"}
folders = []
for folder_data in folders_data:
if isinstance(folder_data, bytes):
# Parse folder name from response like: (\HasNoChildren) "/" "INBOX"
parts = folder_data.decode().split(' "')
if len(parts) >= 2:
folder_name = parts[-1].strip('"')
folders.append(folder_name)
return {"success": True, "folders": folders}
except imaplib.IMAP4.error as e:
return {"success": False, "error": f"IMAP error: {str(e)}"}
except Exception as e:
return {"success": False, "error": str(e)}
finally:
if conn:
try:
await asyncio.to_thread(conn.logout)
except Exception:
pass

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,959 @@
"""
Newbook API Client Service
Handles authentication and API calls to Newbook PMS.
Base URL: https://api.newbook.cloud/rest/
Auth: HTTP Basic Auth (username:password) + API Key + Region in request body
"""
import logging
import httpx
from datetime import date
from typing import Optional, Any
from decimal import Decimal
logger = logging.getLogger(__name__)
# Single base URL for all regions - region is passed in request body
NEWBOOK_BASE_URL = "https://api.newbook.cloud/rest/"
# Valid regions (passed in request body, not URL)
VALID_REGIONS = ["au", "ap", "eu", "us", "uk"]
class NewbookAPIError(Exception):
"""Custom exception for Newbook API errors"""
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class NewbookAPIClient:
"""
Async client for Newbook REST API.
Usage:
async with NewbookAPIClient(username, password, api_key, region, instance_id) as client:
accounts = await client.get_gl_accounts()
revenue = await client.get_earned_revenue(date_from, date_to)
"""
def __init__(
self,
username: str,
password: str,
api_key: str,
region: str = "au",
instance_id: str = None
):
self.username = username
self.password = password
self.api_key = api_key
self.region = region # Passed in request body
self.instance_id = instance_id
self.base_url = NEWBOOK_BASE_URL
self._client: httpx.AsyncClient = None
async def __aenter__(self):
self._client = httpx.AsyncClient(
auth=(self.username, self.password),
timeout=httpx.Timeout(30.0, connect=15.0),
follow_redirects=True,
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._client:
await self._client.aclose()
async def _request(self, endpoint: str, payload: dict = None) -> dict:
"""
Make an authenticated request to Newbook API.
All requests include api_key in the body.
"""
if payload is None:
payload = {}
# Always include region and api_key in request body
payload["region"] = self.region
payload["api_key"] = self.api_key
# Include instance_id if configured
if self.instance_id:
payload["instance_id"] = self.instance_id
url = f"{self.base_url}{endpoint}"
try:
logger.info(f"Newbook API request: POST {endpoint}")
response = await self._client.post(url, json=payload)
if response.status_code == 401:
raise NewbookAPIError("Authentication failed. Check username/password.", 401)
if response.status_code == 403:
raise NewbookAPIError("Access denied. Check API key and permissions.", 403)
response.raise_for_status()
data = response.json()
# Check for Newbook-specific error responses
if isinstance(data, dict) and data.get("success") is False:
error_msg = data.get("message", "Unknown Newbook API error")
raise NewbookAPIError(error_msg, response.status_code, data)
return data
except httpx.HTTPStatusError as e:
logger.error(f"Newbook API HTTP error: {e.response.status_code}")
raise NewbookAPIError(f"HTTP {e.response.status_code}: {str(e)}", e.response.status_code)
except httpx.RequestError as e:
logger.error(f"Newbook API request error: {e}")
raise NewbookAPIError(f"Request failed: {str(e)}")
async def test_connection(self) -> bool:
"""Test API connection by fetching GL accounts (lightweight call)"""
try:
await self.get_gl_accounts()
return True
except NewbookAPIError:
return False
async def get_gl_accounts(self) -> list[dict]:
"""
Fetch list of GL accounts from Newbook.
Endpoint: gl_account_list
Note: Newbook returns individual GL accounts with group info.
Each item has both gl_account_id/gl_account_name (individual)
and gl_group_id/gl_group_name (category).
Returns list of individual GL accounts with:
- id: GL account ID (gl_account_id)
- code: Account code
- name: Account name (gl_account_name)
- group_id: Parent group ID (gl_group_id)
- group_name: Parent group name (gl_group_name)
- type: Account type
"""
response = await self._request("gl_account_list")
# Normalize response format
accounts = {} # Use dict to dedupe by gl_account_id
items = response.get("data", response) if isinstance(response, dict) else response
if isinstance(items, list):
for item in items:
# Get individual account ID (prefer gl_account_id, fall back to id)
gl_account_id = str(item.get("gl_account_id", item.get("id", "")))
gl_account_name = item.get("gl_account_name", item.get("name", ""))
# Get group info for categorization
gl_group_id = str(item.get("gl_group_id", ""))
gl_group_name = item.get("gl_group_name", "")
if not gl_account_id or gl_account_id in accounts:
continue
# Extract code from account name or use account ID
code = item.get("gl_account_code", item.get("code", ""))
if not code and " - " in gl_account_name:
code = gl_account_name.split(" - ")[0].strip()
accounts[gl_account_id] = {
"id": gl_account_id,
"code": code,
"name": gl_account_name,
"group_id": gl_group_id,
"group_name": gl_group_name,
"type": item.get("gl_type", item.get("type", ""))
}
result = list(accounts.values())
logger.info(f"Fetched {len(result)} GL accounts from Newbook")
return result
async def get_earned_revenue(
self,
date_from: date,
date_to: date,
gl_account_ids: list[str] = None
) -> list[dict]:
"""
Fetch earned revenue report - requests one day at a time to get daily breakdown.
Endpoint: reports_earned_revenue
Note: Newbook returns period totals per GL account, not daily breakdown.
We request each day individually to get daily revenue per GL account.
Rate limited to ~80 requests/min to stay under Newbook's 100/min limit.
Args:
date_from: Start date
date_to: End date
gl_account_ids: Optional list of GL account IDs to filter
Returns list of daily revenue entries:
- date: Date (ISO string)
- gl_account_id: GL Account ID
- gl_account_name: GL Account name
- amount_net: Net amount (exc tax)
- amount_gross: Gross amount (inc tax) if available
"""
import asyncio
from datetime import timedelta
revenue_entries = []
current_date = date_from
request_count = 0
# Rate limit: ~80 requests/min to stay under 100/min limit
# 0.75 seconds between requests = 80 requests/min
RATE_LIMIT_DELAY = 0.75
# Request each day individually to get daily breakdown
while current_date <= date_to:
payload = {
"period_from": current_date.isoformat(),
"period_to": current_date.isoformat(),
}
if gl_account_ids:
payload["gl_account_ids"] = gl_account_ids
try:
response = await self._request("reports_earned_revenue", payload)
request_count += 1
items = response.get("data", response) if isinstance(response, dict) else response
# Log first response to debug field names
if request_count == 1:
logger.info(f"Earned revenue first response sample: {items[:2] if isinstance(items, list) else items}")
if isinstance(items, list):
for item in items:
# Newbook returns: earned_revenue_ex (net), earned_revenue (gross)
# Also check legacy field names as fallback
amount_net = Decimal(str(
item.get("earned_revenue_ex") or
item.get("amount_net") or
item.get("amount", 0) or 0
))
# Skip zero amounts
if amount_net == 0:
continue
# GL account code is in gl_account_code field
gl_code = str(
item.get("gl_account_code") or
item.get("gl_account_id", "")
)
gl_name = (
item.get("gl_account_description") or
item.get("gl_account_name", "")
)
entry = {
"date": current_date.isoformat(),
"gl_account_id": gl_code, # Note: this is actually the code, not internal ID
"gl_account_name": gl_name,
"amount_net": amount_net,
"amount_gross": None
}
# Gross amount is earned_revenue (inc tax)
gross = item.get("earned_revenue") or item.get("amount_gross")
if gross:
entry["amount_gross"] = Decimal(str(gross))
revenue_entries.append(entry)
except NewbookAPIError as e:
logger.warning(f"Failed to fetch revenue for {current_date}: {e}")
current_date += timedelta(days=1)
# Rate limiting delay between requests
if current_date <= date_to:
await asyncio.sleep(RATE_LIMIT_DELAY)
logger.info(f"Fetched {len(revenue_entries)} revenue entries from Newbook ({date_from} to {date_to}, {request_count} requests)")
return revenue_entries
async def get_occupancy_report(
self,
date_from: date,
date_to: date
) -> list[dict]:
"""
Fetch occupancy report.
Endpoint: reports_occupancy
Note: Newbook returns data grouped by room category. Each category has
an 'occupancy' dict with dates as keys. We aggregate across all categories
to get daily totals.
Returns list of daily occupancy data:
- date
- total_rooms
- occupied_rooms
- occupancy_percentage
- total_guests (estimated from occupied rooms)
"""
payload = {
"period_from": date_from.isoformat(),
"period_to": date_to.isoformat(),
}
response = await self._request("reports_occupancy", payload)
# Aggregate occupancy across all room categories by date
daily_totals = {} # date -> {available, occupied, maintenance, adults, children}
items = response.get("data", response) if isinstance(response, dict) else response
if isinstance(items, list):
for category in items:
# Each category has an 'occupancy' dict with dates as keys
category_occupancy = category.get("occupancy", {})
if isinstance(category_occupancy, dict):
for date_str, day_data in category_occupancy.items():
# Debug: log first day's data structure to see guest field format
if not daily_totals:
logger.info(f"Occupancy day_data sample keys: {list(day_data.keys()) if isinstance(day_data, dict) else 'not dict'}")
logger.info(f"Occupancy day_data sample: {day_data}")
if date_str not in daily_totals:
daily_totals[date_str] = {
"available": 0,
"occupied": 0,
"maintenance": 0,
"adults": 0,
"children": 0
}
daily_totals[date_str]["available"] += day_data.get("available", 0) or 0
daily_totals[date_str]["occupied"] += day_data.get("occupied", 0) or 0
daily_totals[date_str]["maintenance"] += day_data.get("maintenance", 0) or 0
# Parse guest counts - can be direct fields or in arrays
# Handle array format: [adults, children, infants] or [{type, count}, ...]
guests_data = day_data.get("guests", day_data.get("people", None))
if isinstance(guests_data, list):
if len(guests_data) >= 2:
# Check if it's [adults, children, infants] format (numbers)
if isinstance(guests_data[0], (int, float)):
daily_totals[date_str]["adults"] += int(guests_data[0] or 0)
daily_totals[date_str]["children"] += int(guests_data[1] or 0)
# Ignore infants at index 2
# Check if it's [{type, count}, ...] format
elif isinstance(guests_data[0], dict):
for guest_item in guests_data:
guest_type = str(guest_item.get("type", guest_item.get("name", ""))).lower()
count = int(guest_item.get("count", guest_item.get("quantity", 0)) or 0)
if "adult" in guest_type:
daily_totals[date_str]["adults"] += count
elif "child" in guest_type:
daily_totals[date_str]["children"] += count
# Ignore infants
else:
# Try direct fields
daily_totals[date_str]["adults"] += int(day_data.get("adults", 0) or 0)
daily_totals[date_str]["children"] += int(day_data.get("children", 0) or 0)
# Convert to list format
occupancy_data = []
for date_str, totals in sorted(daily_totals.items()):
total_rooms = totals["available"]
occupied_rooms = totals["occupied"]
total_guests = totals["adults"] + totals["children"]
# Calculate occupancy percentage
occupancy_pct = None
if total_rooms > 0:
occupancy_pct = Decimal(str(round(occupied_rooms / total_rooms * 100, 2)))
# Fall back to occupied_rooms if no guest data
if total_guests == 0:
total_guests = occupied_rooms
occupancy_data.append({
"date": date_str,
"total_rooms": total_rooms,
"occupied_rooms": occupied_rooms,
"occupancy_percentage": occupancy_pct,
"total_guests": total_guests,
})
logger.info(f"Fetched {len(occupancy_data)} daily occupancy records from Newbook (aggregated from {len(items) if items else 0} categories)")
return occupancy_data
async def get_bookings(
self,
date_from: date,
date_to: date,
include_cancelled: bool = False
) -> list[dict]:
"""
Fetch bookings list with inventory items.
Endpoint: bookings_list
Note: Newbook paginates results (default 100, max 1000 per request).
This method automatically fetches all pages.
Args:
date_from: Check-in date from
date_to: Check-in date to
include_cancelled: Include cancelled bookings
Returns list of bookings with inventory items
"""
bookings = []
data_offset = 0
data_limit = 1000 # Max allowed by Newbook
total_fetched = 0
while True:
payload = {
"period_from": date_from.isoformat(),
"period_to": date_to.isoformat(),
"list_type": "staying", # Get bookings staying on these dates
"data_offset": data_offset,
"data_limit": data_limit,
}
response = await self._request("bookings_list", payload)
# Get pagination info from response
data_total = response.get("data_total", 0)
data_count = response.get("data_count", 0)
items = response.get("data", response) if isinstance(response, dict) else response
if isinstance(items, list):
for item in items:
status = item.get("status", "").lower()
if not include_cancelled and status == "cancelled":
continue
# Parse guest count - use booking_adults + booking_children directly
# Newbook provides these as separate fields, excluding infants (who don't eat full meals)
adults = int(item.get("booking_adults", 0) or 0)
children = int(item.get("booking_children", 0) or 0)
infants = int(item.get("booking_infants", 0) or 0)
num_guests = adults + children
# Fall back to 1 only if no guest data at all (single occupancy assumed)
# Don't fall back if there are only infants - they don't count for meals
if num_guests == 0 and infants == 0:
num_guests = 1
# Log first booking's structure to debug
if len(bookings) == 0:
logger.info(f"First booking guest data - adults: {item.get('booking_adults')}, children: {item.get('booking_children')}, infants: {item.get('booking_infants')} => counted: {num_guests}")
logger.info(f"First booking dates - arrival: {item.get('booking_arrival')}, departure: {item.get('booking_departure')}")
logger.info(f"First booking category_id: {item.get('category_id')}")
# Get room category/type - use category_id which maps to room types
# category_id is the numeric ID that corresponds to room types like "Standard", "Suite"
category_id = item.get("category_id") or item.get("site_category_id") or ""
room_type_name = (
item.get("site_type") or
item.get("category_name") or
item.get("room_type_name") or
""
)
# Log first booking's room fields to debug matching
if len(bookings) == 0:
logger.info(f"First booking room fields - category_id: {category_id}, site_type: {item.get('site_type')}, site_name: {item.get('site_name')}")
# Get booking group ID for related bookings (e.g., family/party traveling together)
bookings_group_id = item.get("bookings_group_id")
if bookings_group_id:
bookings_group_id = str(bookings_group_id)
bookings.append({
"booking_id": str(item.get("id", item.get("booking_id", ""))),
"booking_reference": item.get("reference", item.get("booking_reference", item.get("booking_reference_id"))),
"bookings_group_id": bookings_group_id, # Group ID for related bookings
"check_in_date": item.get("booking_arrival", item.get("check_in", item.get("check_in_date"))),
"check_out_date": item.get("booking_departure", item.get("check_out", item.get("check_out_date"))),
"nights": item.get("booking_length", item.get("nights")),
"room_type": room_type_name, # Room type name if available
"category_id": str(category_id) if category_id else "", # Category ID for filtering
"site_id": item.get("site_id"), # Individual room ID
"site_name": item.get("site_name"), # Room number (e.g. "108")
"num_guests": num_guests,
"booking_adults": int(item.get("booking_adults", 0) or 0), # Debug
"booking_children": int(item.get("booking_children", 0) or 0), # Debug
"booking_infants": int(item.get("booking_infants", 0) or 0), # Debug
"num_rooms": item.get("rooms", item.get("num_rooms", 1)),
"total_amount": Decimal(str(item.get("total", 0))) if item.get("total") else None,
"status": status,
"inventory_items": item.get("inventory_items", item.get("items", []))
})
total_fetched += len(items)
# Check if we've fetched all records
if data_count == 0 or total_fetched >= data_total:
break
# Move to next page
data_offset += data_limit
logger.info(f"Fetching next page of bookings (offset: {data_offset}, total: {data_total})")
logger.info(f"Fetched {len(bookings)} bookings from Newbook (total available: {data_total})")
return bookings
def process_bookings_for_allocations(
self,
bookings: list[dict],
breakfast_gl_codes: list[str],
dinner_gl_codes: list[str],
gl_account_id_to_code: dict[str, str] = None,
breakfast_vat_rate: Decimal = None,
dinner_vat_rate: Decimal = None
) -> dict[str, dict]:
"""
Process bookings inventory items to calculate meal allocations per date.
Args:
bookings: List of bookings with inventory_items
breakfast_gl_codes: GL codes that indicate breakfast allocation
dinner_gl_codes: GL codes that indicate dinner allocation
gl_account_id_to_code: Mapping from Newbook gl_account_id to gl_code
breakfast_vat_rate: VAT rate for breakfast (e.g., 0.10 for 10%)
dinner_vat_rate: VAT rate for dinner (e.g., 0.10 for 10%)
Returns dict keyed by date with:
- breakfast_qty: Total breakfast allocations
- breakfast_netvalue: Total breakfast net value (exc VAT)
- dinner_qty: Total dinner allocations
- dinner_netvalue: Total dinner net value (exc VAT)
"""
allocations_by_date = {}
gl_account_id_to_code = gl_account_id_to_code or {}
# Default VAT rates if not provided
breakfast_vat_rate = breakfast_vat_rate or Decimal("0.10")
dinner_vat_rate = dinner_vat_rate or Decimal("0.10")
logger.info(f"Processing {len(bookings)} bookings for allocations")
logger.info(f"Breakfast GL codes: {breakfast_gl_codes}, Dinner GL codes: {dinner_gl_codes}")
logger.info(f"VAT rates - Breakfast: {breakfast_vat_rate}, Dinner: {dinner_vat_rate}")
logger.info(f"GL account ID to code mapping has {len(gl_account_id_to_code)} entries")
for booking in bookings:
inventory_items = booking.get("inventory_items", [])
if not inventory_items:
continue
# Get guest count for this booking - PAX should be based on guests, not item qty
booking_guests = booking.get("num_guests", 1) or 1
for item in inventory_items:
# Newbook inventory items use gl_account_id (internal ID), not gl_code
gl_account_id = str(item.get("gl_account_id", ""))
# Translate to gl_code using our mapping
gl_code = gl_account_id_to_code.get(gl_account_id, "")
# Date is in stay_date field
item_date = item.get("stay_date", item.get("date", item.get("item_date")))
# PAX is the number of guests in the booking, not the inventory item qty
# (inventory items are typically 1 per booking per day, but represent all guests)
pax = booking_guests
# Amount field - Newbook returns gross (inc VAT)
gross_amount = Decimal(str(item.get("amount", item.get("net_amount", 0)) or 0))
if not item_date or not gl_code:
continue
if item_date not in allocations_by_date:
allocations_by_date[item_date] = {
"breakfast_qty": 0,
"breakfast_netvalue": Decimal("0"),
"dinner_qty": 0,
"dinner_netvalue": Decimal("0"),
}
# Check if this item matches breakfast or dinner GL codes
# Calculate net from gross: net = gross / (1 + vat_rate)
if gl_code in breakfast_gl_codes:
net_amount = gross_amount / (1 + breakfast_vat_rate)
allocations_by_date[item_date]["breakfast_qty"] += pax
allocations_by_date[item_date]["breakfast_netvalue"] += net_amount.quantize(Decimal("0.01"))
elif gl_code in dinner_gl_codes:
net_amount = gross_amount / (1 + dinner_vat_rate)
allocations_by_date[item_date]["dinner_qty"] += pax
allocations_by_date[item_date]["dinner_netvalue"] += net_amount.quantize(Decimal("0.01"))
logger.info(f"Found allocations for {len(allocations_by_date)} dates")
return allocations_by_date
async def get_site_list(self) -> list[dict]:
"""
Fetch site/room categories from Newbook and aggregate by room type.
Endpoint: site_list
Returns list of unique room types (aggregated from individual sites):
- id: Type name (used as ID since types don't have IDs)
- name: Type name (e.g., "Standard Room", "Overflow")
- type: Same as name
- count: Number of sites/rooms of this type
"""
response = await self._request("site_list")
# Log the raw response structure to understand it
logger.info(f"site_list raw response type: {type(response)}")
if isinstance(response, dict):
logger.info(f"site_list response keys: {response.keys()}")
items = response.get("data", response) if isinstance(response, dict) else response
# Log first few items to understand structure
if isinstance(items, list) and len(items) > 0:
logger.info(f"site_list first item keys: {items[0].keys() if isinstance(items[0], dict) else 'not a dict'}")
logger.info(f"site_list first 3 items: {items[:3]}")
# Aggregate by room type name AND build category_id -> type mapping
type_counts = {} # type_name -> count
category_id_to_type = {} # category_id -> room_type (for booking filtering)
if isinstance(items, list):
for item in items:
# Get category_id (what bookings use to identify room type)
category_id = item.get("category_id") or item.get("site_category_id")
# Try various field names that might contain the room type/category
room_type = (
item.get("site_type") or
item.get("type") or
item.get("category") or
item.get("category_name") or
item.get("room_type") or
item.get("site_category") or
""
)
# Fall back to site_name if no type found (shouldn't happen normally)
if not room_type:
room_type = item.get("site_name", item.get("name", "Unknown"))
logger.debug(f"No type field found, using site_name: {room_type}")
if room_type:
type_counts[room_type] = type_counts.get(room_type, 0) + 1
# Build mapping from category_id to type
if category_id and room_type:
category_id_to_type[str(category_id)] = room_type
# Convert to list format
categories = []
for type_name, count in sorted(type_counts.items()):
categories.append({
"id": type_name, # Use type name as ID
"name": type_name,
"type": type_name,
"count": count,
})
logger.info(f"Fetched {len(categories)} unique room types from Newbook (from {sum(type_counts.values())} sites)")
logger.info(f"Built category_id to type mapping: {category_id_to_type}")
return categories, category_id_to_type
def process_bookings_for_guests(
self,
bookings: list[dict],
included_room_types: list[str] = None,
category_id_to_type: dict[str, str] = None
) -> dict[str, int]:
"""
Process bookings to count total guests per stay date.
Args:
bookings: List of bookings from get_bookings()
included_room_types: List of room type names to include (None = all)
category_id_to_type: Mapping from category_id (e.g. "1") to room type (e.g. "Standard")
Returns dict keyed by date string with guest count
"""
from datetime import datetime, timedelta
guests_by_date = {}
total_bookings_processed = 0
total_guests_counted = 0
bookings_filtered_out = 0
category_id_to_type = category_id_to_type or {}
logger.info(f"Processing {len(bookings)} bookings for guest counts")
if included_room_types:
logger.info(f"Filtering to room types: {included_room_types}")
if category_id_to_type:
logger.info(f"Using category_id to type mapping: {category_id_to_type}")
# Resolve room types for all bookings using the mapping
resolved_room_types = set()
for b in bookings:
category_id = b.get("category_id", "")
resolved_type = category_id_to_type.get(category_id, b.get("room_type", category_id))
resolved_room_types.add(resolved_type)
logger.info(f"Resolved room types in bookings: {resolved_room_types}")
# Log matching analysis if filtering
if included_room_types:
matching = resolved_room_types & set(included_room_types)
non_matching = resolved_room_types - set(included_room_types)
logger.info(f"Room type matching: {len(matching)} match, {len(non_matching)} don't match")
if non_matching:
logger.info(f"Non-matching room types: {non_matching}")
for booking in bookings:
# Get category_id and resolve to room type
category_id = booking.get("category_id", "")
room_type = category_id_to_type.get(category_id, booking.get("room_type", category_id))
# Filter by room type if specified
if included_room_types and room_type not in included_room_types:
bookings_filtered_out += 1
continue
# Get guest count and stay dates
# Don't use "or 1" - trust the computed value (0 is valid for infant-only bookings)
num_guests = booking.get("num_guests", 0)
check_in = booking.get("check_in_date")
check_out = booking.get("check_out_date")
nights = booking.get("nights", 1)
if not check_in:
continue
total_bookings_processed += 1
total_guests_counted += num_guests
# Parse check-in date
if isinstance(check_in, str):
try:
check_in_date = datetime.fromisoformat(check_in.split("T")[0]).date()
except ValueError:
continue
else:
check_in_date = check_in
# Calculate stay dates (guest is present from check-in through day before check-out)
if check_out:
if isinstance(check_out, str):
try:
check_out_date = datetime.fromisoformat(check_out.split("T")[0]).date()
except ValueError:
check_out_date = check_in_date + timedelta(days=nights or 1)
else:
check_out_date = check_out
else:
check_out_date = check_in_date + timedelta(days=nights or 1)
# Add guests to each stay date (not including checkout day)
current_date = check_in_date
while current_date < check_out_date:
date_str = current_date.isoformat()
if date_str not in guests_by_date:
guests_by_date[date_str] = 0
guests_by_date[date_str] += num_guests
current_date += timedelta(days=1)
logger.info(f"Guest count summary: processed {total_bookings_processed} bookings, {total_guests_counted} total guests, {bookings_filtered_out} filtered by room type")
logger.info(f"Calculated guest counts for {len(guests_by_date)} dates")
if guests_by_date:
sample_dates = list(guests_by_date.items())[:3]
logger.info(f"Sample guest counts: {sample_dates}")
return guests_by_date
def process_bookings_for_arrivals(
self,
bookings: list[dict],
included_room_types: list[str] = None,
category_id_to_type: dict[str, str] = None
) -> dict[str, dict]:
"""
Process bookings to extract arrival information per check-in date.
Args:
bookings: List of bookings from get_bookings()
included_room_types: Optional room type filter (same as guest count filtering)
category_id_to_type: Mapping from category_id to room type name
Returns dict keyed by check-in date string with:
{
"2026-01-20": {
"count": 6,
"ids": ["12345", "12346", ...],
"details": [
{
"booking_id": "12345",
"booking_reference": "NB-001",
"num_guests": 2,
"room_type": "Standard",
"status": "confirmed"
},
...
]
}
}
"""
from datetime import datetime
arrivals_by_date = {}
category_id_to_type = category_id_to_type or {}
logger.info(f"Processing {len(bookings)} bookings for arrival tracking")
if included_room_types:
logger.info(f"Filtering to room types: {included_room_types}")
for booking in bookings:
# Apply room type filter (consistent with guest count filtering)
category_id = booking.get("category_id", "")
room_type = category_id_to_type.get(category_id, booking.get("room_type", category_id))
if included_room_types and room_type not in included_room_types:
continue
# Skip cancelled bookings
if booking.get("status", "").lower() == "cancelled":
continue
# Get check-in date (this is the arrival date)
check_in = booking.get("check_in_date")
if not check_in:
continue
# Parse to date string
if isinstance(check_in, str):
try:
check_in_date = datetime.fromisoformat(check_in.split("T")[0]).date()
except ValueError:
continue
else:
check_in_date = check_in
date_str = check_in_date.isoformat()
# Initialize date entry if needed
if date_str not in arrivals_by_date:
arrivals_by_date[date_str] = {
"count": 0,
"ids": [],
"details": []
}
# Add arrival
arrivals_by_date[date_str]["count"] += 1
arrivals_by_date[date_str]["ids"].append(str(booking.get("booking_id", "")))
arrivals_by_date[date_str]["details"].append({
"booking_id": str(booking.get("booking_id", "")),
"booking_reference": booking.get("booking_reference", ""),
"num_guests": booking.get("num_guests", 0),
"room_type": room_type,
"status": booking.get("status", "")
})
logger.info(f"Found arrivals for {len(arrivals_by_date)} dates")
if arrivals_by_date:
sample = list(arrivals_by_date.items())[:3]
logger.info(f"Sample arrivals: {sample}")
return arrivals_by_date
async def get_charges_list(
self,
date_from: date,
date_to: date,
account_for: str = None
) -> list[dict]:
"""
Fetch charges list from Newbook with pagination support.
Endpoint: charges_list
Args:
date_from: Period start (charges raised or voided within this period)
date_to: Period end
account_for: Optional filter by account type (leads, guests, bookings, companies, travel_agents)
Returns list of charges with:
- id: Charge ID
- gl_account_id: GL Account ID
- gl_account_code: GL Account code
- description: Charge description (e.g., "Ticket: 22900 - 1 x Venison Bourguignon")
- amount_ex_tax: Net amount (exc tax)
- amount_inc_tax: Gross amount (inc tax)
- generated_when: When the charge was created
- voided_when: When the charge was voided (None if not voided)
- voided_by: Who voided it ("0" if not voided)
"""
charges = []
data_offset = 0
data_limit = 1000 # Max allowed by Newbook
total_fetched = 0
while True:
payload = {
"period_from": f"{date_from.isoformat()} 00:00:00",
"period_to": f"{date_to.isoformat()} 23:59:59",
"data_offset": data_offset,
"data_limit": data_limit,
}
if account_for:
payload["account_for"] = account_for
logger.info(f"Fetching charges: {date_from} to {date_to}, offset={data_offset}, limit={data_limit}")
response = await self._request("charges_list", payload)
# Get pagination info from response
data_total = response.get("data_total", 0)
data_count = response.get("data_count", 0)
items = response.get("data", response) if isinstance(response, dict) else response
if isinstance(items, list):
for item in items:
charges.append({
"id": item.get("id"),
"account_id": item.get("account_id"),
"account_for": item.get("account_for"),
"gl_account_id": str(item.get("gl_account_id", "")),
"gl_account_code": item.get("gl_account_code", ""),
"gl_category_id": item.get("gl_category_id"),
"description": item.get("description", ""),
"amount": Decimal(str(item.get("amount", 0) or 0)),
"amount_ex_tax": Decimal(str(item.get("amount_ex_tax", 0) or 0)),
"amount_inc_tax": Decimal(str(item.get("amount_inc_tax", 0) or 0)),
"tax": Decimal(str(item.get("tax", 0) or 0)),
"generated_when": item.get("generated_when"),
"voided_when": item.get("voided_when"),
"voided_by": str(item.get("voided_by", "0")),
})
total_fetched += len(items)
logger.info(f"Charges page: got {data_count} items, total available: {data_total}, fetched so far: {total_fetched}")
# Check if we've fetched all records
if data_count == 0 or total_fetched >= data_total or len(items) < data_limit:
break
# Move to next page
data_offset += data_limit
logger.info(f"Fetched {len(charges)} total charges from Newbook ({date_from} to {date_to})")
return charges

View file

@ -0,0 +1,774 @@
"""
Newbook Data Sync Service
Handles synchronization of data between Newbook API and local database.
"""
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy.dialects.postgresql import insert
from models.settings import KitchenSettings
from models.newbook import (
NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog, NewbookRoomCategory
)
from services.newbook_api import NewbookAPIClient, NewbookAPIError
logger = logging.getLogger(__name__)
def expand_bookings_by_date(
bookings: list[dict],
date_from: date,
date_to: date,
dinner_gl_codes: list[str],
gl_account_id_to_code: dict[str, str]
) -> dict[date, list[dict]]:
"""
Expand multi-night bookings into per-date room breakdown.
Args:
bookings: List of bookings from get_bookings()
date_from: Start date for expansion
date_to: End date for expansion
dinner_gl_codes: GL codes that indicate dinner allocation (from settings)
gl_account_id_to_code: Mapping from Newbook gl_account_id to gl_code
Returns:
Dict mapping date -> list of room objects for that date
Example:
Booking: room 108, Jan 22-24 (2 nights)
Returns: {
date(2026, 1, 22): [{"room_number": "108", "booking_id": "33471", ...}],
date(2026, 1, 23): [{"room_number": "108", "booking_id": "33471", ...}]
}
"""
breakdown_by_date = {}
for booking in bookings:
check_in = datetime.fromisoformat(booking["check_in_date"]).date()
check_out = datetime.fromisoformat(booking["check_out_date"]).date()
# Iterate each night of the stay (check-in to day before check-out)
current_date = check_in
while current_date < check_out and current_date <= date_to:
if current_date >= date_from:
if current_date not in breakdown_by_date:
breakdown_by_date[current_date] = []
# Detect DBB using existing GL code logic (reuse from process_bookings_for_allocations)
# Check if booking has any inventory items matching dinner GL codes
inventory_items = booking.get("inventory_items", [])
is_dbb = False
for item in inventory_items:
gl_account_id = str(item.get("gl_account_id", ""))
gl_code = gl_account_id_to_code.get(gl_account_id, "")
if gl_code in dinner_gl_codes:
is_dbb = True
break
# Detect package from item_name (no GL code mapping for this yet)
is_package = any(
item.get("item_name", "").lower().find("package") >= 0
for item in inventory_items
)
breakdown_by_date[current_date].append({
"room_number": booking.get("site_name"), # e.g., "108"
"booking_id": booking.get("booking_id"), # e.g., "33471"
"bookings_group_id": booking.get("bookings_group_id"), # Group ID for related bookings
"is_dbb": is_dbb,
"is_package": is_package
})
current_date += timedelta(days=1)
return breakdown_by_date
class NewbookSyncService:
"""Service for syncing Newbook data to local database"""
# Default forecast period (days ahead)
FORECAST_DAYS = 60
def __init__(self, db: AsyncSession, kitchen_id: int):
self.db = db
self.kitchen_id = kitchen_id
self._settings: KitchenSettings = None
async def _get_settings(self) -> KitchenSettings:
"""Fetch and cache kitchen settings"""
if self._settings is None:
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
self._settings = result.scalar_one_or_none()
if not self._settings:
raise ValueError("Kitchen settings not found")
return self._settings
async def _get_client(self) -> NewbookAPIClient:
"""Create authenticated Newbook API client"""
settings = await self._get_settings()
if not all([
settings.newbook_api_username,
settings.newbook_api_password,
settings.newbook_api_key,
settings.newbook_api_region
]):
raise ValueError("Newbook API credentials not fully configured")
return NewbookAPIClient(
username=settings.newbook_api_username,
password=settings.newbook_api_password,
api_key=settings.newbook_api_key,
region=settings.newbook_api_region,
instance_id=settings.newbook_instance_id
)
async def _get_included_room_types(self) -> list[str] | None:
"""Get list of room type names that are included for occupancy calculations.
Returns None if no room categories are configured (include all).
Returns list of site_name values for categories with is_included=True.
"""
result = await self.db.execute(
select(NewbookRoomCategory).where(
NewbookRoomCategory.kitchen_id == self.kitchen_id
)
)
categories = list(result.scalars().all())
if not categories:
# No categories configured - include all
return None
# Return only included category names
included = [cat.site_name for cat in categories if cat.is_included]
logger.info(f"Included room types for occupancy: {included}")
return included if included else None
async def _log_sync(
self,
sync_type: str,
date_from: date = None,
date_to: date = None
) -> NewbookSyncLog:
"""Create a sync log entry"""
log = NewbookSyncLog(
kitchen_id=self.kitchen_id,
sync_type=sync_type,
date_from=date_from,
date_to=date_to,
status="running"
)
self.db.add(log)
await self.db.commit()
await self.db.refresh(log)
return log
async def _complete_sync(
self,
log: NewbookSyncLog,
status: str,
records: int = 0,
error: str = None
):
"""Update sync log on completion"""
log.completed_at = datetime.utcnow()
log.status = status
log.records_fetched = records
log.error_message = error
await self.db.commit()
async def sync_gl_accounts(self) -> list[NewbookGLAccount]:
"""
Fetch and sync GL accounts from Newbook.
Updates existing accounts, adds new ones.
Does NOT delete accounts (preserves user selections).
"""
log = await self._log_sync("gl_accounts")
try:
async with await self._get_client() as client:
accounts_data = await client.get_gl_accounts()
synced_accounts = []
for acc in accounts_data:
# Upsert pattern: update if exists, insert if not
stmt = insert(NewbookGLAccount).values(
kitchen_id=self.kitchen_id,
gl_account_id=acc["id"],
gl_code=acc["code"],
gl_name=acc["name"],
gl_type=acc["type"],
gl_group_id=acc.get("group_id"),
gl_group_name=acc.get("group_name"),
updated_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_gl_account",
set_={
"gl_code": acc["code"],
"gl_name": acc["name"],
"gl_type": acc["type"],
"gl_group_id": acc.get("group_id"),
"gl_group_name": acc.get("group_name"),
"updated_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
await self.db.commit()
# Fetch all accounts for return
result = await self.db.execute(
select(NewbookGLAccount).where(NewbookGLAccount.kitchen_id == self.kitchen_id)
)
synced_accounts = list(result.scalars().all())
await self._complete_sync(log, "success", len(synced_accounts))
logger.info(f"Synced {len(synced_accounts)} GL accounts for kitchen {self.kitchen_id}")
return synced_accounts
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
logger.error(f"GL account sync failed: {e}")
raise
async def sync_revenue(
self,
date_from: date,
date_to: date,
tracked_only: bool = True
) -> int:
"""
Fetch and sync earned revenue data.
Args:
date_from: Start date
date_to: End date
tracked_only: Only fetch for tracked GL accounts
Returns number of records synced
"""
log = await self._log_sync("revenue", date_from, date_to)
try:
# Get tracked GL accounts - we filter locally after fetching all data
# (Newbook API filtering by gl_account_ids is unreliable with code vs ID mismatch)
if tracked_only:
result = await self.db.execute(
select(NewbookGLAccount.id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id,
NewbookGLAccount.is_tracked == True
)
)
tracked_gl = {row[1]: row[0] for row in result.all()} # code -> local id
if not tracked_gl:
logger.warning("No tracked GL accounts, skipping revenue sync")
await self._complete_sync(log, "success", 0)
return 0
gl_map = tracked_gl
else:
# Get all GL accounts
result = await self.db.execute(
select(NewbookGLAccount.id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id
)
)
gl_map = {row[1]: row[0] for row in result.all()} # code -> local id
# Fetch all revenue from Newbook (filter locally by tracked accounts)
async with await self._get_client() as client:
revenue_data = await client.get_earned_revenue(date_from, date_to)
records_count = 0
for entry in revenue_data:
# entry["gl_account_id"] actually contains the gl_code from earned_revenue report
local_gl_id = gl_map.get(entry["gl_account_id"])
if not local_gl_id:
continue
entry_date = date.fromisoformat(entry["date"]) if isinstance(entry["date"], str) else entry["date"]
# Upsert revenue entry
stmt = insert(NewbookDailyRevenue).values(
kitchen_id=self.kitchen_id,
gl_account_id=local_gl_id,
date=entry_date,
amount_net=entry["amount_net"],
amount_gross=entry.get("amount_gross"),
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_revenue_per_day",
set_={
"amount_net": entry["amount_net"],
"amount_gross": entry.get("amount_gross"),
"fetched_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
records_count += 1
await self.db.commit()
await self._complete_sync(log, "success", records_count)
logger.info(f"Synced {records_count} revenue records for kitchen {self.kitchen_id}")
return records_count
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
logger.error(f"Revenue sync failed: {e}")
raise
async def sync_occupancy(
self,
date_from: date,
date_to: date,
is_forecast: bool = False
) -> int:
"""
Fetch and sync occupancy data with meal allocations.
Args:
date_from: Start date
date_to: End date
is_forecast: Mark as forecast data (for future dates)
"""
log = await self._log_sync("occupancy", date_from, date_to)
try:
settings = await self._get_settings()
# Parse breakfast/dinner GL codes from settings
breakfast_gl_codes = []
dinner_gl_codes = []
if settings.newbook_breakfast_gl_codes:
breakfast_gl_codes = [c.strip() for c in settings.newbook_breakfast_gl_codes.split(",") if c.strip()]
if settings.newbook_dinner_gl_codes:
dinner_gl_codes = [c.strip() for c in settings.newbook_dinner_gl_codes.split(",") if c.strip()]
# Get VAT rates from settings
breakfast_vat_rate = settings.newbook_breakfast_vat_rate
dinner_vat_rate = settings.newbook_dinner_vat_rate
# Build GL account ID to code mapping for allocation processing
gl_account_id_to_code = {}
if breakfast_gl_codes or dinner_gl_codes:
result = await self.db.execute(
select(NewbookGLAccount.gl_account_id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id
)
)
gl_account_id_to_code = {row[0]: row[1] for row in result.all()}
logger.info(f"Built GL account mapping with {len(gl_account_id_to_code)} entries")
# Get included room types for filtering
included_room_types = await self._get_included_room_types()
async with await self._get_client() as client:
# Fetch category_id to type mapping for guest filtering
_, category_id_to_type = await client.get_site_list()
# Fetch occupancy data
occupancy_data = await client.get_occupancy_report(date_from, date_to)
# Always fetch bookings to get guest counts and allocations
bookings = await client.get_bookings(date_from, date_to)
# Process bookings for guest counts (filtered by room type)
guests_by_date = client.process_bookings_for_guests(bookings, included_room_types, category_id_to_type)
# Process bookings for meal allocations (also filtered by room type)
allocations_by_date = {}
if breakfast_gl_codes or dinner_gl_codes:
allocations_by_date = client.process_bookings_for_allocations(
bookings, breakfast_gl_codes, dinner_gl_codes, gl_account_id_to_code,
breakfast_vat_rate, dinner_vat_rate
)
# Process bookings for arrival tracking (also filtered by room type)
arrivals_by_date = client.process_bookings_for_arrivals(bookings, included_room_types, category_id_to_type)
# Process bookings for room breakdown (for ResidentsTableChart)
breakdown_by_date = expand_bookings_by_date(
bookings,
date_from,
date_to,
dinner_gl_codes,
gl_account_id_to_code
)
# Diagnostic logging: check for duplicate room entries on same date
for check_date, rooms_on_date in breakdown_by_date.items():
room_to_bookings = {}
for room_entry in rooms_on_date:
room_num = room_entry.get("room_number")
if room_num:
if room_num not in room_to_bookings:
room_to_bookings[room_num] = []
room_to_bookings[room_num].append(room_entry.get("booking_id"))
# Report rooms with multiple bookings on same date
for room_num, booking_ids in room_to_bookings.items():
if len(booking_ids) > 1:
logger.warning(f"Date {check_date}, Room {room_num}: Multiple bookings: {booking_ids}")
# Also log detailed info about bookings with same room number
room_booking_details = {}
for booking in bookings:
room_num = booking.get("site_name")
booking_id = booking.get("booking_id")
if room_num and booking_id:
if room_num not in room_booking_details:
room_booking_details[room_num] = []
room_booking_details[room_num].append({
'booking_id': booking_id,
'booking_reference': booking.get("booking_reference"),
'check_in': booking.get("check_in_date"),
'check_out': booking.get("check_out_date"),
'nights': booking.get("nights")
})
# Report rooms with multiple total bookings in the period
for room_num, booking_list in room_booking_details.items():
if len(booking_list) > 1:
logger.warning(f"Room {room_num} has {len(booking_list)} bookings in period {date_from} to {date_to}:")
for b in booking_list:
logger.warning(f" - Booking {b['booking_id']} (ref: {b['booking_reference']}): {b['check_in']} to {b['check_out']} ({b['nights']} nights)")
records_count = 0
today = date.today()
for entry in occupancy_data:
# Skip entries with no date
if not entry.get("date"):
continue
entry_date = date.fromisoformat(entry["date"]) if isinstance(entry["date"], str) else entry["date"]
# Skip if date parsing failed
if not entry_date:
continue
# Determine if this is forecast/current data (today or future)
# Today should be updatable, not locked as historical
entry_is_forecast = entry_date >= today
# Get allocations for this date if available
allocs = allocations_by_date.get(entry["date"], allocations_by_date.get(str(entry_date), {}))
# Get guest count from bookings (filtered by room type)
guest_count = guests_by_date.get(str(entry_date), guests_by_date.get(entry["date"]))
# Get arrivals for this date
arrivals = arrivals_by_date.get(str(entry_date), arrivals_by_date.get(entry["date"], {}))
# Get rooms breakdown for this date
rooms_breakdown = breakdown_by_date.get(entry_date, [])
# Log first few entries for debugging
if records_count < 3:
logger.info(f"Occupancy entry: date={entry_date}, guest_count={guest_count}, occupied_rooms={entry.get('occupied_rooms')}, is_forecast={entry_is_forecast}")
# For past dates, only insert if not exists (don't overwrite)
# For future dates, always update
if entry_is_forecast:
stmt = insert(NewbookDailyOccupancy).values(
kitchen_id=self.kitchen_id,
date=entry_date,
total_rooms=entry.get("total_rooms"),
occupied_rooms=entry.get("occupied_rooms"),
occupancy_percentage=entry.get("occupancy_percentage"),
total_guests=guest_count,
breakfast_allocation_qty=allocs.get("breakfast_qty"),
breakfast_allocation_netvalue=allocs.get("breakfast_netvalue"),
dinner_allocation_qty=allocs.get("dinner_qty"),
dinner_allocation_netvalue=allocs.get("dinner_netvalue"),
arrival_count=arrivals.get("count"),
arrival_booking_ids=arrivals.get("ids"),
arrival_booking_details=arrivals.get("details"),
rooms_breakdown=rooms_breakdown,
is_forecast=True,
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_occupancy_per_day",
set_={
"total_rooms": entry.get("total_rooms"),
"occupied_rooms": entry.get("occupied_rooms"),
"occupancy_percentage": entry.get("occupancy_percentage"),
"total_guests": guest_count,
"breakfast_allocation_qty": allocs.get("breakfast_qty"),
"breakfast_allocation_netvalue": allocs.get("breakfast_netvalue"),
"dinner_allocation_qty": allocs.get("dinner_qty"),
"dinner_allocation_netvalue": allocs.get("dinner_netvalue"),
"arrival_count": arrivals.get("count"),
"arrival_booking_ids": arrivals.get("ids"),
"arrival_booking_details": arrivals.get("details"),
"rooms_breakdown": rooms_breakdown,
"is_forecast": True,
"fetched_at": datetime.utcnow()
}
)
else:
# Past dates - insert if new, or update is_forecast flag if already exists
# This ensures dates that were previously forecast get marked as historical
stmt = insert(NewbookDailyOccupancy).values(
kitchen_id=self.kitchen_id,
date=entry_date,
total_rooms=entry.get("total_rooms"),
occupied_rooms=entry.get("occupied_rooms"),
occupancy_percentage=entry.get("occupancy_percentage"),
total_guests=guest_count,
breakfast_allocation_qty=allocs.get("breakfast_qty"),
breakfast_allocation_netvalue=allocs.get("breakfast_netvalue"),
dinner_allocation_qty=allocs.get("dinner_qty"),
dinner_allocation_netvalue=allocs.get("dinner_netvalue"),
arrival_count=arrivals.get("count"),
arrival_booking_ids=arrivals.get("ids"),
arrival_booking_details=arrivals.get("details"),
rooms_breakdown=rooms_breakdown,
is_forecast=False,
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_occupancy_per_day",
set_={
"rooms_breakdown": rooms_breakdown,
"is_forecast": False,
"fetched_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
records_count += 1
await self.db.commit()
await self._complete_sync(log, "success", records_count)
logger.info(f"Synced {records_count} occupancy records for kitchen {self.kitchen_id}")
return records_count
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
logger.error(f"Occupancy sync failed: {e}")
raise
async def run_daily_sync(self) -> dict:
"""
Run the daily automatic sync job.
- Backfills any missing historical data (last 30 days)
- Updates forecast period (next 60 days)
"""
today = date.today()
results = {
"revenue_historical": 0,
"occupancy_historical": 0,
"occupancy_forecast": 0,
}
try:
# Historical data (last 30 days)
hist_from = today - timedelta(days=30)
results["revenue_historical"] = await self.sync_revenue(hist_from, today)
results["occupancy_historical"] = await self.sync_occupancy(hist_from, today, is_forecast=False)
# Forecast data (next 60 days)
forecast_to = today + timedelta(days=self.FORECAST_DAYS)
results["occupancy_forecast"] = await self.sync_occupancy(today, forecast_to, is_forecast=True)
# Update last sync timestamp
settings = await self._get_settings()
settings.newbook_last_sync = datetime.utcnow()
await self.db.commit()
logger.info(f"Daily sync completed for kitchen {self.kitchen_id}: {results}")
except Exception as e:
logger.error(f"Daily sync failed for kitchen {self.kitchen_id}: {e}")
raise
return results
async def run_upcoming_sync(self) -> dict:
"""
Run upcoming sync for next 7 days only.
This is designed to run more frequently (e.g., every 15 minutes) to keep
the most important upcoming room data fresh for ResidentsTableChart.
"""
today = date.today()
next_week = today + timedelta(days=7)
# Sync next 7 days of occupancy
result = await self.sync_occupancy(today, next_week, is_forecast=True)
# Update last upcoming sync timestamp
settings = await self._get_settings()
settings.newbook_last_upcoming_sync = datetime.utcnow()
await self.db.commit()
return {
'upcoming': result
}
async def sync_forecast_period(self) -> dict:
"""
Manual sync for forecast period only (next ~2 months).
Called from settings UI button.
"""
today = date.today()
forecast_to = today + timedelta(days=self.FORECAST_DAYS)
results = {
"occupancy": await self.sync_occupancy(today, forecast_to, is_forecast=True),
}
return results
async def sync_historical_range(self, date_from: date, date_to: date) -> dict:
"""
Manual sync for specific historical date range.
Called from settings UI date picker.
Note: For historical data, this will force update existing records.
"""
log = await self._log_sync("historical_manual", date_from, date_to)
try:
settings = await self._get_settings()
# Parse breakfast/dinner GL codes for meal allocation counts
breakfast_gl_codes = []
dinner_gl_codes = []
if settings.newbook_breakfast_gl_codes:
breakfast_gl_codes = [c.strip() for c in settings.newbook_breakfast_gl_codes.split(",") if c.strip()]
if settings.newbook_dinner_gl_codes:
dinner_gl_codes = [c.strip() for c in settings.newbook_dinner_gl_codes.split(",") if c.strip()]
# Get VAT rates (needed for allocation processing)
breakfast_vat_rate = settings.newbook_breakfast_vat_rate
dinner_vat_rate = settings.newbook_dinner_vat_rate
# Build GL account ID to code mapping for allocation processing
gl_account_id_to_code = {}
if breakfast_gl_codes or dinner_gl_codes:
result = await self.db.execute(
select(NewbookGLAccount.gl_account_id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id
)
)
gl_account_id_to_code = {row[0]: row[1] for row in result.all()}
# Sync revenue from earned revenue report (actual revenue)
revenue_count = await self.sync_revenue(date_from, date_to)
# Get included room types for filtering guest counts
included_room_types = await self._get_included_room_types()
# For historical sync: get occupancy, guest counts, and meal allocation COUNTS
# (values come from earned_revenue report, but counts are useful for analysis)
async with await self._get_client() as client:
# Fetch category_id to type mapping for guest filtering
_, category_id_to_type = await client.get_site_list()
occupancy_data = await client.get_occupancy_report(date_from, date_to)
# Fetch bookings for guest counts and meal allocation counts
bookings = await client.get_bookings(date_from, date_to)
# Process bookings for guest counts (filtered by room type)
guests_by_date = client.process_bookings_for_guests(bookings, included_room_types, category_id_to_type)
# Process bookings for meal allocation counts (PAX only, values from earned_revenue)
allocations_by_date = {}
if breakfast_gl_codes or dinner_gl_codes:
allocations_by_date = client.process_bookings_for_allocations(
bookings, breakfast_gl_codes, dinner_gl_codes, gl_account_id_to_code,
breakfast_vat_rate, dinner_vat_rate
)
# Process bookings for arrival tracking (also filtered by room type)
arrivals_by_date = client.process_bookings_for_arrivals(bookings, included_room_types, category_id_to_type)
occupancy_count = 0
for entry in occupancy_data:
entry_date = date.fromisoformat(entry["date"]) if isinstance(entry["date"], str) else entry["date"]
allocs = allocations_by_date.get(entry["date"], allocations_by_date.get(str(entry_date), {}))
# Get guest count from bookings (filtered by room type)
guest_count = guests_by_date.get(str(entry_date), guests_by_date.get(entry["date"]))
# Get arrivals for this date
arrivals = arrivals_by_date.get(str(entry_date), arrivals_by_date.get(entry["date"], {}))
# Log first few entries for debugging
if occupancy_count < 3:
logger.info(f"Historical occupancy entry: date={entry_date}, guest_count={guest_count}, occupied_rooms={entry.get('occupied_rooms')}, breakfast_qty={allocs.get('breakfast_qty')}")
# Force update for manual historical sync
# Store meal allocation QTY (pax counts) but NOT values (values come from earned_revenue)
stmt = insert(NewbookDailyOccupancy).values(
kitchen_id=self.kitchen_id,
date=entry_date,
total_rooms=entry.get("total_rooms"),
occupied_rooms=entry.get("occupied_rooms"),
occupancy_percentage=entry.get("occupancy_percentage"),
total_guests=guest_count,
breakfast_allocation_qty=allocs.get("breakfast_qty"),
breakfast_allocation_netvalue=None, # Historical: use earned_revenue for actual values
dinner_allocation_qty=allocs.get("dinner_qty"),
dinner_allocation_netvalue=None, # Historical: use earned_revenue for actual values
arrival_count=arrivals.get("count"),
arrival_booking_ids=arrivals.get("ids"),
arrival_booking_details=arrivals.get("details"),
is_forecast=False,
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_occupancy_per_day",
set_={
"total_rooms": entry.get("total_rooms"),
"occupied_rooms": entry.get("occupied_rooms"),
"occupancy_percentage": entry.get("occupancy_percentage"),
"total_guests": guest_count,
"breakfast_allocation_qty": allocs.get("breakfast_qty"),
"breakfast_allocation_netvalue": None,
"dinner_allocation_qty": allocs.get("dinner_qty"),
"dinner_allocation_netvalue": None,
"arrival_count": arrivals.get("count"),
"arrival_booking_ids": arrivals.get("ids"),
"arrival_booking_details": arrivals.get("details"),
"is_forecast": False,
"fetched_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
occupancy_count += 1
await self.db.commit()
results = {
"revenue": revenue_count,
"occupancy": occupancy_count
}
await self._complete_sync(log, "success", revenue_count + occupancy_count)
return results
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
raise

View file

@ -0,0 +1,353 @@
"""
Nextcloud WebDAV service for file operations.
Handles:
- WebDAV authentication and connection testing
- File upload/download/delete operations
- Directory creation and management
- File path generation based on invoice metadata
"""
import httpx
import logging
import re
import hashlib
from datetime import datetime, date
from typing import Tuple, Optional
logger = logging.getLogger(__name__)
class NextcloudService:
"""Service for Nextcloud WebDAV operations"""
def __init__(self, host: str, username: str, password: str, base_path: str = "/Kitchen Invoices"):
"""
Initialize Nextcloud service.
Args:
host: Nextcloud server URL (e.g., "https://cloud.example.com")
username: Nextcloud username
password: Nextcloud password or app password
base_path: Base directory in Nextcloud for storing files
"""
self.host = host.rstrip('/') if host else ""
self.username = username or ""
self.password = password or ""
self.base_path = (base_path if base_path is not None else "/Kitchen Invoices").strip('/')
# WebDAV endpoint
self.webdav_url = f"{self.host}/remote.php/dav/files/{self.username}" if self.host and self.username else ""
# Create async HTTP client - will be initialized when needed
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self, upload_timeout: Optional[float] = None) -> httpx.AsyncClient:
"""Get or create the async HTTP client.
Args:
upload_timeout: If provided, creates a new client with this write timeout
(for large file uploads like backups). Otherwise uses default.
"""
if upload_timeout is not None:
# Return a separate client with extended timeout for large uploads
return httpx.AsyncClient(
auth=(self.username, self.password),
timeout=httpx.Timeout(60.0, write=upload_timeout, read=upload_timeout),
follow_redirects=True
)
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
auth=(self.username, self.password),
timeout=60.0,
follow_redirects=True
)
return self._client
async def test_connection(self) -> Tuple[bool, str]:
"""
Test WebDAV connection and authentication.
Returns:
(success, message) tuple
"""
if not self.host or not self.username or not self.password:
return (False, "Nextcloud not configured - missing host, username, or password")
try:
client = await self._get_client()
# PROPFIND on root to test auth
response = await client.request(
"PROPFIND",
self.webdav_url,
headers={"Depth": "0"}
)
if response.status_code == 207: # Multi-Status (success)
return (True, "Nextcloud connection successful")
elif response.status_code == 401:
return (False, "Authentication failed - check username/password")
elif response.status_code == 404:
return (False, f"WebDAV endpoint not found - check Nextcloud URL")
else:
return (False, f"Unexpected response: HTTP {response.status_code}")
except httpx.ConnectError:
return (False, f"Cannot connect to {self.host}")
except httpx.TimeoutException:
return (False, f"Connection timed out")
except Exception as e:
return (False, f"Connection error: {str(e)}")
async def ensure_directory(self, path: str) -> bool:
"""
Create directory and all parent directories if they don't exist.
Args:
path: Directory path relative to base_path
Returns:
True if directory exists or was created
"""
full_path = f"{self.base_path}/{path}".strip('/')
parts = full_path.split('/')
client = await self._get_client()
current_path = ""
for part in parts:
if not part:
continue
current_path = f"{current_path}/{part}" if current_path else part
url = f"{self.webdav_url}/{current_path}"
# Check if exists
try:
response = await client.request("PROPFIND", url, headers={"Depth": "0"})
if response.status_code == 404:
# Create directory
create_response = await client.request("MKCOL", url)
if create_response.status_code not in (201, 405): # 405 = already exists
logger.error(f"Failed to create directory {current_path}: HTTP {create_response.status_code}")
return False
logger.debug(f"Created directory: {current_path}")
except Exception as e:
logger.error(f"Error checking/creating directory {current_path}: {e}")
return False
return True
def generate_filename(
self,
invoice_date: Optional[date],
supplier_name: Optional[str],
invoice_number: Optional[str],
total: Optional[float],
original_filename: str,
upload_hash: Optional[str] = None
) -> str:
"""
Generate human-readable filename for archived invoice.
Format: {date}-{supplier}-{invoice_number}-{total}-{hash}.{ext}
Example: 2026-01-15-Brakes-INV001234-£234_50-a1b2c3d4.pdf
"""
# Sanitize supplier name (remove special chars, limit length)
safe_supplier = re.sub(r'[^\w\s-]', '', supplier_name or 'Unknown')
safe_supplier = re.sub(r'\s+', '-', safe_supplier.strip())[:30]
# Sanitize invoice number
safe_invoice = re.sub(r'[^\w-]', '', invoice_number or 'NoNumber')[:20]
# Format total with currency (replace . with _ for filename safety)
if total is not None:
total_str = f"£{total:.2f}".replace('.', '_')
else:
total_str = "£0_00"
# Get extension from original filename
ext = original_filename.split('.')[-1].lower() if original_filename and '.' in original_filename else 'pdf'
# Format date
if invoice_date:
date_str = invoice_date.strftime('%Y-%m-%d') if isinstance(invoice_date, date) else str(invoice_date)[:10]
else:
date_str = 'NoDate'
# Short hash (generate if not provided)
if upload_hash:
short_hash = upload_hash[:8]
else:
hash_input = f"{invoice_number or ''}{total or ''}{datetime.utcnow().isoformat()}"
short_hash = hashlib.md5(hash_input.encode()).hexdigest()[:8]
return f"{date_str}-{safe_supplier}-{safe_invoice}-{total_str}-{short_hash}.{ext}"
def generate_path(self, supplier_name: Optional[str], invoice_date: Optional[date]) -> str:
"""
Generate directory path for invoice archive.
Format: /{supplier}/{year}/{month}/
Example: /Brakes/2026/01/
"""
safe_supplier = re.sub(r'[^\w\s-]', '', supplier_name or 'Unknown')
safe_supplier = re.sub(r'\s+', '-', safe_supplier.strip())
if invoice_date:
if isinstance(invoice_date, date):
return f"{safe_supplier}/{invoice_date.year}/{invoice_date.month:02d}"
else:
# Try to parse string date
try:
dt = datetime.strptime(str(invoice_date)[:10], '%Y-%m-%d')
return f"{safe_supplier}/{dt.year}/{dt.month:02d}"
except ValueError:
pass
return f"{safe_supplier}/unknown"
async def upload_file(
self,
file_content: bytes,
directory_path: str,
filename: str,
timeout: Optional[float] = None
) -> Tuple[bool, str]:
"""
Upload file to Nextcloud.
Args:
file_content: File bytes
directory_path: Directory path (relative to base_path)
filename: Target filename
timeout: Optional extended timeout in seconds for large files
Returns:
(success, full_webdav_path or error_message)
"""
if not self.host or not self.username:
return (False, "Nextcloud not configured")
upload_client = None
try:
# Ensure directory exists
if not await self.ensure_directory(directory_path):
return (False, f"Failed to create directory: {directory_path}")
# Full path
full_path = f"{self.base_path}/{directory_path}/{filename}".strip('/')
url = f"{self.webdav_url}/{full_path}"
size_mb = len(file_content) / (1024 * 1024)
logger.info(f"Uploading {size_mb:.1f} MB to Nextcloud: {full_path}")
# Use extended timeout for large files
if timeout:
upload_client = await self._get_client(upload_timeout=timeout)
client = upload_client
else:
client = await self._get_client()
response = await client.put(url, content=file_content)
if response.status_code in (201, 204): # Created or No Content (overwritten)
logger.info(f"Uploaded file to Nextcloud: {full_path} ({size_mb:.1f} MB)")
return (True, full_path)
else:
return (False, f"Upload failed: HTTP {response.status_code}")
except httpx.TimeoutException as e:
size_mb = len(file_content) / (1024 * 1024)
logger.error(f"Nextcloud upload timed out for {filename} ({size_mb:.1f} MB): {type(e).__name__}")
return (False, f"Upload timed out ({size_mb:.1f} MB file) - try increasing timeout or check connection speed")
except Exception as e:
logger.error(f"Nextcloud upload error for {filename}: {type(e).__name__}: {e}")
return (False, f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
finally:
if upload_client and not upload_client.is_closed:
await upload_client.aclose()
async def download_file(self, path: str) -> Tuple[bool, bytes | str]:
"""
Download file from Nextcloud.
Args:
path: Full WebDAV path (relative to user's files root)
Returns:
(success, file_bytes or error_message)
"""
if not self.host or not self.username:
return (False, "Nextcloud not configured")
try:
url = f"{self.webdav_url}/{path}"
client = await self._get_client()
response = await client.get(url)
if response.status_code == 200:
return (True, response.content)
elif response.status_code == 404:
return (False, "File not found")
else:
return (False, f"Download failed: HTTP {response.status_code}")
except Exception as e:
return (False, str(e))
async def copy_to_deleted(
self,
source_path: str,
supplier_name: Optional[str],
original_filename: str
) -> Tuple[bool, str]:
"""
Copy file to deleted folder before deletion from DB.
Target: /{base_path}/{supplier}/deleted/[DELETED FROM FLASH] {filename}
"""
safe_supplier = re.sub(r'[^\w\s-]', '', supplier_name or 'Unknown')
safe_supplier = re.sub(r'\s+', '-', safe_supplier.strip())
deleted_dir = f"{safe_supplier}/deleted"
deleted_filename = f"[DELETED FROM FLASH] {original_filename}"
# Download original
success, content = await self.download_file(source_path)
if not success:
return (False, f"Failed to download original: {content}")
# Upload to deleted folder
return await self.upload_file(content, deleted_dir, deleted_filename)
async def delete_file(self, path: str) -> Tuple[bool, str]:
"""
Delete file from Nextcloud.
Args:
path: Full WebDAV path
Returns:
(success, message)
"""
if not self.host or not self.username:
return (False, "Nextcloud not configured")
try:
url = f"{self.webdav_url}/{path}"
client = await self._get_client()
response = await client.delete(url)
if response.status_code in (204, 404): # Deleted or already gone
return (True, "File deleted")
else:
return (False, f"Delete failed: HTTP {response.status_code}")
except Exception as e:
return (False, str(e))
async def close(self):
"""Close HTTP client"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None

View file

@ -0,0 +1,937 @@
"""
PDF Highlighter Service
Adds yellow highlight annotations to invoice PDFs for non-stock line items.
Uses Azure OCR bounding region data to position highlights accurately.
Also adds "*NOT KITCHEN STOCK*" labels and optional notes overlay.
"""
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple
import fitz # PyMuPDF
logger = logging.getLogger(__name__)
# Azure Document Intelligence returns coordinates in inches
# PDF coordinates are in points (72 points per inch)
POINTS_PER_INCH = 72
# Standard page width for scaling (US Letter = 612pt, A4 = 595pt)
STANDARD_PAGE_WIDTH = 612
# Header label text and styling (single header for all highlights)
HEADER_LABEL = "** NON KITCHEN STOCK ITEMS HIGHLIGHTED **"
HEADER_FONT_SIZE_BASE = 12 # Base size for standard page width
HEADER_FONT_SIZE_MIN = 12 # Minimum font size (absolute)
HEADER_COLOR = (0.8, 0.0, 0.0) # Dark red
HEADER_BG_COLOR = (1.0, 1.0, 0.8) # Light yellow background
HEADER_FONT = "hebo" # Helvetica Bold
# Notes box styling
NOTES_BOX_COLOR = (1.0, 1.0, 0.8) # Light yellow background
NOTES_BORDER_COLOR = (0.9, 0.7, 0.0) # Orange border
NOTES_TEXT_COLOR = (0.2, 0.2, 0.2) # Dark gray text
NOTES_FONT_SIZE_BASE = 16 # Base size for standard page width (reduced from 18)
NOTES_FONT_SIZE_MIN_BASE = 10 # Minimum base size for scaling
NOTES_FONT_SIZE_MIN_ABSOLUTE = 14 # Absolute minimum font size
NOTES_TITLE = "INVOICE NOTES:"
NOTES_BOX_WIDTH_RATIO = 0.38 # Box width as ratio of page width (reduced from 0.45)
NOTES_BOX_HEIGHT_RATIO = 0.15 # Box height as ratio of page height (reduced from 0.18)
# Minimum sizes for photo-based PDFs (ensures visibility)
NOTES_BOX_MIN_WIDTH = 260 # Minimum width in points
NOTES_BOX_MIN_HEIGHT = 110 # Minimum height in points
NOTES_MAX_LINES = 6 # Allow more lines in larger box
def parse_azure_ocr_line_items(ocr_raw_json: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Parse Azure Document Intelligence OCR JSON to extract line items with bounding regions.
Azure OCR format stores line items at:
documents[0].fields.Items.value[] where each item has:
- bounding_regions: [{page_number, polygon: [[x,y], ...]}]
- value.Description.value: description text
- value.ProductCode.value: product code (optional)
Args:
ocr_raw_json: Raw OCR JSON from Azure Document Intelligence
Returns:
List of normalized line items with description, product_code, and bounding_regions
"""
result = []
try:
documents = ocr_raw_json.get('documents', [])
if not documents:
logger.debug("No documents in OCR JSON")
return result
fields = documents[0].get('fields', {})
items_field = fields.get('Items', {})
items_list = items_field.get('value', [])
for item in items_list:
# Get bounding regions for the whole line item row
bounding_regions = item.get('bounding_regions', [])
# Get field values
item_value = item.get('value', {})
# Extract description
description_field = item_value.get('Description', {})
description = description_field.get('value', '')
# Extract product code (may not exist)
product_code_field = item_value.get('ProductCode', {})
product_code = product_code_field.get('value', '')
if bounding_regions:
result.append({
'description': description,
'product_code': product_code,
'bounding_regions': bounding_regions
})
logger.debug(f"Parsed OCR item: '{description[:30]}...' with {len(bounding_regions)} regions")
except Exception as e:
logger.warning(f"Failed to parse Azure OCR line items: {e}")
logger.info(f"Parsed {len(result)} line items from Azure OCR data")
return result
def parse_azure_ocr_key_fields(ocr_raw_json: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Parse Azure Document Intelligence OCR JSON to extract key invoice fields with bounding regions.
These are the important fields that should NOT be covered by the notes overlay:
- VendorName (supplier)
- InvoiceDate
- SubTotal (net total)
- TotalTax
- InvoiceTotal (gross total)
- AmountDue
Args:
ocr_raw_json: Raw OCR JSON from Azure Document Intelligence
Returns:
List of field info with name and bounding_regions
"""
result = []
# Key fields that should not be covered
key_field_names = [
'VendorName', 'VendorAddress', 'CustomerName', 'CustomerAddress',
'InvoiceDate', 'DueDate', 'PurchaseOrder',
'SubTotal', 'TotalTax', 'InvoiceTotal', 'AmountDue',
'InvoiceId', 'BillingAddress', 'ShippingAddress'
]
try:
documents = ocr_raw_json.get('documents', [])
if not documents:
return result
fields = documents[0].get('fields', {})
for field_name in key_field_names:
field = fields.get(field_name, {})
bounding_regions = field.get('bounding_regions', [])
if bounding_regions:
result.append({
'field_name': field_name,
'bounding_regions': bounding_regions
})
logger.debug(f"Found key OCR field: {field_name} with {len(bounding_regions)} regions")
except Exception as e:
logger.warning(f"Failed to parse Azure OCR key fields: {e}")
logger.debug(f"Parsed {len(result)} key fields from Azure OCR data")
return result
class PDFHighlighter:
"""Service for adding highlight annotations to PDFs using OCR coordinate data."""
def __init__(self, pdf_path: str):
"""
Initialize the highlighter with a PDF file.
Args:
pdf_path: Path to the PDF file to annotate
"""
self.pdf_path = Path(pdf_path)
if not self.pdf_path.exists():
raise FileNotFoundError(f"PDF file not found: {pdf_path}")
self.doc = fitz.open(str(self.pdf_path))
logger.debug(f"Opened PDF: {pdf_path} ({len(self.doc)} pages)")
def clear_all_annotations(self) -> int:
"""
Remove all highlight, FreeText, and Square annotations from the PDF.
This clears both the yellow highlights and the text labels/notes we add.
Returns:
Number of annotations removed
"""
removed_count = 0
for page in self.doc:
# Get all annotations on this page
annots_to_delete = []
for annot in page.annots() or []:
annot_type = annot.type[0]
# 8 = Highlight, 2 = FreeText (for labels and notes), 4 = Square (for notes box)
if annot_type in (8, 2, 4):
# For FreeText and Square annotations, only delete ones we created (check title)
if annot_type in (2, 4):
info = annot.info
if info.get('title', '') == 'Kitchen Invoice Flash':
annots_to_delete.append(annot)
else:
annots_to_delete.append(annot)
# Delete the annotations
for annot in annots_to_delete:
page.delete_annot(annot)
removed_count += 1
if removed_count > 0:
logger.info(f"Cleared {removed_count} existing annotations")
return removed_count
def clear_all_highlights(self) -> int:
"""Alias for backward compatibility."""
return self.clear_all_annotations()
def highlight_items_with_ocr_data(
self,
ocr_line_items: List[Dict[str, Any]],
non_stock_line_items: List[Any],
output_path: str,
notes: Optional[str] = None,
ocr_data: Optional[Dict[str, Any]] = None
) -> str:
"""
Add yellow highlights to non-stock items using OCR bounding region data.
This method first clears any existing highlight annotations, then adds
new highlights for the current non-stock items. This allows highlights
to be updated when non-stock status changes.
Args:
ocr_line_items: Line items from invoice.ocr_raw_json['line_items']
Contains 'description', 'product_code', 'bounding_regions'
non_stock_line_items: Database LineItem objects with is_non_stock=True
output_path: Path to save the annotated PDF
notes: Optional invoice notes to overlay on page 1
ocr_data: Full OCR JSON data (needed for notes overlay positioning)
Returns:
Path to the annotated PDF (or original path if highlighting failed)
"""
# Always clear existing annotations first (allows re-highlighting)
cleared_count = self.clear_all_highlights()
highlights_added = 0
# Add highlights for non-stock items if we have the data
if non_stock_line_items and ocr_line_items:
# Match database line items to OCR line items
matched_items = self._match_line_items(ocr_line_items, non_stock_line_items)
if matched_items:
logger.info(f"Matched {len(matched_items)} of {len(non_stock_line_items)} non-stock items to OCR data")
# Add highlights for each matched item
for ocr_item in matched_items:
bounding_regions = ocr_item.get('bounding_regions', [])
for region in bounding_regions:
page_number = region.get('page_number', 1) - 1 # PyMuPDF uses 0-based indexing
polygon = region.get('polygon', [])
if page_number < 0 or page_number >= len(self.doc):
logger.warning(f"Invalid page number {page_number + 1} for item")
continue
if not polygon or len(polygon) < 4:
logger.warning(f"Invalid polygon data for item: {ocr_item.get('description', 'Unknown')}")
continue
try:
bbox = self._convert_polygon_to_bbox(polygon, page_number)
if bbox:
self._add_highlight_annotation(page_number, bbox)
highlights_added += 1
except Exception as e:
logger.warning(f"Failed to add highlight for item: {e}")
continue
else:
logger.warning("No line items could be matched to OCR data")
# Add header label if any highlights were added
if highlights_added > 0:
self._add_header_label(page_number=0)
# Add notes overlay on page 1 if provided (independent of highlights)
notes_added = False
if notes and ocr_data:
try:
notes_added = self.add_notes_overlay(notes, ocr_data, page_number=0)
except Exception as e:
logger.warning(f"Failed to add notes overlay: {e}")
# Save the annotated PDF
try:
# When saving to the same file we opened, must use incremental save
if str(output_path) == str(self.pdf_path):
self.doc.save(output_path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)
else:
# Saving to a different file - can use full save with garbage collection
self.doc.save(output_path, garbage=4, deflate=True)
logger.info(f"Saved PDF: {highlights_added} highlights, notes={'yes' if notes_added else 'no'}, cleared={cleared_count}")
return output_path
except Exception as e:
logger.error(f"Failed to save annotated PDF: {e}")
return str(self.pdf_path)
finally:
try:
if self.doc and not self.doc.is_closed:
self.doc.close()
except:
pass
def _match_line_items(
self,
ocr_items: List[Dict[str, Any]],
db_items: List[Any]
) -> List[Dict[str, Any]]:
"""
Match database line items to OCR line items by description or product code.
Args:
ocr_items: Line items from OCR JSON with bounding_regions
db_items: Database LineItem objects
Returns:
List of matched OCR items (with bounding regions)
"""
matched = []
for db_item in db_items:
db_description = (db_item.description or '').lower().strip()
db_product_code = (db_item.product_code or '').lower().strip()
best_match = None
best_score = 0
for ocr_item in ocr_items:
ocr_description = (ocr_item.get('description') or '').lower().strip()
ocr_product_code = (ocr_item.get('product_code') or '').lower().strip()
# Skip if no bounding regions
if not ocr_item.get('bounding_regions'):
continue
# Try exact description match (highest priority)
if db_description and ocr_description == db_description:
best_match = ocr_item
best_score = 100
break
# Try product code match
if db_product_code and ocr_product_code == db_product_code:
if best_score < 90:
best_match = ocr_item
best_score = 90
# Try partial description match (description contains or is contained)
if db_description and ocr_description:
if db_description in ocr_description or ocr_description in db_description:
if best_score < 80:
best_match = ocr_item
best_score = 80
# Try fuzzy match using simple word overlap
if db_description and ocr_description and best_score < 70:
similarity = self._calculate_similarity(db_description, ocr_description)
if similarity >= 0.85 and similarity * 100 > best_score:
best_match = ocr_item
best_score = similarity * 100
if best_match:
matched.append(best_match)
logger.debug(f"Matched '{db_description}' to OCR item with score {best_score}")
else:
logger.warning(f"Could not match item: '{db_description}' (code: {db_product_code})")
return matched
def _calculate_similarity(self, s1: str, s2: str) -> float:
"""
Calculate simple word-overlap similarity between two strings.
Returns:
Similarity score from 0.0 to 1.0
"""
if not s1 or not s2:
return 0.0
words1 = set(s1.lower().split())
words2 = set(s2.lower().split())
if not words1 or not words2:
return 0.0
intersection = len(words1 & words2)
union = len(words1 | words2)
return intersection / union if union > 0 else 0.0
def _convert_polygon_to_bbox(
self,
polygon: List[List[float]],
page_number: int
) -> Optional[fitz.Rect]:
"""
Convert Azure OCR polygon coordinates to PyMuPDF Rect.
Azure returns coordinates in inches from top-left.
PyMuPDF uses points (72 points per inch) from top-left.
Args:
polygon: List of [x, y] coordinate pairs from Azure OCR
page_number: 0-based page index
Returns:
fitz.Rect object for the bounding box, or None if invalid
"""
if not polygon or len(polygon) < 4:
return None
try:
# Extract x and y coordinates
x_coords = [p[0] for p in polygon]
y_coords = [p[1] for p in polygon]
# Get bounding box in inches
x0_inches = min(x_coords)
y0_inches = min(y_coords)
x1_inches = max(x_coords)
y1_inches = max(y_coords)
# Convert inches to points (72 points per inch)
x0 = x0_inches * POINTS_PER_INCH
y0 = y0_inches * POINTS_PER_INCH
x1 = x1_inches * POINTS_PER_INCH
y1 = y1_inches * POINTS_PER_INCH
# Create rectangle with some padding for better visibility
padding = 2 # points
rect = fitz.Rect(x0 - padding, y0 - padding, x1 + padding, y1 + padding)
# Validate that rect is within page bounds
page = self.doc[page_number]
page_rect = page.rect
# Clip to page bounds
rect = rect & page_rect
if rect.is_empty or rect.is_infinite:
logger.warning(f"Invalid rect after clipping: {rect}")
return None
return rect
except Exception as e:
logger.warning(f"Failed to convert polygon to bbox: {e}")
return None
def _add_highlight_annotation(
self,
page_number: int,
bbox: fitz.Rect,
color: Tuple[float, float, float] = (1.0, 1.0, 0.0) # Yellow
):
"""
Add a highlight annotation to a page at the specified location.
Args:
page_number: 0-based page index
bbox: Rectangle defining the highlight area
color: RGB color tuple (0.0-1.0 range), defaults to yellow
"""
page = self.doc[page_number]
# Create highlight annotation
highlight = page.add_highlight_annot(bbox)
# Set highlight color (yellow)
highlight.set_colors(stroke=color)
# Set opacity (semi-transparent)
highlight.set_opacity(0.5)
# Update to apply changes (no popup/hover text)
highlight.update()
logger.debug(f"Added highlight on page {page_number + 1} at {bbox}")
def _add_header_label(self, page_number: int = 0):
"""
Add a single header label in the top-left corner of the page.
This replaces individual per-item labels with one prominent header.
Font size and dimensions scale based on page width for consistent appearance
on both standard PDFs and photo-based PDFs.
Args:
page_number: Page to add the header to (0-indexed, default first page)
"""
if page_number >= len(self.doc):
return
page = self.doc[page_number]
page_rect = page.rect
# Calculate scale factor based on page width
# Photo-based PDFs are often much larger (e.g., 2480pt vs 612pt for standard)
scale_factor = page_rect.width / STANDARD_PAGE_WIDTH
# Scale font size with minimum enforcement
font_size = max(HEADER_FONT_SIZE_MIN, int(HEADER_FONT_SIZE_BASE * scale_factor))
margin = max(15, int(15 * scale_factor))
logger.debug(f"Header: page width={page_rect.width:.0f}pt, scale={scale_factor:.2f}, font={font_size}pt")
border_width = max(2, int(2 * scale_factor))
# Calculate header dimensions - use generous width for bold text
label_width = len(HEADER_LABEL) * font_size * 0.65
label_height = font_size + int(10 * scale_factor)
# Position in top-left with scaled margin
label_x = margin
label_y = margin
# Create header rectangle with background (add padding)
padding = int(10 * scale_factor)
label_rect = fitz.Rect(label_x, label_y, label_x + label_width + padding, label_y + label_height)
# Add background box (Square annotation for visible background)
bg_annot = page.add_rect_annot(label_rect)
bg_annot.set_colors(stroke=HEADER_COLOR, fill=HEADER_BG_COLOR)
bg_annot.set_border(width=border_width)
bg_annot.set_info(title="Kitchen Invoice Flash")
bg_annot.update()
# Add text annotation on top - use full width
text_padding = int(5 * scale_factor)
text_rect = fitz.Rect(label_x + text_padding, label_y + 2, label_x + label_width + text_padding, label_y + label_height - 2)
text_annot = page.add_freetext_annot(
text_rect,
HEADER_LABEL,
fontsize=font_size,
fontname=HEADER_FONT,
text_color=HEADER_COLOR,
fill_color=None,
border_color=None,
align=fitz.TEXT_ALIGN_CENTER
)
text_annot.set_info(title="Kitchen Invoice Flash")
text_annot.update()
logger.debug(f"Added header label on page {page_number + 1} (scale: {scale_factor:.2f}, font: {font_size}pt)")
def add_notes_overlay(
self,
notes: str,
ocr_data: Dict[str, Any],
page_number: int = 0
) -> bool:
"""
Add a notes box overlay on a page in a clear spot.
Uses OCR data to find an area that doesn't overlap with existing content.
Args:
notes: The notes text to display
ocr_data: Full OCR JSON data containing page/content information
page_number: Page to add notes to (0-indexed, default first page)
Returns:
True if notes were added successfully, False otherwise
"""
if not notes or not notes.strip():
return False
if page_number >= len(self.doc):
logger.warning(f"Page {page_number} doesn't exist, skipping notes overlay")
return False
page = self.doc[page_number]
page_rect = page.rect
# Find a clear spot for the notes box
clear_rect = self._find_clear_spot(page, ocr_data, page_number)
if not clear_rect:
logger.warning("Could not find a clear spot for notes overlay")
return False
# Draw the notes box
self._draw_notes_box(page, clear_rect, notes)
logger.info(f"Added notes overlay on page {page_number + 1} at {clear_rect}")
return True
def _find_clear_spot(
self,
page: fitz.Page,
ocr_data: Dict[str, Any],
page_number: int
) -> Optional[fitz.Rect]:
"""
Find a clear rectangular area on the page that doesn't overlap with content.
Uses both PyMuPDF's native text extraction AND Azure OCR key field bounding
regions to ensure we don't cover important invoice data like supplier name,
date, net total, gross total, etc.
Box dimensions scale proportionally with page size for consistent appearance
on both standard PDFs and photo-based PDFs.
Args:
page: The PDF page
ocr_data: Full OCR JSON data with key field bounding regions
page_number: 0-indexed page number
Returns:
A fitz.Rect for the clear area, or None if no suitable spot found
"""
page_rect = page.rect
# Calculate box size as ratio of page dimensions with minimum enforcement
# This ensures consistent proportions but also visibility on photo-based PDFs
box_width = max(NOTES_BOX_MIN_WIDTH, page_rect.width * NOTES_BOX_WIDTH_RATIO)
box_height = max(NOTES_BOX_MIN_HEIGHT, page_rect.height * NOTES_BOX_HEIGHT_RATIO)
# Scale factor for margins based on page width
scale_factor = page_rect.width / STANDARD_PAGE_WIDTH
margin = max(15, int(15 * scale_factor))
# Use PyMuPDF's native text extraction to find occupied areas
# This is more accurate than OCR JSON data for native PDFs
occupied_rects = self._get_text_regions_from_pdf(page)
# Also add key invoice field regions from Azure OCR data
# This ensures we don't cover important fields like supplier, date, totals
if ocr_data:
key_fields = parse_azure_ocr_key_fields(ocr_data)
for field in key_fields:
for region in field.get('bounding_regions', []):
# Only add regions on the current page
if region.get('page_number', 1) - 1 == page_number:
polygon = region.get('polygon', [])
if polygon and len(polygon) >= 4:
rect = self._polygon_to_rect(polygon)
if rect and not rect.is_empty:
# Add padding around key fields to ensure they're not covered
padding = 10 * scale_factor
padded_rect = fitz.Rect(
rect.x0 - padding, rect.y0 - padding,
rect.x1 + padding, rect.y1 + padding
)
occupied_rects.append(padded_rect)
logger.debug(f"Added OCR key field '{field['field_name']}' to occupied regions")
# For very wide pages (scans, landscape), constrain to visible area
# Scale the max visible width proportionally
max_visible_width = min(page_rect.width, 850 * scale_factor)
logger.debug(f"Page dimensions: {page_rect.width}x{page_rect.height}, box size: {box_width:.0f}x{box_height:.0f}, scale: {scale_factor:.2f}")
# Candidate positions to try (in order of preference)
# Bottom of page is usually safest for invoices
candidates = [
# Bottom-right corner (safest - most invoices have space here)
(max_visible_width - box_width - margin, page_rect.height - box_height - margin),
# Bottom-left corner
(margin, page_rect.height - box_height - margin),
# Top-right corner within visible area
(max_visible_width - box_width - margin, margin),
# Top-left corner
(margin, margin),
# Middle-right edge
(max_visible_width - box_width - margin, page_rect.height / 2 - box_height / 2),
]
# Scale overlap threshold based on page size
overlap_threshold = 100 * scale_factor * scale_factor # Area scales with square of linear scale
for x0, y0 in candidates:
candidate_rect = fitz.Rect(x0, y0, x0 + box_width, y0 + box_height)
# Check if this candidate overlaps with any text region
overlap_found = False
for occupied in occupied_rects:
intersection = candidate_rect & occupied
if not intersection.is_empty:
# Any overlap with text is bad
overlap_area = intersection.width * intersection.height
if overlap_area > overlap_threshold:
overlap_found = True
logger.debug(f"Candidate at ({x0:.0f}, {y0:.0f}) overlaps with text at {occupied}")
break
if not overlap_found:
logger.info(f"Found clear spot for notes at ({x0:.0f}, {y0:.0f}), size {box_width:.0f}x{box_height:.0f}")
return candidate_rect
# If no clear spot found, use bottom-right as last resort
logger.warning("No clear spot found, using bottom-right corner")
return fitz.Rect(max_visible_width - box_width - margin, page_rect.height - box_height - margin,
max_visible_width - margin, page_rect.height - margin)
def _get_text_regions_from_pdf(self, page: fitz.Page) -> List[fitz.Rect]:
"""
Extract text block regions directly from the PDF using PyMuPDF.
This is more accurate than OCR JSON because it reads the actual
text layer embedded in the PDF (if any).
Args:
page: The PDF page
Returns:
List of fitz.Rect objects representing text areas
"""
occupied = []
try:
# Get text blocks - each block is (x0, y0, x1, y1, text, block_no, block_type)
# block_type: 0 = text, 1 = image
blocks = page.get_text("blocks")
for block in blocks:
x0, y0, x1, y1 = block[:4]
block_type = block[6] if len(block) > 6 else 0
# Include both text blocks (0) and image blocks (1)
rect = fitz.Rect(x0, y0, x1, y1)
if not rect.is_empty and rect.width > 5 and rect.height > 5:
occupied.append(rect)
# Also get image areas (in case the invoice is a scanned image)
for img in page.get_images():
try:
img_rect = page.get_image_rects(img[0])
if img_rect:
for rect in img_rect:
if not rect.is_empty:
occupied.append(rect)
except Exception:
pass
except Exception as e:
logger.warning(f"Error extracting text regions from PDF: {e}")
return occupied
def _polygon_to_rect(self, polygon: List[List[float]]) -> Optional[fitz.Rect]:
"""Convert a polygon (list of [x, y] pairs in inches) to a fitz.Rect in points."""
if not polygon or len(polygon) < 4:
return None
try:
x_coords = [p[0] for p in polygon]
y_coords = [p[1] for p in polygon]
return fitz.Rect(
min(x_coords) * POINTS_PER_INCH,
min(y_coords) * POINTS_PER_INCH,
max(x_coords) * POINTS_PER_INCH,
max(y_coords) * POINTS_PER_INCH
)
except Exception:
return None
def _draw_notes_box(self, page: fitz.Page, rect: fitz.Rect, notes: str):
"""
Draw a notes box with title and text content using Square annotation + text.
Square annotation with fill provides reliable background rendering.
Font size is adaptive - starts large and reduces if content doesn't fit.
All sizes scale based on page dimensions for consistent appearance.
Args:
page: The PDF page
rect: Rectangle defining the box area
notes: The notes text to display
"""
page_rect = page.rect
# Calculate scale factor based on page width
scale_factor = page_rect.width / STANDARD_PAGE_WIDTH
# Scale font sizes with absolute minimum enforcement
font_size_target = max(NOTES_FONT_SIZE_MIN_ABSOLUTE, int(NOTES_FONT_SIZE_BASE * scale_factor))
font_size_min = max(NOTES_FONT_SIZE_MIN_ABSOLUTE, int(NOTES_FONT_SIZE_MIN_BASE * scale_factor))
logger.debug(f"Notes box: page width={page_rect.width:.0f}pt, scale={scale_factor:.2f}, target font={font_size_target}pt")
# Scale padding and spacing
padding = int(10 * scale_factor)
border_width = max(2, int(2 * scale_factor))
# Find the best font size that allows content to fit
font_size = font_size_target
max_width = rect.width - padding
box_height = rect.height
while font_size >= font_size_min:
# Calculate chars per line at this font size
chars_per_line = int(max_width / (font_size * 0.55))
# Calculate line height (font size + scaled spacing)
line_height = font_size + int(4 * scale_factor)
title_height = font_size + int(12 * scale_factor) # Title takes more space
# Available height for notes text
available_text_height = box_height - title_height - padding
max_lines = int(available_text_height / line_height)
# Word wrap with current settings
words = notes.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip() if current_line else word
if len(test_line) <= chars_per_line:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
# Check if content fits
if len(lines) <= max_lines:
break # Good fit at this font size
# Try smaller font (decrement scales with page size)
font_size -= max(2, int(2 * scale_factor))
# Use minimum font size if we exhausted all options
if font_size < font_size_min:
font_size = font_size_min
chars_per_line = int(max_width / (font_size * 0.55))
line_height = font_size + int(4 * scale_factor)
title_height = font_size + int(12 * scale_factor)
available_text_height = box_height - title_height - padding
max_lines = int(available_text_height / line_height)
# Re-wrap at minimum font size
words = notes.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip() if current_line else word
if len(test_line) <= chars_per_line:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
# Limit lines to fit in box
lines = lines[:max_lines]
if len(notes.split()) > sum(len(line.split()) for line in lines):
if lines:
lines[-1] = lines[-1][:max(0, chars_per_line - 3)] + "..."
# Create Square annotation for the background box (more reliable than FreeText fill)
box_annot = page.add_rect_annot(rect)
box_annot.set_colors(stroke=NOTES_BORDER_COLOR, fill=NOTES_BOX_COLOR)
box_annot.set_border(width=border_width)
box_annot.set_info(title="Kitchen Invoice Flash")
box_annot.update()
# Scale text padding
text_padding = int(5 * scale_factor)
title_spacing = int(8 * scale_factor)
# Add title text as FreeText annotation
title_rect = fitz.Rect(rect.x0 + text_padding, rect.y0 + int(3 * scale_factor), rect.x1 - text_padding, rect.y0 + font_size + title_spacing)
title_annot = page.add_freetext_annot(
title_rect,
NOTES_TITLE,
fontsize=font_size + 1,
fontname="hebo", # Bold title
text_color=(0.6, 0.3, 0.0), # Dark orange for title
fill_color=None,
border_color=None,
align=fitz.TEXT_ALIGN_LEFT
)
title_annot.set_info(title="Kitchen Invoice Flash")
title_annot.update()
# Add notes text as FreeText annotation
text_y = rect.y0 + font_size + int(12 * scale_factor)
text_rect = fitz.Rect(rect.x0 + text_padding, text_y, rect.x1 - text_padding, rect.y1 - text_padding)
notes_text = "\n".join(lines)
notes_annot = page.add_freetext_annot(
text_rect,
notes_text,
fontsize=font_size - 1,
fontname="helv",
text_color=NOTES_TEXT_COLOR,
fill_color=None,
border_color=None,
align=fitz.TEXT_ALIGN_LEFT
)
notes_annot.set_info(title="Kitchen Invoice Flash")
notes_annot.update()
logger.debug(f"Drew notes box at {rect} with {len(lines)} lines at font size {font_size} (target was {font_size_target}, scale: {scale_factor:.2f})")
def highlight_non_stock_items(
pdf_path: str,
ocr_line_items: List[Dict[str, Any]],
non_stock_line_items: List[Any],
output_path: str
) -> str:
"""
Convenience function to highlight non-stock items in an invoice PDF.
Args:
pdf_path: Path to the original PDF
ocr_line_items: Line items from invoice.ocr_raw_json['line_items']
non_stock_line_items: Database LineItem objects with is_non_stock=True
output_path: Path to save the annotated PDF
Returns:
Path to the annotated PDF (or original path if highlighting failed)
"""
if not non_stock_line_items:
logger.info("No non-stock items to highlight, returning original")
return pdf_path
try:
highlighter = PDFHighlighter(pdf_path)
return highlighter.highlight_items_with_ocr_data(
ocr_line_items=ocr_line_items,
non_stock_line_items=non_stock_line_items,
output_path=output_path
)
except FileNotFoundError:
logger.error(f"PDF file not found: {pdf_path}")
return pdf_path
except Exception as e:
logger.error(f"PDF highlighting failed: {e}", exc_info=True)
return pdf_path

View file

@ -0,0 +1,334 @@
"""
PDF Rotation Service
Handles rotation of PDF pages based on Azure OCR angle data and transforms
OCR coordinates to match the corrected orientation.
This should be called as the FIRST post-processing step after Azure returns,
before any other processing extracts data from raw_json.
"""
import fitz # PyMuPDF
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def normalize_angle(angle: Optional[float]) -> int:
"""
Round angle to nearest 90 degrees.
Azure returns angle in degrees (can be negative for CCW rotation).
We round to nearest 90° for correction.
"""
if angle is None or angle == 0:
return 0
# Round to nearest 90 degrees, preserving sign
rounded = round(angle / 90) * 90
# Normalize to -180 to 180 range for cleaner math
while rounded > 180:
rounded -= 360
while rounded < -180:
rounded += 360
return int(rounded)
def transform_polygon(polygon: list, rotation: int, page_width: float, page_height: float) -> list:
"""
Transform polygon coordinates based on rotation.
The rotation is the detected angle (can be negative for CCW).
We compute the correction and transform coordinates to match the corrected PDF.
Args:
polygon: List of [x, y] coordinate pairs in inches
rotation: Detected angle in degrees (can be negative, e.g., -90 for CCW)
page_width: Original page width in inches (before rotation)
page_height: Original page height in inches (before rotation)
Returns:
Transformed polygon with coordinates adjusted for rotation
"""
# Calculate correction angle (opposite of detected)
# -90° detected -> +90° correction
correction = -rotation
# Normalize to positive 0-360 range
correction = int(((correction % 360) + 360) % 360)
transformed = []
for point in polygon:
x, y = point[0], point[1]
if correction == 90:
# Applied 90° CW rotation to PDF
# For +90° CW: (x, y) -> (height - y, x)
new_x = page_height - y
new_y = x
elif correction == 180:
# Applied 180° rotation to PDF
# (x, y) -> (width - x, height - y)
new_x = page_width - x
new_y = page_height - y
elif correction == 270:
# Applied 270° CW (90° CCW) rotation to PDF
# For +270° CW: (x, y) -> (y, width - x)
new_x = y
new_y = page_width - x
else:
new_x, new_y = x, y
transformed.append([new_x, new_y])
return transformed
def transform_bounding_regions(regions: list, rotation: int, page_width: float, page_height: float) -> list:
"""Transform bounding regions for a rotated page."""
for region in regions:
if 'polygon' in region:
region['polygon'] = transform_polygon(
region['polygon'],
rotation,
page_width,
page_height
)
return regions
def transform_fields_coordinates(fields: dict, rotations: dict[int, int], pages: list):
"""
Recursively transform bounding regions in fields.
Args:
fields: Dictionary of field name -> field data
rotations: Dictionary of page_number -> rotation angle
pages: List of page info dictionaries (with original dimensions)
"""
for field_name, field_data in fields.items():
if not isinstance(field_data, dict):
continue
# Get page info for this field
page_num = 1
if 'bounding_regions' in field_data and field_data['bounding_regions']:
page_num = field_data['bounding_regions'][0].get('page_number', 1)
rotation = rotations.get(page_num, 0)
if rotation != 0:
page_info = next((p for p in pages if p.get('page_number') == page_num), None)
if page_info and 'bounding_regions' in field_data:
transform_bounding_regions(
field_data['bounding_regions'],
rotation,
page_info.get('width', 8.5),
page_info.get('height', 11)
)
# Recurse into nested value
if 'value' in field_data:
val = field_data['value']
if isinstance(val, dict):
transform_fields_coordinates(val, rotations, pages)
elif isinstance(val, list):
for item in val:
if isinstance(item, dict):
# Handle bounding_regions at item level
item_page = 1
if 'bounding_regions' in item and item['bounding_regions']:
item_page = item['bounding_regions'][0].get('page_number', 1)
item_rotation = rotations.get(item_page, 0)
if item_rotation != 0 and 'bounding_regions' in item:
item_page_info = next((p for p in pages if p.get('page_number') == item_page), None)
if item_page_info:
transform_bounding_regions(
item['bounding_regions'],
item_rotation,
item_page_info.get('width', 8.5),
item_page_info.get('height', 11)
)
# Recurse into item value
if 'value' in item and isinstance(item['value'], dict):
transform_fields_coordinates(item['value'], rotations, pages)
def transform_ocr_coordinates(ocr_json: dict, rotations: dict[int, int]) -> dict:
"""
Transform all coordinates in OCR JSON based on page rotations.
Args:
ocr_json: The raw OCR JSON from Azure
rotations: Dictionary mapping page_number to rotation angle
Returns:
Updated OCR JSON with transformed coordinates
"""
# Store original dimensions before updating
original_pages = []
for page_info in ocr_json.get('pages', []):
original_pages.append({
'page_number': page_info.get('page_number', 1),
'width': page_info.get('width'),
'height': page_info.get('height')
})
# Update page dimensions (swap width/height for 90/270 corrections)
for page_info in ocr_json.get('pages', []):
page_num = page_info.get('page_number', 1)
rotation = rotations.get(page_num, 0)
# Calculate correction angle (opposite of detected)
correction = int(((-rotation % 360) + 360) % 360)
if correction in (90, 270):
# Swap width and height
old_width = page_info.get('width')
old_height = page_info.get('height')
page_info['width'] = old_height
page_info['height'] = old_width
logger.debug(f"Page {page_num}: swapped dimensions {old_width}x{old_height} -> {old_height}x{old_width}")
# Reset angle to 0 since we've corrected it
page_info['angle'] = 0
# Transform bounding regions in documents using ORIGINAL dimensions
for doc in ocr_json.get('documents', []):
transform_fields_coordinates(doc.get('fields', {}), rotations, original_pages)
return ocr_json
def rotate_pdf_pages(pdf_path: str, ocr_raw_json: dict) -> tuple[bool, dict]:
"""
Rotate PDF pages based on OCR angle data and transform coordinates.
This should be called immediately after Azure OCR returns, before any
other post-processing extracts data from raw_json.
Args:
pdf_path: Path to the PDF file
ocr_raw_json: The serialized OCR result containing page angles
Returns:
Tuple of (modified: bool, updated_ocr_json: dict)
- modified: True if any pages were rotated
- updated_ocr_json: OCR JSON with transformed coordinates
"""
pages_info = ocr_raw_json.get('pages', [])
rotations_needed = {}
# Check which pages need rotation
for page_info in pages_info:
page_num = page_info.get('page_number', 1)
raw_angle = page_info.get('angle', 0)
angle = normalize_angle(raw_angle)
logger.info(f"Page {page_num}: raw angle={raw_angle}, normalized={angle}")
if angle != 0:
rotations_needed[page_num] = angle
if not rotations_needed:
logger.debug("No page rotations needed")
return False, ocr_raw_json
logger.info(f"Rotating pages: {rotations_needed}")
try:
import tempfile
import shutil
import os
# Open source PDF for reading
src_doc = fitz.open(pdf_path)
# Create a new document for output
out_doc = fitz.open()
# Process each page in order
for page_idx in range(len(src_doc)):
page_num = page_idx + 1
src_page = src_doc[page_idx]
rect = src_page.rect
rotation = rotations_needed.get(page_num, 0)
if rotation == 0:
# No rotation needed - just copy the page as-is
out_doc.insert_pdf(src_doc, from_page=page_idx, to_page=page_idx)
logger.debug(f"Page {page_num}: no rotation needed, copied as-is")
else:
# Calculate correction: -90° content needs +90° rotation to appear upright
correction = -rotation
# Normalize to 0-360 for PyMuPDF
correction = int(((correction % 360) + 360) % 360)
logger.info(f"Page {page_num}: detected angle={rotation}°, applying correction={correction}°")
logger.info(f"Page {page_num}: original size {rect.width}x{rect.height}")
# Render page to pixmap at high resolution
# Use 2x scale for better quality
mat = fitz.Matrix(2, 2)
pix = src_page.get_pixmap(matrix=mat)
# Rotate the pixmap
# PyMuPDF Pixmap doesn't have direct rotate, so we use PIL
from PIL import Image
import io
# Convert pixmap to PIL Image
img_data = pix.tobytes("png")
img = Image.open(io.BytesIO(img_data))
# Rotate image (PIL rotates counter-clockwise, we need clockwise)
# correction=90 means rotate 90° CW, which is -90° in PIL (or 270° CCW)
pil_rotation = (360 - correction) % 360
if pil_rotation != 0:
img = img.rotate(pil_rotation, expand=True)
logger.info(f"Page {page_num}: rotated image {pil_rotation}° CCW (={correction}° CW)")
# For 90° or 270° rotation, swap width and height
if correction in (90, 270):
new_width, new_height = rect.height, rect.width
else:
new_width, new_height = rect.width, rect.height
# Create new page with correct dimensions
new_page = out_doc.new_page(width=new_width, height=new_height)
# Convert PIL image back to bytes
img_buffer = io.BytesIO()
img.save(img_buffer, format='PNG')
img_buffer.seek(0)
# Insert the rotated image into the new page
new_page.insert_image(
fitz.Rect(0, 0, new_width, new_height),
stream=img_buffer.read()
)
logger.info(f"Page {page_num}: re-rendered with {correction}° rotation, new size {new_width}x{new_height}")
src_doc.close()
# Save to temp file first, then replace original
temp_fd, temp_path = tempfile.mkstemp(suffix='.pdf')
try:
os.close(temp_fd) # Close the file descriptor, we just need the path
out_doc.save(temp_path, garbage=4, deflate=True)
out_doc.close()
# Replace original with rotated version
shutil.move(temp_path, pdf_path)
except Exception:
# Clean up temp file on error
if os.path.exists(temp_path):
os.unlink(temp_path)
raise
logger.info(f"PDF saved with rotated pages: {pdf_path}")
except Exception as e:
logger.error(f"Error rotating PDF: {e}")
# Return original JSON if rotation fails
return False, ocr_raw_json
# Transform OCR coordinates to match new orientation
updated_json = transform_ocr_coordinates(ocr_raw_json, rotations_needed)
return True, updated_json

View file

@ -0,0 +1,153 @@
"""
Purchase Order matching service finds pending POs for an invoice,
scores match confidence, and handles link/unlink operations.
"""
from datetime import timedelta
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.purchase_order import PurchaseOrder
from models.invoice import Invoice
async def find_matching_pos(
db: AsyncSession,
kitchen_id: int,
supplier_id: int,
invoice_date=None,
invoice_total=None,
) -> list[dict]:
"""Find pending POs for a supplier, ordered by match confidence."""
q = (
select(PurchaseOrder)
.where(
PurchaseOrder.kitchen_id == kitchen_id,
PurchaseOrder.supplier_id == supplier_id,
PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
)
.options(selectinload(PurchaseOrder.line_items))
.order_by(PurchaseOrder.order_date.desc())
)
result = await db.execute(q)
pos = result.scalars().all()
matches = []
for po in pos:
confidence = calculate_match_confidence(po, invoice_date, invoice_total)
matches.append({
"po_id": po.id,
"order_date": po.order_date.isoformat() if po.order_date else None,
"total_amount": float(po.total_amount) if po.total_amount else None,
"order_reference": po.order_reference,
"status": po.status,
"order_type": po.order_type,
"confidence": round(confidence, 2),
})
# Sort by confidence descending
matches.sort(key=lambda m: m["confidence"], reverse=True)
return matches
def calculate_match_confidence(po: PurchaseOrder, invoice_date=None, invoice_total=None) -> float:
"""Score 0-1: supplier already matched (+0.4), date proximity (+0.3), amount similarity (+0.3)."""
score = 0.0
# Supplier match is guaranteed since we filter by supplier_id — grant base score
score += 0.4
# Date proximity: full marks if same day, degrades over 7 days
if invoice_date and po.order_date:
try:
from datetime import date as date_type
if isinstance(invoice_date, str):
inv_date = date_type.fromisoformat(invoice_date)
else:
inv_date = invoice_date
days_apart = abs((inv_date - po.order_date).days)
if days_apart <= 7:
score += 0.3 * (1 - days_apart / 7)
except (ValueError, TypeError):
pass
# Amount similarity: full marks if within 5%, degrades to 0 at 50% difference
if invoice_total is not None and po.total_amount:
try:
inv_total = float(invoice_total)
po_total = float(po.total_amount)
if po_total > 0:
pct_diff = abs(inv_total - po_total) / po_total
if pct_diff <= 0.05:
score += 0.3
elif pct_diff < 0.5:
score += 0.3 * (1 - pct_diff / 0.5)
except (ValueError, TypeError):
pass
return score
async def link_po_to_invoice(
db: AsyncSession, po_id: int, invoice_id: int, kitchen_id: int, user_id: int
) -> PurchaseOrder:
"""Set PO status=LINKED, linked_invoice_id=invoice_id."""
result = await db.execute(
select(PurchaseOrder)
.where(
PurchaseOrder.id == po_id,
PurchaseOrder.kitchen_id == kitchen_id,
)
.options(
selectinload(PurchaseOrder.line_items),
selectinload(PurchaseOrder.supplier),
selectinload(PurchaseOrder.created_by_user),
)
)
po = result.scalar_one_or_none()
if not po:
return None
# Verify invoice exists
inv_result = await db.execute(
select(Invoice).where(
Invoice.id == invoice_id,
Invoice.kitchen_id == kitchen_id,
)
)
if not inv_result.scalar_one_or_none():
return None
po.status = "LINKED"
po.linked_invoice_id = invoice_id
po.updated_by = user_id
await db.commit()
return po
async def unlink_po(
db: AsyncSession, po_id: int, kitchen_id: int, user_id: int
) -> PurchaseOrder:
"""Reset PO status=PENDING, linked_invoice_id=None."""
result = await db.execute(
select(PurchaseOrder)
.where(
PurchaseOrder.id == po_id,
PurchaseOrder.kitchen_id == kitchen_id,
)
.options(
selectinload(PurchaseOrder.line_items),
selectinload(PurchaseOrder.supplier),
selectinload(PurchaseOrder.created_by_user),
)
)
po = result.scalar_one_or_none()
if not po:
return None
po.status = "PENDING"
po.linked_invoice_id = None
po.updated_by = user_id
await db.commit()
return po

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

View file

@ -0,0 +1,183 @@
"""
Resos API Client
CRITICAL: This client is READ-ONLY. All methods use GET requests only.
NO data is written, modified, or deleted in Resos.
Data flows ONE WAY: Resos Local Database
"""
import httpx
import base64
from datetime import date, datetime, timedelta
import logging
from typing import Optional
import asyncio
logger = logging.getLogger(__name__)
class ResosAPIError(Exception):
"""Custom exception for Resos API errors"""
pass
class ResosAPIClient:
"""
Async client for Resos API
CRITICAL: This client is READ-ONLY. All methods use GET requests only.
NO data is written, modified, or deleted in Resos.
Data flows ONE WAY: Resos Local Database
"""
BASE_URL = "https://api.resos.com/v1"
def __init__(self, api_key: str):
self.api_key = api_key
# HTTP Basic Auth: base64_encode(api_key + ':')
self.auth_header = f"Basic {base64.b64encode(f'{api_key}:'.encode()).decode()}"
async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=30.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.aclose()
async def test_connection(self) -> bool:
"""Test API connection by fetching opening hours (GET request only)"""
try:
response = await self.client.get(
f"{self.BASE_URL}/openingHours",
headers={"Authorization": self.auth_header}
)
return response.status_code == 200
except Exception as e:
logger.error(f"Resos connection test failed: {e}")
return False
async def get_bookings(
self,
from_date: date,
to_date: date,
batch_days: int = 7
) -> list[dict]:
"""
Fetch bookings for date range with pagination and rate limiting (GET request only)
Uses batching by date spans to avoid hitting API limits.
Implements rate limiting (1 request per second).
Returns list of booking objects with structure:
{
'_id': 'booking_id',
'date': '2026-01-20',
'time': '19:00',
'people': 2,
'status': 'confirmed',
'guest': {...},
'customFields': [...],
'restaurantNotes': [...]
}
"""
all_bookings = []
current_date = from_date
# Batch requests by date spans (default 7 days per request)
while current_date <= to_date:
batch_end = min(current_date + timedelta(days=batch_days - 1), to_date)
from_datetime = f"{current_date}T00:00:00"
to_datetime = f"{batch_end}T23:59:59"
# Paginate through bookings for this date range
offset = 0
batch_total = 0
while True:
logger.info(f"Fetching Resos bookings: {current_date} to {batch_end} (offset: {offset})")
response = await self.client.get(
f"{self.BASE_URL}/bookings",
headers={"Authorization": self.auth_header},
params={
"fromDateTime": from_datetime,
"toDateTime": to_datetime,
"limit": 100, # Max per request (Resos API limit)
"skip": offset # Pagination offset
}
)
if response.status_code != 200:
error_body = response.text
logger.error(f"Resos API error {response.status_code}: {error_body}")
raise ResosAPIError(f"Failed to fetch bookings: {response.status_code} - {error_body}")
data = response.json()
page_bookings = data if isinstance(data, list) else []
if not page_bookings:
# No more bookings to fetch
break
all_bookings.extend(page_bookings)
batch_total += len(page_bookings)
logger.info(f"Fetched {len(page_bookings)} bookings (offset {offset})")
# If we got fewer than the limit, we've reached the end
if len(page_bookings) < 100:
break
# Move to next page
offset += 100
# Rate limiting: 1 request per second
await asyncio.sleep(1)
logger.info(f"Total for {current_date} to {batch_end}: {batch_total} bookings")
# Move to next date batch
current_date = batch_end + timedelta(days=1)
logger.info(f"Total bookings fetched: {len(all_bookings)}")
return all_bookings
async def get_opening_hours(self) -> list[dict]:
"""
Fetch opening hours/service periods (GET request only)
Returns list of opening hour objects:
{
'_id': 'opening_hour_id',
'name': 'Dinner',
'startTime': '18:00',
'endTime': '22:00',
'days': ['monday', 'tuesday', 'wednesday', ...]
}
"""
response = await self.client.get(
f"{self.BASE_URL}/openingHours",
headers={"Authorization": self.auth_header},
params={"showDeleted": "false", "onlySpecial": "false"}
)
if response.status_code != 200:
raise ResosAPIError(f"Failed to fetch opening hours: {response.status_code}")
return response.json()
async def get_custom_field_definitions(self) -> list[dict]:
"""
Fetch custom field definitions (GET request only)
Returns field definitions with choice options for dropdowns/radios
"""
response = await self.client.get(
f"{self.BASE_URL}/customFields",
headers={"Authorization": self.auth_header}
)
if response.status_code != 200:
raise ResosAPIError(f"Failed to fetch custom fields: {response.status_code}")
return response.json()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,864 @@
"""
Resos Data Sync Service
Handles synchronization of booking data from Resos API to local database.
READ-ONLY integration - all API calls are GET requests only.
"""
import logging
from datetime import date, datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, func, case
from sqlalchemy.dialects.postgresql import insert
from models.settings import KitchenSettings
from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
from services.resos_api import ResosAPIClient, ResosAPIError
logger = logging.getLogger(__name__)
class ResosSyncService:
"""Service for syncing Resos booking data"""
FORECAST_DAYS = 60
HISTORICAL_BACKFILL_DAYS = 30
def __init__(self, kitchen_id: int, db: AsyncSession):
self.kitchen_id = kitchen_id
self.db = db
self._settings: KitchenSettings = None
async def _get_settings(self) -> KitchenSettings:
"""Fetch and cache kitchen settings"""
if self._settings is None:
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
self._settings = result.scalar_one_or_none()
if not self._settings:
raise ValueError("Kitchen settings not found")
return self._settings
async def _get_client(self) -> ResosAPIClient:
"""Create authenticated Resos API client"""
settings = await self._get_settings()
if not settings.resos_api_key:
raise ValueError("Resos API key not configured")
return ResosAPIClient(settings.resos_api_key)
async def _log_sync(
self,
sync_type: str,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> ResosSyncLog:
"""Create sync log entry"""
log = ResosSyncLog(
kitchen_id=self.kitchen_id,
sync_type=sync_type,
status="running",
date_from=date_from,
date_to=date_to
)
self.db.add(log)
await self.db.commit()
await self.db.refresh(log)
return log
async def _complete_sync(
self,
log: ResosSyncLog,
bookings_fetched: int,
bookings_flagged: int,
error: Optional[str] = None
):
"""Mark sync log as complete"""
log.status = "failed" if error else "success"
log.bookings_fetched = bookings_fetched
log.bookings_flagged = bookings_flagged
log.error_message = error
log.completed_at = datetime.utcnow()
await self.db.commit()
def _parse_custom_fields(self, custom_fields: list[dict], field_mapping: dict) -> dict:
"""
Extract custom fields from Resos booking using configured mapping
Args:
custom_fields: Raw custom fields array from Resos API
field_mapping: Mapping dict from settings (field_name -> resos_field_id)
Returns dict with extracted field values
"""
result = {}
# Build lookup by field ID if mapping exists
if field_mapping:
field_lookup = {f['_id']: f for f in custom_fields}
# Use mapped field IDs
if 'booking_number' in field_mapping:
field_id = field_mapping['booking_number']
if field_id in field_lookup:
result['hotel_booking_number'] = field_lookup[field_id].get('value')
if 'hotel_guest' in field_mapping:
field_id = field_mapping['hotel_guest']
if field_id in field_lookup:
choice_name = field_lookup[field_id].get('multipleChoiceValueName', '').lower()
result['is_hotel_guest'] = 'yes' in choice_name
if 'dbb' in field_mapping:
field_id = field_mapping['dbb']
if field_id in field_lookup:
choice_name = field_lookup[field_id].get('multipleChoiceValueName', '').lower()
result['is_dbb'] = 'yes' in choice_name
if 'package' in field_mapping:
field_id = field_mapping['package']
if field_id in field_lookup:
choice_name = field_lookup[field_id].get('multipleChoiceValueName', '').lower()
result['is_package'] = 'yes' in choice_name
if 'exclude' in field_mapping:
field_id = field_mapping['exclude']
if field_id in field_lookup:
result['exclude_flag'] = field_lookup[field_id].get('value')
# Handle allergies - combine predefined and other fields
allergies_parts = []
if 'allergies' in field_mapping:
field_id = field_mapping['allergies']
if field_id in field_lookup:
field = field_lookup[field_id]
# First, handle multipleChoiceValueName (predefined checkbox options)
if 'multipleChoiceValueName' in field:
choice_val = field.get('multipleChoiceValueName', '')
# Handle both list and string values
if isinstance(choice_val, list):
# Extract 'name' from each dict in the list
for item in choice_val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item: # If it's just a string
allergies_parts.append(str(item).strip())
elif isinstance(choice_val, str) and choice_val.startswith('['):
# Parse Python string representation (uses single quotes)
import ast
try:
parsed = ast.literal_eval(choice_val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
except (ValueError, SyntaxError):
# If parsing fails, just use the raw value
allergies_parts.append(str(choice_val))
elif choice_val:
allergies_parts.append(str(choice_val).strip())
# Then, ALSO check 'value' field
# This can contain either:
# 1. Actual Python list of checkbox selections: [{'_id': '...', 'name': 'Gluten Free', ...}]
# 2. Python list string of checkbox selections: "[{'_id': '...', 'name': 'Gluten Free', ...}]"
# 3. Free-text input: "No beef"
val = field.get('value', '')
if val:
# Check if already a list (API returns it as actual list, not string)
if isinstance(val, list):
for item in val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
elif isinstance(val, str) and val.startswith('['):
# Parse Python list string (if API returns string representation)
import ast
try:
parsed = ast.literal_eval(val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
except (ValueError, SyntaxError):
# If parsing fails, use as plain text
allergies_parts.append(str(val).strip())
else:
# Plain text value
allergies_parts.append(str(val).strip())
if 'allergies_other' in field_mapping:
field_id = field_mapping['allergies_other']
if field_id in field_lookup:
val = field_lookup[field_id].get('value', '')
if val:
allergies_parts.append(str(val).strip())
if allergies_parts:
result['allergies'] = ', '.join(filter(None, allergies_parts))
else:
# Fallback: Use name-based matching (case-insensitive substring)
allergies_parts = []
for field in custom_fields:
name = field.get('name', '').lower()
if 'booking #' in name or 'booking number' in name:
result['hotel_booking_number'] = field.get('value')
elif 'hotel guest' in name:
choice_name = field.get('multipleChoiceValueName', '').lower()
result['is_hotel_guest'] = 'yes' in choice_name
elif 'dbb' in name:
choice_name = field.get('multipleChoiceValueName', '').lower()
result['is_dbb'] = 'yes' in choice_name
elif 'package' in name:
choice_name = field.get('multipleChoiceValueName', '').lower()
result['is_package'] = 'yes' in choice_name
elif 'group' in name and 'exclude' in name:
result['exclude_flag'] = field.get('value')
elif 'allerg' in name or 'dietary' in name:
# Collect both predefined checkbox options and free-text "Other" input
# First, handle multipleChoiceValueName (predefined options)
if 'multipleChoiceValueName' in field:
val = field.get('multipleChoiceValueName', '')
# Handle both list and string values
if isinstance(val, list):
# Extract 'name' from each dict in the list
for item in val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item: # If it's just a string
allergies_parts.append(str(item).strip())
elif isinstance(val, str) and val.startswith('['):
# Parse Python string representation (uses single quotes)
import ast
try:
parsed = ast.literal_eval(val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
except (ValueError, SyntaxError):
# If parsing fails, just use the raw value
allergies_parts.append(str(val))
elif val:
allergies_parts.append(str(val).strip())
# Then, ALSO check 'value' field (free-text "Other" input OR checkbox selections)
# This can exist alongside multipleChoiceValueName
val = field.get('value', '')
if val:
# Check if already a list (API returns it as actual list, not string)
if isinstance(val, list):
for item in val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
elif isinstance(val, str) and val.startswith('['):
# Parse Python list string (if API returns string representation)
import ast
try:
parsed = ast.literal_eval(val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
else:
allergies_parts.append(str(val).strip())
except (ValueError, SyntaxError):
allergies_parts.append(str(val).strip())
else:
allergies_parts.append(str(val).strip())
if allergies_parts:
result['allergies'] = ', '.join(filter(None, allergies_parts))
return result
def _check_flags(
self,
booking: dict,
people: int,
notes: str,
allergies: str,
settings: KitchenSettings
) -> tuple[bool, list[str]]:
"""
Check if booking should be flagged
Returns: (is_flagged, flag_reasons list)
"""
flags = []
# Large group check
if people >= settings.resos_large_group_threshold:
flags.append("large_group")
# Allergy check
if allergies:
flags.append("allergies")
# Note keyword check
if notes and settings.resos_note_keywords:
keywords = [k.strip().lower() for k in settings.resos_note_keywords.split('|') if k.strip()]
notes_lower = notes.lower()
for keyword in keywords:
if keyword in notes_lower:
flags.append(f"note_keyword_{keyword}")
return (len(flags) > 0, flags)
async def sync_opening_hours(self) -> int:
"""
Sync opening hours/service periods from Resos
Returns number of periods synced
"""
logger.info(f"Syncing opening hours for kitchen {self.kitchen_id}")
async with await self._get_client() as client:
hours = await client.get_opening_hours()
# Filter out special/one-off periods - only keep regular recurring service periods
# Special periods include things like "closed", "no power", one-time events, etc.
regular_hours = [h for h in hours if h.get('special') == False]
special_hours = [h for h in hours if h.get('special') == True]
logger.info(f"Fetched {len(hours)} total periods, filtered to {len(regular_hours)} regular service periods")
# Log special periods to understand what's being filtered out
breakfast_related = [h for h in special_hours if 'breakfast' in h.get('name', '').lower() or h.get('open', 0) < 1200]
if breakfast_related:
logger.info(f"Found {len(breakfast_related)} breakfast/morning periods that are marked as special:")
for h in breakfast_related[:5]: # Log first 5
open_time = f"{h.get('open', 0) // 100:02d}:{h.get('open', 0) % 100:02d}" if 'open' in h else 'N/A'
close_time = f"{h.get('close', 0) // 100:02d}:{h.get('close', 0) % 100:02d}" if 'close' in h else 'N/A'
logger.info(f" - {h.get('name', 'Unknown')}: {open_time} - {close_time} (special={h.get('special')})")
# Delete existing hours
await self.db.execute(
delete(ResosOpeningHour).where(ResosOpeningHour.kitchen_id == self.kitchen_id)
)
# Insert fresh data (only regular periods)
for hour in regular_hours:
# Transform time format: Resos API uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00)
# Convert to time objects for database
start_time = None
end_time = None
if 'open' in hour:
open_val = hour['open']
hours_part = open_val // 100
mins_part = open_val % 100
start_time = datetime.strptime(f"{hours_part:02d}:{mins_part:02d}", "%H:%M").time()
if 'close' in hour:
close_val = hour['close']
hours_part = close_val // 100
mins_part = close_val % 100
end_time = datetime.strptime(f"{hours_part:02d}:{mins_part:02d}", "%H:%M").time()
opening_hour = ResosOpeningHour(
kitchen_id=self.kitchen_id,
resos_opening_hour_id=hour['_id'],
name=hour.get('name', 'Unknown'),
start_time=start_time,
end_time=end_time,
days_of_week=','.join(hour.get('days', [])),
is_special=hour.get('type') == 'special',
fetched_at=datetime.utcnow()
)
self.db.add(opening_hour)
await self.db.commit()
logger.info(f"Synced {len(regular_hours)} regular opening hours (excluded {len(hours) - len(regular_hours)} special periods)")
return len(regular_hours)
async def sync_bookings(
self,
date_from: date,
date_to: date,
is_forecast: bool = False
) -> dict:
"""
Sync bookings for date range
Returns summary dict with counts
"""
logger.info(f"Starting Resos sync: kitchen_id={self.kitchen_id}, from={date_from}, to={date_to}, forecast={is_forecast}")
log = await self._log_sync('forecast' if is_forecast else 'historical', date_from, date_to)
try:
settings = await self._get_settings()
logger.info(f"Retrieved settings, API key configured: {bool(settings.resos_api_key)}")
logger.info(f"Creating Resos API client...")
async with await self._get_client() as client:
logger.info(f"Fetching bookings from Resos API...")
bookings = await client.get_bookings(date_from, date_to)
total_fetched = len(bookings)
total_processed = 0
total_skipped = 0
total_flagged = 0
total_orphaned = 0
logger.info(f"Fetched {total_fetched} bookings from Resos API")
# Track all resos IDs from API response for orphan detection
api_booking_ids = set()
# Get custom field mapping from settings
field_mapping = settings.resos_custom_field_mapping or {}
# Statuses to exclude from sync (cancelled, waitlist, deleted bookings shouldn't be counted)
excluded_statuses = {'canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'}
# Process each booking
for booking_data in bookings:
resos_id = booking_data.get('_id')
if resos_id:
api_booking_ids.add(resos_id)
# Handle bookings with excluded statuses - remove from DB if they exist
status = booking_data.get('status', '').lower()
if status in excluded_statuses:
if resos_id:
await self.db.execute(
delete(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.resos_booking_id == resos_id
)
)
)
logger.debug(f"Removed/skipped booking {resos_id} with excluded status: {status}")
total_skipped += 1
continue
total_processed += 1
custom_fields = self._parse_custom_fields(
booking_data.get('customFields', []),
field_mapping
)
# Extract notes
notes_list = booking_data.get('restaurantNotes', [])
notes = '\n'.join([n.get('restaurantNote', '') for n in notes_list if n.get('restaurantNote')])
# Check flags
is_flagged, flag_reasons = self._check_flags(
booking_data,
booking_data.get('people', 0),
notes,
custom_fields.get('allergies', ''),
settings
)
if is_flagged:
total_flagged += 1
# Parse date and time
# Resos API returns date as "YYYY-MM-DD" and time as "HH:MM"
from datetime import time as time_class
booking_date = date.fromisoformat(booking_data['date'])
# Parse time string (format: "HH:MM" or "HH:MM:SS")
time_str = booking_data['time']
if ':' in time_str:
time_parts = time_str.split(':')
booking_time = time_class(int(time_parts[0]), int(time_parts[1]))
else:
# Fallback if no colon
booking_time = time_class(0, 0)
# Parse booked_at timestamp if available
# Convert to timezone-naive datetime for database (TIMESTAMP WITHOUT TIME ZONE)
booked_at = None
if booking_data.get('createdAt'):
try:
dt = datetime.fromisoformat(booking_data['createdAt'].replace('Z', '+00:00'))
# Remove timezone info to match database column type
booked_at = dt.replace(tzinfo=None)
except:
pass
# Extract table name from tables array (Phase 8.1)
# Format: [{'_id': '...', 'name': 'Table 8', 'area': {...}}]
table_name = None
tables = booking_data.get('tables', [])
if tables and len(tables) > 0:
table_name = tables[0].get('name')
# Upsert booking using INSERT ... ON CONFLICT
stmt = insert(ResosBooking).values(
kitchen_id=self.kitchen_id,
resos_booking_id=booking_data['_id'],
booking_date=booking_date,
booking_time=booking_time,
people=booking_data.get('people', 0),
status=booking_data.get('status', 'unknown').lower(),
seating_area=booking_data.get('area'),
table_name=table_name,
hotel_booking_number=custom_fields.get('hotel_booking_number'),
is_hotel_guest=custom_fields.get('is_hotel_guest'),
is_dbb=custom_fields.get('is_dbb'),
is_package=custom_fields.get('is_package'),
exclude_flag=custom_fields.get('exclude_flag'),
allergies=custom_fields.get('allergies'),
notes=notes,
booked_at=booked_at,
opening_hour_id=booking_data.get('openingHourId'),
opening_hour_name=booking_data.get('openingHourName'),
is_flagged=is_flagged,
flag_reasons=','.join(flag_reasons) if flag_reasons else None,
fetched_at=datetime.utcnow(),
is_forecast=is_forecast
)
stmt = stmt.on_conflict_do_update(
index_elements=['kitchen_id', 'resos_booking_id'],
set_={
'booking_date': stmt.excluded.booking_date,
'booking_time': stmt.excluded.booking_time,
'people': stmt.excluded.people,
'status': stmt.excluded.status,
'seating_area': stmt.excluded.seating_area,
'table_name': stmt.excluded.table_name,
'hotel_booking_number': stmt.excluded.hotel_booking_number,
'is_hotel_guest': stmt.excluded.is_hotel_guest,
'is_dbb': stmt.excluded.is_dbb,
'is_package': stmt.excluded.is_package,
'exclude_flag': stmt.excluded.exclude_flag,
'allergies': stmt.excluded.allergies,
'notes': stmt.excluded.notes,
'booked_at': stmt.excluded.booked_at,
'opening_hour_id': stmt.excluded.opening_hour_id,
'opening_hour_name': stmt.excluded.opening_hour_name,
'is_flagged': stmt.excluded.is_flagged,
'flag_reasons': stmt.excluded.flag_reasons,
'fetched_at': stmt.excluded.fetched_at,
'is_forecast': stmt.excluded.is_forecast,
}
)
await self.db.execute(stmt)
await self.db.commit()
logger.info(f"Committed {total_processed} bookings to database ({total_skipped} skipped with excluded statuses)")
# Remove orphaned bookings (in DB for this date range but not in API response)
if api_booking_ids:
orphan_result = await self.db.execute(
delete(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date >= date_from,
ResosBooking.booking_date <= date_to,
~ResosBooking.resos_booking_id.in_(api_booking_ids)
)
)
)
total_orphaned = orphan_result.rowcount
if total_orphaned > 0:
await self.db.commit()
logger.info(f"Removed {total_orphaned} orphaned bookings no longer in Resos API")
# Aggregate into daily stats
logger.info(f"Aggregating daily stats for {date_from} to {date_to}...")
await self._aggregate_daily_stats(date_from, date_to, is_forecast)
logger.info(f"Daily stats aggregation complete")
await self._complete_sync(log, total_processed, total_flagged)
logger.info(f"Resos sync completed: {total_processed} processed, {total_skipped} excluded, {total_orphaned} orphaned removed, {total_flagged} flagged")
return {
'bookings_fetched': total_fetched,
'bookings_processed': total_processed,
'bookings_skipped': total_skipped,
'bookings_orphaned': total_orphaned,
'bookings_flagged': total_flagged,
'date_from': date_from,
'date_to': date_to
}
except Exception as e:
logger.error(f"Resos sync failed: {e}", exc_info=True)
await self._complete_sync(log, 0, 0, str(e))
raise
async def _aggregate_daily_stats(
self,
date_from: date,
date_to: date,
is_forecast: bool
):
"""
Aggregate bookings into daily stats
"""
logger.info(f"_aggregate_daily_stats: Querying bookings for aggregation...")
# Query bookings grouped by date and service period
result = await self.db.execute(
select(
ResosBooking.booking_date,
ResosBooking.opening_hour_id,
ResosBooking.opening_hour_name,
func.count(ResosBooking.id).label('booking_count'),
func.sum(ResosBooking.people).label('cover_count'),
func.sum(case((ResosBooking.is_flagged == True, 1), else_=0)).label('flagged_count')
).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date >= date_from,
ResosBooking.booking_date <= date_to,
~func.lower(ResosBooking.status).in_(['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'])
)
).group_by(
ResosBooking.booking_date,
ResosBooking.opening_hour_id,
ResosBooking.opening_hour_name
)
)
# Get kitchen settings for service type mapping
settings_result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None
# Create a map from opening_hour_id (resos_id) to service_type
service_type_map = {}
if opening_hours_mapping:
for mapping in opening_hours_mapping:
if isinstance(mapping, dict):
resos_id = mapping.get('resos_id', '')
service_type = mapping.get('service_type', '')
if resos_id and service_type:
service_type_map[resos_id] = service_type
# Build daily stats
daily_data = {}
logger.info(f"_aggregate_daily_stats: Building daily stats from query results...")
for row in result:
booking_date = row.booking_date
if booking_date not in daily_data:
daily_data[booking_date] = {
'total_bookings': 0,
'total_covers': 0,
'flagged_count': 0,
'service_breakdown_raw': {} # Store by service type
}
daily_data[booking_date]['total_bookings'] += row.booking_count
daily_data[booking_date]['total_covers'] += row.cover_count
daily_data[booking_date]['flagged_count'] += row.flagged_count
# Map opening_hour_id to service_type
opening_hour_id = row.opening_hour_id
opening_hour_name = row.opening_hour_name or 'Unknown'
# Look up service type by opening_hour_id, fallback to opening_hour_name
service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name
# Capitalize service type for display
service_type_display = service_type.capitalize() if service_type else 'Unknown'
# Aggregate by service type
if service_type_display not in daily_data[booking_date]['service_breakdown_raw']:
daily_data[booking_date]['service_breakdown_raw'][service_type_display] = {
'bookings': 0,
'covers': 0
}
daily_data[booking_date]['service_breakdown_raw'][service_type_display]['bookings'] += row.booking_count
daily_data[booking_date]['service_breakdown_raw'][service_type_display]['covers'] += row.cover_count
# Convert service_breakdown_raw dict to list format
for booking_date in daily_data.keys():
daily_data[booking_date]['service_breakdown'] = [
{
'period': service_type,
'bookings': stats['bookings'],
'covers': stats['covers']
}
for service_type, stats in daily_data[booking_date]['service_breakdown_raw'].items()
]
del daily_data[booking_date]['service_breakdown_raw'] # Remove temp field
# Build consolidated bookings summary for each day
for booking_date in daily_data.keys():
# Fetch all bookings for this date
bookings_result = await self.db.execute(
select(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date == booking_date,
~func.lower(ResosBooking.status).in_(['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'])
)
).order_by(ResosBooking.booking_time)
)
bookings_for_date = bookings_result.scalars().all()
# Build consolidated summary (stripped data for quick access)
bookings_summary = []
for b in bookings_for_date:
# Map opening_hour_id to service_type for display
service_type = service_type_map.get(b.opening_hour_id, b.opening_hour_name) if b.opening_hour_id else b.opening_hour_name
service_type_display = service_type.capitalize() if service_type else (b.opening_hour_name or 'Unknown')
bookings_summary.append({
'time': b.booking_time.strftime('%H:%M'),
'people': b.people,
'period': service_type_display,
'booked_at': b.booked_at.isoformat() if b.booked_at else None,
'is_flagged': b.is_flagged,
'status': b.status
})
# Collect unique flag types for this day
unique_flags = set()
for b in bookings_for_date:
if b.is_flagged and b.flag_reasons:
# Split flag_reasons and add to set
for flag in b.flag_reasons.split(','):
flag = flag.strip()
if flag:
unique_flags.add(flag)
daily_data[booking_date]['bookings_summary'] = bookings_summary
daily_data[booking_date]['unique_flag_types'] = list(unique_flags)
# Upsert daily stats
for booking_date, data in daily_data.items():
stmt = insert(ResosDailyStats).values(
kitchen_id=self.kitchen_id,
date=booking_date,
total_bookings=data['total_bookings'],
total_covers=data['total_covers'],
service_breakdown=data['service_breakdown'],
flagged_booking_count=data['flagged_count'],
unique_flag_types=data['unique_flag_types'],
bookings_summary=data['bookings_summary'],
fetched_at=datetime.utcnow(),
is_forecast=is_forecast
)
stmt = stmt.on_conflict_do_update(
index_elements=['kitchen_id', 'date'],
set_={
'total_bookings': stmt.excluded.total_bookings,
'total_covers': stmt.excluded.total_covers,
'service_breakdown': stmt.excluded.service_breakdown,
'flagged_booking_count': stmt.excluded.flagged_booking_count,
'unique_flag_types': stmt.excluded.unique_flag_types,
'bookings_summary': stmt.excluded.bookings_summary,
'fetched_at': stmt.excluded.fetched_at,
'is_forecast': stmt.excluded.is_forecast,
}
)
await self.db.execute(stmt)
# Clean up stale daily stats for dates in range that no longer have valid bookings
# (e.g. all bookings for a date were cancelled - daily_data won't have an entry,
# so the old stats row with inflated counts would persist)
dates_with_bookings = set(daily_data.keys())
existing_stats = await self.db.execute(
select(ResosDailyStats.date).where(
and_(
ResosDailyStats.kitchen_id == self.kitchen_id,
ResosDailyStats.date >= date_from,
ResosDailyStats.date <= date_to
)
)
)
for row in existing_stats:
if row.date not in dates_with_bookings:
await self.db.execute(
delete(ResosDailyStats).where(
and_(
ResosDailyStats.kitchen_id == self.kitchen_id,
ResosDailyStats.date == row.date
)
)
)
logger.info(f"Removed stale daily stats for {row.date} (no valid bookings remaining)")
await self.db.commit()
async def run_daily_sync(self) -> dict:
"""
Run daily sync:
- Historical: Yesterday - 30 days
- Forecast: Today + 60 days
"""
today = date.today()
yesterday = today - timedelta(days=1)
historical_from = yesterday - timedelta(days=self.HISTORICAL_BACKFILL_DAYS)
forecast_to = today + timedelta(days=self.FORECAST_DAYS)
# Sync opening hours first
await self.sync_opening_hours()
# Sync historical
hist_result = await self.sync_bookings(historical_from, yesterday, is_forecast=False)
# Sync forecast
forecast_result = await self.sync_bookings(today, forecast_to, is_forecast=True)
# Update last sync timestamp
settings = await self._get_settings()
settings.resos_last_sync = datetime.utcnow()
await self.db.commit()
return {
'historical': hist_result,
'forecast': forecast_result
}
async def run_upcoming_sync(self) -> dict:
"""
Run upcoming sync for next 7 days only.
This is designed to run more frequently (e.g., every 15 minutes) to keep
the most important upcoming bookings fresh.
"""
today = date.today()
next_week = today + timedelta(days=7)
# Sync opening hours first
await self.sync_opening_hours()
# Sync next 7 days
result = await self.sync_bookings(today, next_week, is_forecast=True)
# Update last upcoming sync timestamp
settings = await self._get_settings()
settings.resos_last_upcoming_sync = datetime.utcnow()
await self.db.commit()
return {
'upcoming': result
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,173 @@
"""
Stock History Service for detecting stock status changes.
Similar to price history, this tracks whether items have been marked as non-stock
in the past and warns when the status conflicts with history.
"""
import logging
from datetime import date, timedelta
from typing import Optional, List
from dataclasses import dataclass
from sqlalchemy import select, and_, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from models.invoice import Invoice
from models.line_item import LineItem
logger = logging.getLogger(__name__)
def normalize_description(description: Optional[str]) -> Optional[str]:
"""
Normalize description by taking only the first line.
"""
if not description:
return description
return description.split('\n')[0].strip()
@dataclass
class StockStatusHistory:
"""Stock status history result for a line item."""
has_history: bool
previously_non_stock: bool
total_occurrences: int
non_stock_occurrences: int
most_recent_status: Optional[bool] = None # is_non_stock value
class StockHistoryService:
"""Service for stock status history and conflict detection."""
def __init__(self, db: AsyncSession, kitchen_id: int):
self.db = db
self.kitchen_id = kitchen_id
async def get_stock_status_history(
self,
supplier_id: int,
product_code: Optional[str],
description: Optional[str],
unit: Optional[str] = None,
lookback_days: int = 90,
exclude_invoice_id: Optional[int] = None
) -> StockStatusHistory:
"""
Get stock status history for a line item.
Returns information about whether this item has been marked as non-stock
in previous invoices.
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.)
lookback_days: How far back to check (default 90 days)
exclude_invoice_id: Exclude a specific invoice (typically current invoice)
Returns:
StockStatusHistory with conflict information
"""
cutoff_date = date.today() - 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,
]
# 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))
# Exclude current invoice if provided
if exclude_invoice_id:
conditions.append(Invoice.id != exclude_invoice_id)
# Get all matching line items
result = await self.db.execute(
select(LineItem.is_non_stock, Invoice.invoice_date)
.where(and_(*conditions))
.order_by(desc(Invoice.invoice_date))
)
rows = result.fetchall()
if not rows:
return StockStatusHistory(
has_history=False,
previously_non_stock=False,
total_occurrences=0,
non_stock_occurrences=0
)
# Count occurrences
total_occurrences = len(rows)
non_stock_occurrences = sum(1 for row in rows if row[0]) # is_non_stock=True
most_recent_status = rows[0][0] # Most recent is_non_stock value
# Previously marked as non-stock if ANY previous occurrence was non-stock
previously_non_stock = non_stock_occurrences > 0
return StockStatusHistory(
has_history=True,
previously_non_stock=previously_non_stock,
total_occurrences=total_occurrences,
non_stock_occurrences=non_stock_occurrences,
most_recent_status=most_recent_status
)
async def check_all_line_items(
self,
invoice_id: int,
lookback_days: int = 90
) -> dict[int, StockStatusHistory]:
"""
Check stock status history for all line items in an invoice.
Returns:
Dict mapping line_item_id to StockStatusHistory
"""
# Get all line items for this invoice
result = await self.db.execute(
select(LineItem, Invoice.supplier_id)
.join(Invoice, LineItem.invoice_id == Invoice.id)
.where(
and_(
LineItem.invoice_id == invoice_id,
Invoice.kitchen_id == self.kitchen_id
)
)
)
rows = result.fetchall()
history_map = {}
for line_item, supplier_id in rows:
history = await self.get_stock_status_history(
supplier_id=supplier_id,
product_code=line_item.product_code,
description=line_item.description,
unit=line_item.unit,
lookback_days=lookback_days,
exclude_invoice_id=invoice_id
)
history_map[line_item.id] = history
return history_map