Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
3
backend/services/__init__.py
Normal file
3
backend/services/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .duplicate_detector import DuplicateDetector, detect_document_type
|
||||
|
||||
__all__ = ["DuplicateDetector", "detect_document_type"]
|
||||
BIN
backend/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/backup_service.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/backup_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/brakes_scraper.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/brakes_scraper.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/services/__pycache__/duplicate_detector.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/duplicate_detector.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/email_service.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/email_service.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/services/__pycache__/forecast_api.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/forecast_api.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/imap_sync.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/imap_sync.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/kds_graphql.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/kds_graphql.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/llm_service.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/llm_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/newbook_api.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/newbook_api.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/newbook_sync.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/newbook_sync.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/nextcloud_service.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/nextcloud_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/pdf_highlighter.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/pdf_highlighter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/pdf_rotation.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/pdf_rotation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/po_matching.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/po_matching.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/price_history.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/price_history.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/resos_api.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/resos_api.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/resos_stats.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/resos_stats.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/resos_sync.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/resos_sync.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/sambapos_api.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/sambapos_api.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/signalr_listener.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/signalr_listener.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/stock_history.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/stock_history.cpython-313.pyc
Normal file
Binary file not shown.
712
backend/services/backup_service.py
Normal file
712
backend/services/backup_service.py
Normal 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))
|
||||
130
backend/services/brakes_scraper.py
Normal file
130
backend/services/brakes_scraper.py
Normal 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
|
||||
452
backend/services/dispute_archival_service.py
Normal file
452
backend/services/dispute_archival_service.py
Normal 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())
|
||||
214
backend/services/duplicate_detector.py
Normal file
214
backend/services/duplicate_detector.py
Normal 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"
|
||||
312
backend/services/email_service.py
Normal file
312
backend/services/email_service.py
Normal 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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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
|
||||
363
backend/services/file_archival_service.py
Normal file
363
backend/services/file_archival_service.py
Normal 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)
|
||||
286
backend/services/forecast_api.py
Normal file
286
backend/services/forecast_api.py
Normal 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
|
||||
503
backend/services/imap_sync.py
Normal file
503
backend/services/imap_sync.py
Normal 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
|
||||
1558
backend/services/llm_service.py
Normal file
1558
backend/services/llm_service.py
Normal file
File diff suppressed because it is too large
Load diff
959
backend/services/newbook_api.py
Normal file
959
backend/services/newbook_api.py
Normal 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
|
||||
774
backend/services/newbook_sync.py
Normal file
774
backend/services/newbook_sync.py
Normal 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
|
||||
353
backend/services/nextcloud_service.py
Normal file
353
backend/services/nextcloud_service.py
Normal 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
|
||||
937
backend/services/pdf_highlighter.py
Normal file
937
backend/services/pdf_highlighter.py
Normal 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
|
||||
334
backend/services/pdf_rotation.py
Normal file
334
backend/services/pdf_rotation.py
Normal 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
|
||||
153
backend/services/po_matching.py
Normal file
153
backend/services/po_matching.py
Normal 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
|
||||
843
backend/services/price_history.py
Normal file
843
backend/services/price_history.py
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
"""
|
||||
Price History Service for price change detection and history tracking.
|
||||
|
||||
This service is used by:
|
||||
- Search pages for showing price change indicators
|
||||
- Invoice review for line item price status
|
||||
- History modal for viewing price trends
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Tuple, List
|
||||
from dataclasses import dataclass
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.invoice import Invoice
|
||||
from models.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.acknowledged_price import AcknowledgedPrice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_description(description: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Normalize description by taking only the first line.
|
||||
Many descriptions have newlines and additional details,
|
||||
but for matching purposes we only want the first line.
|
||||
"""
|
||||
if not description:
|
||||
return description
|
||||
# Take everything before the first newline
|
||||
return description.split('\n')[0].strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceHistoryPoint:
|
||||
"""Single point in price history."""
|
||||
date: date
|
||||
price: Decimal
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
quantity: Optional[Decimal] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceStatus:
|
||||
"""Price status result for a line item."""
|
||||
status: str # "consistent", "no_history", "amber", "red", "acknowledged"
|
||||
previous_price: Optional[Decimal] = None
|
||||
change_percent: Optional[float] = None
|
||||
acknowledged_price: Optional[Decimal] = None
|
||||
# Future price info (for old invoices)
|
||||
future_price: Optional[Decimal] = None
|
||||
future_change_percent: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineItemHistory:
|
||||
"""Full history data for a line item."""
|
||||
product_code: Optional[str]
|
||||
description: Optional[str]
|
||||
supplier_id: int
|
||||
supplier_name: Optional[str]
|
||||
# Price history for charting
|
||||
price_history: List[PriceHistoryPoint]
|
||||
# Stats
|
||||
total_occurrences: int
|
||||
total_quantity: Decimal
|
||||
avg_qty_per_invoice: Decimal
|
||||
avg_qty_per_week: Decimal
|
||||
avg_qty_per_month: Decimal
|
||||
# Current status
|
||||
current_price: Optional[Decimal]
|
||||
price_change_status: str
|
||||
|
||||
|
||||
class PriceHistoryService:
|
||||
"""Service for price history and change detection."""
|
||||
|
||||
def __init__(self, db: AsyncSession, kitchen_id: int):
|
||||
self.db = db
|
||||
self.kitchen_id = kitchen_id
|
||||
|
||||
async def _get_settings(self) -> Optional[KitchenSettings]:
|
||||
"""Get kitchen settings for price thresholds."""
|
||||
result = await self.db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kitchen_id == self.kitchen_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_acknowledged_price(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str]
|
||||
) -> Optional[AcknowledgedPrice]:
|
||||
"""Get acknowledged price for a product if it exists."""
|
||||
# Build query - need to handle NULL values in comparison
|
||||
conditions = [
|
||||
AcknowledgedPrice.kitchen_id == self.kitchen_id,
|
||||
AcknowledgedPrice.supplier_id == supplier_id,
|
||||
]
|
||||
|
||||
# Handle product_code - compare with IS NULL for None
|
||||
if product_code:
|
||||
conditions.append(AcknowledgedPrice.product_code == product_code)
|
||||
else:
|
||||
conditions.append(AcknowledgedPrice.product_code.is_(None))
|
||||
|
||||
# Handle description - compare with IS NULL for None
|
||||
if description:
|
||||
conditions.append(AcknowledgedPrice.description == description)
|
||||
else:
|
||||
conditions.append(AcknowledgedPrice.description.is_(None))
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AcknowledgedPrice).where(and_(*conditions))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_previous_prices(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
unit: Optional[str],
|
||||
lookback_days: int,
|
||||
exclude_invoice_id: Optional[int] = None,
|
||||
reference_date: Optional[date] = None
|
||||
) -> List[Tuple[Decimal, date]]:
|
||||
"""Get previous prices for a product within lookback period."""
|
||||
# Use reference_date if provided (invoice date), otherwise use today
|
||||
ref_date = reference_date or date.today()
|
||||
cutoff_date = ref_date - timedelta(days=lookback_days)
|
||||
|
||||
# Build conditions for matching product
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id,
|
||||
Invoice.invoice_date >= cutoff_date,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
]
|
||||
|
||||
# Match by product_code if available, otherwise by description
|
||||
if product_code:
|
||||
conditions.append(LineItem.product_code == product_code)
|
||||
else:
|
||||
conditions.append(LineItem.product_code.is_(None))
|
||||
if description:
|
||||
# Normalize description to first line only for matching
|
||||
normalized_desc = normalize_description(description)
|
||||
# Match against first line of stored descriptions
|
||||
conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == normalized_desc
|
||||
)
|
||||
|
||||
# Match by unit - critical for products sold in different units (Box, Each, Kg, etc.)
|
||||
if unit:
|
||||
conditions.append(LineItem.unit == unit)
|
||||
else:
|
||||
conditions.append(LineItem.unit.is_(None))
|
||||
|
||||
# Exclude current invoice if provided
|
||||
if exclude_invoice_id:
|
||||
conditions.append(Invoice.id != exclude_invoice_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(LineItem.unit_price, Invoice.invoice_date)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
)
|
||||
|
||||
return [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
async def _get_future_prices(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
unit: Optional[str],
|
||||
reference_date: date,
|
||||
exclude_invoice_id: Optional[int] = None
|
||||
) -> List[Tuple[Decimal, date]]:
|
||||
"""Get future prices for a product (after reference_date)."""
|
||||
# Build conditions for matching product
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id,
|
||||
Invoice.invoice_date > reference_date,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
]
|
||||
|
||||
# Match by product_code if available, otherwise by description
|
||||
if product_code:
|
||||
conditions.append(LineItem.product_code == product_code)
|
||||
else:
|
||||
conditions.append(LineItem.product_code.is_(None))
|
||||
if description:
|
||||
normalized_desc = normalize_description(description)
|
||||
conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == normalized_desc
|
||||
)
|
||||
|
||||
# Match by unit
|
||||
if unit:
|
||||
conditions.append(LineItem.unit == unit)
|
||||
else:
|
||||
conditions.append(LineItem.unit.is_(None))
|
||||
|
||||
# Exclude current invoice if provided
|
||||
if exclude_invoice_id:
|
||||
conditions.append(Invoice.id != exclude_invoice_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(LineItem.unit_price, Invoice.invoice_date)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
)
|
||||
|
||||
return [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
async def get_price_status(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
current_price: Decimal,
|
||||
unit: Optional[str] = None,
|
||||
current_invoice_id: Optional[int] = None,
|
||||
reference_date: Optional[date] = None,
|
||||
lookback_days: Optional[int] = None,
|
||||
amber_threshold: Optional[int] = None,
|
||||
red_threshold: Optional[int] = None,
|
||||
) -> PriceStatus:
|
||||
"""
|
||||
Get price status for a line item.
|
||||
|
||||
Returns:
|
||||
PriceStatus with:
|
||||
- "consistent": Price matches history (green tick)
|
||||
- "no_history": First time seeing this item (no icon)
|
||||
- "amber": Small price change within threshold
|
||||
- "red": Large price change above threshold
|
||||
- "acknowledged": Price was flagged but user acknowledged it
|
||||
"""
|
||||
# Get settings if thresholds not provided
|
||||
if lookback_days is None or amber_threshold is None or red_threshold is None:
|
||||
settings = await self._get_settings()
|
||||
lookback_days = lookback_days or (settings.price_change_lookback_days if settings else 30)
|
||||
amber_threshold = amber_threshold or (settings.price_change_amber_threshold if settings else 10)
|
||||
red_threshold = red_threshold or (settings.price_change_red_threshold if settings else 20)
|
||||
|
||||
# Get previous prices
|
||||
previous_prices = await self._get_previous_prices(
|
||||
supplier_id, product_code, description, unit, lookback_days, current_invoice_id, reference_date
|
||||
)
|
||||
|
||||
# Get future prices (if viewing an old invoice)
|
||||
future_price = None
|
||||
future_change_percent = None
|
||||
if reference_date:
|
||||
future_prices = await self._get_future_prices(
|
||||
supplier_id, product_code, description, unit, reference_date, current_invoice_id
|
||||
)
|
||||
if future_prices:
|
||||
future_price = future_prices[0][0] # Most recent future price
|
||||
if future_price and future_price != 0:
|
||||
future_change_percent = float((future_price - current_price) / current_price * 100)
|
||||
|
||||
if not previous_prices:
|
||||
return PriceStatus(
|
||||
status="no_history",
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
# Get most recent previous price
|
||||
previous_price = previous_prices[0][0]
|
||||
|
||||
# Calculate change percentage
|
||||
if previous_price and previous_price != 0:
|
||||
change_percent = float((current_price - previous_price) / previous_price * 100)
|
||||
else:
|
||||
change_percent = 0.0
|
||||
|
||||
# If there's a future price change, suppress regular price change indicator
|
||||
# (only show the grey future price indicator)
|
||||
# But still include previous_price/change_percent so price history button works
|
||||
if future_price and future_change_percent is not None:
|
||||
return PriceStatus(
|
||||
status="consistent", # Hide regular indicator
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
abs_change = abs(change_percent)
|
||||
|
||||
# Check if price is acknowledged
|
||||
acknowledged = await self._get_acknowledged_price(
|
||||
supplier_id, product_code, description
|
||||
)
|
||||
|
||||
if acknowledged and acknowledged.acknowledged_price == current_price:
|
||||
return PriceStatus(
|
||||
status="acknowledged",
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
acknowledged_price=acknowledged.acknowledged_price,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
# Determine status based on change
|
||||
if abs_change <= 0.01: # Essentially no change (floating point tolerance)
|
||||
return PriceStatus(
|
||||
status="consistent",
|
||||
previous_price=previous_price,
|
||||
change_percent=0.0,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
elif abs_change <= red_threshold:
|
||||
return PriceStatus(
|
||||
status="amber", # Any change > 0.01% up to red threshold shows amber
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
else:
|
||||
return PriceStatus(
|
||||
status="red",
|
||||
previous_price=previous_price,
|
||||
change_percent=change_percent,
|
||||
future_price=future_price,
|
||||
future_change_percent=future_change_percent
|
||||
)
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
unit: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
) -> LineItemHistory:
|
||||
"""
|
||||
Get full history for a product including price history and quantity stats.
|
||||
|
||||
Args:
|
||||
supplier_id: Supplier ID
|
||||
product_code: Product code (may be None)
|
||||
description: Description (used if no product_code)
|
||||
unit: Unit (Box, Each, Kg, etc.)
|
||||
date_from: Start date (default: 12 months ago)
|
||||
date_to: End date (default: today)
|
||||
|
||||
Returns:
|
||||
LineItemHistory with price history and stats
|
||||
"""
|
||||
# Default date range: 12 months
|
||||
if date_to is None:
|
||||
date_to = date.today()
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=365)
|
||||
|
||||
# Build conditions for matching product
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id,
|
||||
Invoice.invoice_date >= date_from,
|
||||
Invoice.invoice_date <= date_to,
|
||||
]
|
||||
|
||||
# Match by product_code if available, otherwise by description
|
||||
if product_code:
|
||||
conditions.append(LineItem.product_code == product_code)
|
||||
else:
|
||||
conditions.append(LineItem.product_code.is_(None))
|
||||
if description:
|
||||
# Normalize description to first line only for matching
|
||||
normalized_desc = normalize_description(description)
|
||||
# Match against first line of stored descriptions
|
||||
conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == normalized_desc
|
||||
)
|
||||
|
||||
# Match by unit - critical for products sold in different units
|
||||
if unit:
|
||||
conditions.append(LineItem.unit == unit)
|
||||
else:
|
||||
conditions.append(LineItem.unit.is_(None))
|
||||
|
||||
# Get price history points
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
Invoice.invoice_date,
|
||||
LineItem.unit_price,
|
||||
LineItem.quantity,
|
||||
Invoice.id,
|
||||
Invoice.invoice_number,
|
||||
Invoice.document_type,
|
||||
)
|
||||
.where(and_(*conditions))
|
||||
.order_by(Invoice.invoice_date)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
price_history = []
|
||||
total_qty = Decimal(0)
|
||||
total_occurrences = 0
|
||||
current_price = None
|
||||
|
||||
for row in rows:
|
||||
inv_date, unit_price, qty, inv_id, inv_num, doc_type = row
|
||||
if unit_price is not None:
|
||||
price_history.append(PriceHistoryPoint(
|
||||
date=inv_date,
|
||||
price=unit_price,
|
||||
invoice_id=inv_id,
|
||||
invoice_number=inv_num,
|
||||
quantity=qty
|
||||
))
|
||||
# Only use positive prices from non-credit-notes for current price
|
||||
if unit_price > 0 and doc_type != 'credit_note':
|
||||
current_price = unit_price
|
||||
if qty:
|
||||
total_qty += qty
|
||||
total_occurrences += 1
|
||||
|
||||
# Calculate averages
|
||||
avg_qty_per_invoice = total_qty / total_occurrences if total_occurrences > 0 else Decimal(0)
|
||||
|
||||
# Calculate weeks and months in period
|
||||
days_in_period = (date_to - date_from).days or 1
|
||||
weeks_in_period = max(days_in_period / 7, 1)
|
||||
months_in_period = max(days_in_period / 30, 1)
|
||||
|
||||
avg_qty_per_week = total_qty / Decimal(str(weeks_in_period))
|
||||
avg_qty_per_month = total_qty / Decimal(str(months_in_period))
|
||||
|
||||
# Get supplier name
|
||||
supplier_result = await self.db.execute(
|
||||
select(Supplier.name).where(Supplier.id == supplier_id)
|
||||
)
|
||||
supplier_name = supplier_result.scalar_one_or_none()
|
||||
|
||||
# Determine price change status for current price
|
||||
if current_price and len(price_history) > 1:
|
||||
status = await self.get_price_status(
|
||||
supplier_id, product_code, description, current_price, unit
|
||||
)
|
||||
price_change_status = status.status
|
||||
else:
|
||||
price_change_status = "no_history" if not price_history else "consistent"
|
||||
|
||||
return LineItemHistory(
|
||||
product_code=product_code,
|
||||
description=description,
|
||||
supplier_id=supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
price_history=price_history,
|
||||
total_occurrences=total_occurrences,
|
||||
total_quantity=total_qty,
|
||||
avg_qty_per_invoice=avg_qty_per_invoice,
|
||||
avg_qty_per_week=avg_qty_per_week,
|
||||
avg_qty_per_month=avg_qty_per_month,
|
||||
current_price=current_price,
|
||||
price_change_status=price_change_status
|
||||
)
|
||||
|
||||
async def acknowledge_price(
|
||||
self,
|
||||
user_id: int,
|
||||
supplier_id: int,
|
||||
product_code: Optional[str],
|
||||
description: Optional[str],
|
||||
new_price: Decimal,
|
||||
source_invoice_id: Optional[int] = None,
|
||||
source_line_item_id: Optional[int] = None,
|
||||
) -> AcknowledgedPrice:
|
||||
"""
|
||||
Acknowledge a price change for a product.
|
||||
|
||||
Creates or updates the AcknowledgedPrice record so the price
|
||||
won't be flagged in future.
|
||||
"""
|
||||
# Check if already exists
|
||||
existing = await self._get_acknowledged_price(
|
||||
supplier_id, product_code, description
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing record
|
||||
existing.acknowledged_price = new_price
|
||||
existing.acknowledged_at = datetime.utcnow()
|
||||
existing.acknowledged_by_user_id = user_id
|
||||
existing.source_invoice_id = source_invoice_id
|
||||
existing.source_line_item_id = source_line_item_id
|
||||
await self.db.commit()
|
||||
return existing
|
||||
else:
|
||||
# Create new record
|
||||
acknowledged = AcknowledgedPrice(
|
||||
kitchen_id=self.kitchen_id,
|
||||
supplier_id=supplier_id,
|
||||
product_code=product_code,
|
||||
description=description,
|
||||
acknowledged_price=new_price,
|
||||
acknowledged_by_user_id=user_id,
|
||||
source_invoice_id=source_invoice_id,
|
||||
source_line_item_id=source_line_item_id
|
||||
)
|
||||
self.db.add(acknowledged)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(acknowledged)
|
||||
return acknowledged
|
||||
|
||||
async def get_consolidated_line_items(
|
||||
self,
|
||||
search_query: Optional[str] = None,
|
||||
supplier_id: Optional[int] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Tuple[List[dict], int]:
|
||||
"""
|
||||
Get consolidated line items for search results.
|
||||
|
||||
Groups line items by (product_code OR description) + supplier,
|
||||
returning most recent price, total quantity, occurrence count, etc.
|
||||
|
||||
Returns:
|
||||
(list of consolidated items, total count)
|
||||
"""
|
||||
# Build base conditions — search all history unless dates explicitly provided
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
]
|
||||
|
||||
if date_from is not None:
|
||||
conditions.append(Invoice.invoice_date >= date_from)
|
||||
if date_to is not None:
|
||||
conditions.append(Invoice.invoice_date <= date_to)
|
||||
|
||||
if supplier_id:
|
||||
conditions.append(Invoice.supplier_id == supplier_id)
|
||||
|
||||
if search_query:
|
||||
# Split into words so "Cod Fillet" matches "COD: FILLET 1-2KG SCALED BONED"
|
||||
words = search_query.strip().split()
|
||||
if len(words) == 1:
|
||||
search_pattern = f"%{words[0]}%"
|
||||
conditions.append(or_(
|
||||
LineItem.product_code.ilike(search_pattern),
|
||||
LineItem.description.ilike(search_pattern)
|
||||
))
|
||||
else:
|
||||
# All words must appear in description (or exact phrase matches product_code)
|
||||
word_conditions = []
|
||||
for word in words:
|
||||
word_conditions.append(LineItem.description.ilike(f"%{word}%"))
|
||||
conditions.append(or_(
|
||||
and_(*word_conditions),
|
||||
LineItem.product_code.ilike(f"%{search_query}%")
|
||||
))
|
||||
|
||||
# Build query for consolidated items using subquery
|
||||
# We need to group by product identity and get aggregates
|
||||
|
||||
# First, get the consolidation key and aggregates
|
||||
from sqlalchemy import case, literal_column
|
||||
|
||||
# Create expression for first line of description (reuse this in SELECT and GROUP BY)
|
||||
desc_first_line = func.split_part(LineItem.description, '\n', 1)
|
||||
|
||||
# Create a composite key for grouping
|
||||
# Use first line of description only for grouping
|
||||
consolidation_key = func.concat(
|
||||
func.coalesce(LineItem.product_code, ''),
|
||||
'||',
|
||||
func.coalesce(desc_first_line, ''),
|
||||
'||',
|
||||
func.cast(Invoice.supplier_id, String)
|
||||
)
|
||||
|
||||
# Get aggregated data
|
||||
# Note: We don't group by unit since the same product may have slightly different
|
||||
# unit values across invoices. We'll pick the most recent unit in the detail query.
|
||||
agg_query = (
|
||||
select(
|
||||
LineItem.product_code,
|
||||
desc_first_line.label('description'),
|
||||
Invoice.supplier_id,
|
||||
Supplier.name.label('supplier_name'),
|
||||
func.sum(LineItem.quantity).label('total_quantity'),
|
||||
func.count(LineItem.id).label('occurrence_count'),
|
||||
func.max(Invoice.invoice_date).label('most_recent_date'),
|
||||
)
|
||||
.select_from(LineItem)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.join(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(and_(*conditions))
|
||||
.group_by(
|
||||
LineItem.product_code,
|
||||
desc_first_line,
|
||||
Invoice.supplier_id,
|
||||
Supplier.name,
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_subquery = agg_query.subquery()
|
||||
count_result = await self.db.execute(
|
||||
select(func.count()).select_from(count_subquery)
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Get paginated results
|
||||
agg_result = await self.db.execute(
|
||||
agg_query.order_by(desc('most_recent_date')).limit(limit).offset(offset)
|
||||
)
|
||||
rows = agg_result.fetchall()
|
||||
|
||||
# Build result list with additional data
|
||||
items = []
|
||||
for row in rows:
|
||||
product_code = row.product_code
|
||||
description = row.description
|
||||
supplier_id_val = row.supplier_id
|
||||
|
||||
# Get most recent price, invoice info, and unit
|
||||
# Skip credit notes and zero/negative prices (free replacements)
|
||||
recent_conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id_val,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
LineItem.product_code == product_code if product_code else LineItem.product_code.is_(None),
|
||||
]
|
||||
if description:
|
||||
recent_conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == description
|
||||
)
|
||||
|
||||
recent_query = (
|
||||
select(
|
||||
LineItem.unit_price,
|
||||
Invoice.id,
|
||||
Invoice.invoice_number,
|
||||
LineItem.unit,
|
||||
LineItem.id.label('line_item_id'),
|
||||
LineItem.line_number,
|
||||
LineItem.raw_content,
|
||||
LineItem.pack_quantity.label('li_pack_quantity'),
|
||||
LineItem.unit_size,
|
||||
LineItem.unit_size_type,
|
||||
)
|
||||
.where(and_(*recent_conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(1)
|
||||
)
|
||||
recent_result = await self.db.execute(recent_query)
|
||||
recent_row = recent_result.fetchone()
|
||||
|
||||
most_recent_price = recent_row[0] if recent_row else None
|
||||
most_recent_invoice_id = recent_row[1] if recent_row else None
|
||||
most_recent_invoice_number = recent_row[2] if recent_row else None
|
||||
unit = recent_row[3] if recent_row else None
|
||||
most_recent_line_item_id = recent_row[4] if recent_row else None
|
||||
most_recent_line_number = recent_row[5] if recent_row else None
|
||||
most_recent_raw_content = recent_row[6] if recent_row else None
|
||||
most_recent_pack_quantity = recent_row[7] if recent_row else None
|
||||
most_recent_unit_size = recent_row[8] if recent_row else None
|
||||
most_recent_unit_size_type = recent_row[9] if recent_row else None
|
||||
|
||||
# Get earliest price in period for change detection
|
||||
# Only when date range is provided (skip for undated searches like IngredientModal)
|
||||
price_change_percent = None
|
||||
price_change_status = "no_history"
|
||||
earliest_price = None
|
||||
|
||||
if date_from is not None and date_to is not None:
|
||||
# Match by first line of description only
|
||||
# Skip credit notes and zero/negative prices
|
||||
earliest_conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
LineItem.invoice_id == Invoice.id,
|
||||
Invoice.supplier_id == supplier_id_val,
|
||||
Invoice.invoice_date >= date_from,
|
||||
Invoice.invoice_date <= date_to,
|
||||
LineItem.unit_price.isnot(None),
|
||||
LineItem.unit_price > 0,
|
||||
or_(Invoice.document_type.is_(None), Invoice.document_type != 'credit_note'),
|
||||
LineItem.product_code == product_code if product_code else LineItem.product_code.is_(None),
|
||||
]
|
||||
if description:
|
||||
earliest_conditions.append(
|
||||
func.split_part(LineItem.description, '\n', 1) == description
|
||||
)
|
||||
|
||||
earliest_query = (
|
||||
select(LineItem.unit_price)
|
||||
.where(and_(*earliest_conditions))
|
||||
.order_by(Invoice.invoice_date)
|
||||
.limit(1)
|
||||
)
|
||||
earliest_result = await self.db.execute(earliest_query)
|
||||
earliest_row = earliest_result.fetchone()
|
||||
earliest_price = earliest_row[0] if earliest_row else None
|
||||
|
||||
if most_recent_price is not None and earliest_price is not None:
|
||||
if earliest_price != 0:
|
||||
price_change_percent = float(
|
||||
(most_recent_price - earliest_price) / earliest_price * 100
|
||||
)
|
||||
|
||||
# Get price status
|
||||
# Extend lookback to ensure we have history beyond the search period
|
||||
if most_recent_price:
|
||||
# Calculate extended lookback: search period + configured lookback days
|
||||
search_period_days = (date_to - date_from).days
|
||||
extended_lookback = search_period_days + 30 # Add configured lookback on top
|
||||
|
||||
status = await self.get_price_status(
|
||||
supplier_id_val, product_code, description, most_recent_price,
|
||||
unit=unit,
|
||||
lookback_days=extended_lookback,
|
||||
current_invoice_id=most_recent_invoice_id # Exclude most recent invoice from comparison
|
||||
)
|
||||
price_change_status = status.status
|
||||
|
||||
# Check if has definition
|
||||
from models.product_definition import ProductDefinition
|
||||
|
||||
# Build conditions for definition lookup
|
||||
def_conditions = [
|
||||
ProductDefinition.kitchen_id == self.kitchen_id,
|
||||
ProductDefinition.supplier_id == supplier_id_val,
|
||||
]
|
||||
|
||||
# Match by product_code (preferred) OR description (fallback), not both
|
||||
if product_code:
|
||||
# Exact match by product_code only
|
||||
def_conditions.append(ProductDefinition.product_code == product_code)
|
||||
else:
|
||||
# Match by description, but ONLY definitions without product_code
|
||||
def_conditions.append(ProductDefinition.product_code.is_(None))
|
||||
if description:
|
||||
def_conditions.append(ProductDefinition.description_pattern == description)
|
||||
|
||||
def_query = (
|
||||
select(ProductDefinition.portions_per_unit, ProductDefinition.pack_quantity)
|
||||
.where(and_(*def_conditions))
|
||||
.limit(1)
|
||||
)
|
||||
def_result = await self.db.execute(def_query)
|
||||
def_row = def_result.fetchone()
|
||||
|
||||
# Look up ingredient source mapping
|
||||
from models.ingredient import Ingredient, IngredientSource
|
||||
|
||||
src_conditions = [
|
||||
IngredientSource.kitchen_id == self.kitchen_id,
|
||||
IngredientSource.supplier_id == supplier_id_val,
|
||||
]
|
||||
|
||||
# Match by product_code (preferred) OR description_pattern (fallback)
|
||||
if product_code:
|
||||
src_conditions.append(IngredientSource.product_code == product_code)
|
||||
else:
|
||||
src_conditions.append(IngredientSource.product_code.is_(None))
|
||||
if description:
|
||||
src_conditions.append(func.lower(IngredientSource.description_pattern) == description.lower())
|
||||
|
||||
src_query = (
|
||||
select(
|
||||
IngredientSource.ingredient_id,
|
||||
Ingredient.name.label('ingredient_name'),
|
||||
Ingredient.standard_unit.label('ingredient_standard_unit'),
|
||||
IngredientSource.price_per_std_unit,
|
||||
)
|
||||
.join(Ingredient, IngredientSource.ingredient_id == Ingredient.id)
|
||||
.where(and_(*src_conditions))
|
||||
.limit(1)
|
||||
)
|
||||
src_result = await self.db.execute(src_query)
|
||||
src_row = src_result.fetchone()
|
||||
|
||||
items.append({
|
||||
'product_code': product_code,
|
||||
'description': description,
|
||||
'supplier_id': supplier_id_val,
|
||||
'supplier_name': row.supplier_name,
|
||||
'unit': unit,
|
||||
'most_recent_price': most_recent_price,
|
||||
'earliest_price_in_period': earliest_price,
|
||||
'price_change_percent': price_change_percent,
|
||||
'price_change_status': price_change_status,
|
||||
'total_quantity': row.total_quantity,
|
||||
'occurrence_count': row.occurrence_count,
|
||||
'most_recent_invoice_id': most_recent_invoice_id,
|
||||
'most_recent_invoice_number': most_recent_invoice_number,
|
||||
'most_recent_date': row.most_recent_date,
|
||||
'has_definition': def_row is not None,
|
||||
'portions_per_unit': def_row[0] if def_row else None,
|
||||
'pack_quantity': def_row[1] if def_row else None,
|
||||
'most_recent_line_item_id': most_recent_line_item_id,
|
||||
'most_recent_line_number': most_recent_line_number,
|
||||
'most_recent_raw_content': most_recent_raw_content,
|
||||
'most_recent_pack_quantity': most_recent_pack_quantity,
|
||||
'most_recent_unit_size': most_recent_unit_size,
|
||||
'most_recent_unit_size_type': most_recent_unit_size_type,
|
||||
'ingredient_id': src_row[0] if src_row else None,
|
||||
'ingredient_name': src_row[1] if src_row else None,
|
||||
'ingredient_standard_unit': src_row[2] if src_row else None,
|
||||
'price_per_std_unit': src_row[3] if src_row else None,
|
||||
})
|
||||
|
||||
return items, total_count
|
||||
|
||||
|
||||
# Import String for cast
|
||||
from sqlalchemy import String
|
||||
183
backend/services/resos_api.py
Normal file
183
backend/services/resos_api.py
Normal 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()
|
||||
1165
backend/services/resos_stats.py
Normal file
1165
backend/services/resos_stats.py
Normal file
File diff suppressed because it is too large
Load diff
864
backend/services/resos_sync.py
Normal file
864
backend/services/resos_sync.py
Normal 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
|
||||
}
|
||||
1220
backend/services/sambapos_api.py
Normal file
1220
backend/services/sambapos_api.py
Normal file
File diff suppressed because it is too large
Load diff
173
backend/services/stock_history.py
Normal file
173
backend/services/stock_history.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue