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>
352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""
|
|
Background Scheduler for Daily Sync Jobs
|
|
|
|
Uses APScheduler for reliable scheduled task execution.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from sqlalchemy import select
|
|
|
|
from database import AsyncSessionLocal
|
|
from models.settings import KitchenSettings
|
|
from services.newbook_sync import NewbookSyncService
|
|
from services.resos_sync import ResosSyncService
|
|
from services.imap_sync import ImapSyncService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
scheduler = AsyncIOScheduler()
|
|
|
|
|
|
async def run_daily_newbook_sync():
|
|
"""
|
|
Daily sync job that runs for all kitchens with auto-sync enabled.
|
|
Scheduled to run at 4:00 AM server time.
|
|
"""
|
|
logger.info("Starting daily Newbook sync job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Get all kitchens with auto-sync enabled
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.newbook_auto_sync_enabled == True,
|
|
KitchenSettings.newbook_api_key.isnot(None)
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with auto-sync enabled")
|
|
|
|
for settings in settings_list:
|
|
try:
|
|
sync_service = NewbookSyncService(db, settings.kitchen_id)
|
|
results = await sync_service.run_daily_sync()
|
|
logger.info(f"Kitchen {settings.kitchen_id} sync completed: {results}")
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} sync failed: {e}")
|
|
# Continue with other kitchens
|
|
|
|
|
|
async def run_daily_resos_sync():
|
|
"""
|
|
Daily Resos sync job that runs for all kitchens with auto-sync enabled.
|
|
Scheduled to run at 4:30 AM server time.
|
|
"""
|
|
logger.info("Starting daily Resos sync job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Get all kitchens with Resos auto-sync enabled
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.resos_auto_sync_enabled == True,
|
|
KitchenSettings.resos_api_key.isnot(None)
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with Resos auto-sync enabled")
|
|
|
|
for settings in settings_list:
|
|
try:
|
|
sync_service = ResosSyncService(settings.kitchen_id, db)
|
|
results = await sync_service.run_daily_sync()
|
|
logger.info(f"Kitchen {settings.kitchen_id} Resos sync completed: {results}")
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} Resos sync failed: {e}")
|
|
# Continue with other kitchens
|
|
|
|
|
|
async def run_upcoming_newbook_sync():
|
|
"""
|
|
Upcoming Newbook sync job (next 7 days) that runs more frequently.
|
|
Interval configured per kitchen in settings (default 15 minutes).
|
|
Keeps ResidentsTableChart and forecast data fresh.
|
|
"""
|
|
logger.info("Starting upcoming Newbook sync job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# First, log all kitchens and their sync status for debugging
|
|
all_settings = await db.execute(select(KitchenSettings))
|
|
all_list = all_settings.scalars().all()
|
|
for s in all_list:
|
|
logger.debug(f"Kitchen {s.kitchen_id}: upcoming_sync_enabled={s.newbook_upcoming_sync_enabled}, api_key_set={bool(s.newbook_api_key)}")
|
|
|
|
# Get all kitchens with upcoming sync enabled
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.newbook_upcoming_sync_enabled == True,
|
|
KitchenSettings.newbook_api_key.isnot(None)
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with upcoming Newbook sync enabled")
|
|
|
|
for settings in settings_list:
|
|
try:
|
|
sync_service = NewbookSyncService(db, settings.kitchen_id)
|
|
results = await sync_service.run_upcoming_sync()
|
|
logger.info(f"Kitchen {settings.kitchen_id} upcoming Newbook sync completed: {results}")
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} upcoming Newbook sync failed: {e}")
|
|
# Continue with other kitchens
|
|
|
|
|
|
async def run_upcoming_resos_sync():
|
|
"""
|
|
Upcoming Resos sync job (next 7 days) that runs more frequently.
|
|
Interval configured per kitchen in settings (default 15 minutes).
|
|
"""
|
|
logger.info("Starting upcoming Resos sync job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Get all kitchens with upcoming sync enabled
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.resos_upcoming_sync_enabled == True,
|
|
KitchenSettings.resos_api_key.isnot(None)
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with upcoming Resos sync enabled")
|
|
|
|
for settings in settings_list:
|
|
try:
|
|
sync_service = ResosSyncService(settings.kitchen_id, db)
|
|
results = await sync_service.run_upcoming_sync()
|
|
logger.info(f"Kitchen {settings.kitchen_id} upcoming Resos sync completed: {results}")
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} upcoming Resos sync failed: {e}")
|
|
# Continue with other kitchens
|
|
|
|
|
|
async def run_imap_inbox_sync():
|
|
"""
|
|
IMAP email inbox sync job that runs for all kitchens with IMAP enabled.
|
|
Runs every 15 minutes by default - polls configured email accounts for
|
|
invoice attachments and processes them through the OCR pipeline.
|
|
"""
|
|
logger.info("Starting IMAP inbox sync job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Get all kitchens with IMAP enabled
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.imap_enabled == True,
|
|
KitchenSettings.imap_host.isnot(None),
|
|
KitchenSettings.imap_password.isnot(None)
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with IMAP enabled")
|
|
|
|
for settings in settings_list:
|
|
try:
|
|
sync_service = ImapSyncService(settings.kitchen_id, db)
|
|
results = await sync_service.process_inbox()
|
|
logger.info(
|
|
f"Kitchen {settings.kitchen_id} IMAP sync completed: "
|
|
f"{results['emails_processed']} emails, "
|
|
f"{results['invoices_created']} invoices created"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} IMAP sync failed: {e}")
|
|
# Continue with other kitchens
|
|
|
|
|
|
async def run_scheduled_backup():
|
|
"""
|
|
Run scheduled backups for all kitchens with auto-backup enabled.
|
|
Scheduled to run at 3:00 AM server time.
|
|
"""
|
|
from services.backup_service import BackupService
|
|
|
|
logger.info("Starting scheduled backup job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Get all kitchens with backup frequency set
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.backup_frequency.in_(["daily", "weekly"])
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with scheduled backup")
|
|
|
|
for settings in settings_list:
|
|
# Check if it's time to run based on frequency
|
|
should_run = False
|
|
|
|
if settings.backup_frequency == "daily":
|
|
should_run = True
|
|
elif settings.backup_frequency == "weekly":
|
|
# Run on Sundays (weekday 6)
|
|
should_run = datetime.utcnow().weekday() == 6
|
|
|
|
if should_run:
|
|
try:
|
|
backup_service = BackupService(db, settings.kitchen_id)
|
|
success, msg, _ = await backup_service.create_backup(
|
|
backup_type="scheduled"
|
|
)
|
|
logger.info(f"Kitchen {settings.kitchen_id} backup: {msg}")
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} backup failed: {e}")
|
|
|
|
|
|
async def run_file_archival():
|
|
"""
|
|
Archive eligible invoice files to Nextcloud.
|
|
Runs at 3:30 AM, after backups to ensure local files are backed up first.
|
|
"""
|
|
from services.file_archival_service import FileArchivalService
|
|
from models.invoice import Invoice, InvoiceStatus
|
|
|
|
logger.info("Starting file archival job")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Get kitchens with Nextcloud enabled
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.nextcloud_enabled == True
|
|
)
|
|
)
|
|
settings_list = result.scalars().all()
|
|
|
|
logger.info(f"Found {len(settings_list)} kitchens with Nextcloud enabled")
|
|
|
|
for settings in settings_list:
|
|
try:
|
|
archival_service = FileArchivalService(db, settings.kitchen_id)
|
|
|
|
# Get confirmed invoices still local
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.kitchen_id == settings.kitchen_id,
|
|
Invoice.status == InvoiceStatus.CONFIRMED,
|
|
Invoice.file_storage_location == "local"
|
|
)
|
|
)
|
|
invoices = result.scalars().all()
|
|
|
|
archived_count = 0
|
|
for invoice in invoices:
|
|
if await archival_service.is_ready_for_archival(invoice):
|
|
try:
|
|
success, msg = await archival_service.archive_invoice_file(invoice)
|
|
if success:
|
|
archived_count += 1
|
|
except Exception as e:
|
|
logger.warning(f"Failed to archive invoice {invoice.id}: {e}")
|
|
|
|
if archived_count > 0:
|
|
logger.info(f"Kitchen {settings.kitchen_id}: Archived {archived_count} invoices to Nextcloud")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Kitchen {settings.kitchen_id} archival failed: {e}")
|
|
|
|
|
|
def start_scheduler():
|
|
"""Initialize and start the scheduler"""
|
|
# Backup at 3:00 AM
|
|
scheduler.add_job(
|
|
run_scheduled_backup,
|
|
CronTrigger(hour=3, minute=0),
|
|
id="daily_backup",
|
|
name="Daily Backup",
|
|
replace_existing=True
|
|
)
|
|
|
|
# File archival at 3:30 AM (after backup to ensure local files are backed up first)
|
|
scheduler.add_job(
|
|
run_file_archival,
|
|
CronTrigger(hour=3, minute=30),
|
|
id="file_archival",
|
|
name="File Archival to Nextcloud",
|
|
replace_existing=True
|
|
)
|
|
|
|
# Daily sync at 4:00 AM
|
|
scheduler.add_job(
|
|
run_daily_newbook_sync,
|
|
CronTrigger(hour=4, minute=0),
|
|
id="daily_newbook_sync",
|
|
name="Daily Newbook Data Sync",
|
|
replace_existing=True
|
|
)
|
|
|
|
# Daily Resos sync at 4:30 AM
|
|
scheduler.add_job(
|
|
run_daily_resos_sync,
|
|
CronTrigger(hour=4, minute=30),
|
|
id="daily_resos_sync",
|
|
name="Daily Resos Booking Data Sync",
|
|
replace_existing=True
|
|
)
|
|
|
|
# Upcoming Newbook sync (next 7 days) - runs every 15 minutes by default
|
|
# Note: The interval is configured per kitchen in settings
|
|
scheduler.add_job(
|
|
run_upcoming_newbook_sync,
|
|
IntervalTrigger(minutes=15),
|
|
id="upcoming_newbook_sync",
|
|
name="Upcoming Newbook Sync (Next 7 Days)",
|
|
replace_existing=True
|
|
)
|
|
|
|
# Upcoming Resos sync (next 7 days) - runs every 15 minutes by default
|
|
# Note: The interval is configured per kitchen in settings
|
|
scheduler.add_job(
|
|
run_upcoming_resos_sync,
|
|
IntervalTrigger(minutes=15),
|
|
id="upcoming_resos_sync",
|
|
name="Upcoming Resos Sync (Next 7 Days)",
|
|
replace_existing=True
|
|
)
|
|
|
|
# IMAP email inbox sync - runs every 15 minutes
|
|
# Polls configured email accounts for invoice attachments
|
|
scheduler.add_job(
|
|
run_imap_inbox_sync,
|
|
IntervalTrigger(minutes=15),
|
|
id="imap_inbox_sync",
|
|
name="IMAP Email Inbox Sync",
|
|
replace_existing=True
|
|
)
|
|
|
|
scheduler.start()
|
|
logger.info("Scheduler started - backup at 3:00 AM, archival at 3:30 AM, Newbook sync at 4:00 AM, Resos sync at 4:30 AM, Upcoming syncs every 15 min, IMAP sync every 15 min")
|
|
|
|
|
|
def stop_scheduler():
|
|
"""Shutdown the scheduler"""
|
|
if scheduler.running:
|
|
scheduler.shutdown(wait=False)
|
|
logger.info("Scheduler stopped")
|