Files
xenia-pos-local/local_backend/services/cloud_sync.py
bonamin 80842d9be3 fix(connect): store cloud order id locally to enable reliable status mirroring
The Phase 4 _mirror_status_to_cloud function had no way to look up the
cloud order's numeric id once it was marked synced (not in the pending
list anymore), so status updates from local staff could silently fail.

Fix:
  - models/order.py: online_order_cloud_id INTEGER column added to Order
  - main.py: migration for the new column
  - schemas/order.py: online_order_cloud_id exposed in OrderOut
  - cloud_sync.py: stores cloud_order["id"] as online_order_cloud_id
    when creating the local order during the pull
  - connect_orders.py: _mirror_status_to_cloud now takes the integer
    cloud id directly — no more pending-list lookup; function body
    reduced from ~50 lines to ~15

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:14:43 +03:00

371 lines
14 KiB
Python

"""
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task.
Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure.
Lock behaviour:
- cloud sets locked=true → set lock_pending=true in state
- lock_pending is enforced at workday-close time (see business_day router)
- while a workday is open, the site keeps running; lock applies once it closes
License expiry behaviour:
- 5 days before expiry → warning only (days_until_expiry in state)
- on expiry → 5-day grace period begins (grace_expires_at in state)
- after grace + no open workday → licensed=False enforced by business_day router
"""
import asyncio
import json
import logging
import socket
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
from config import settings
from middleware.license_check import license_state
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
logger = logging.getLogger(__name__)
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
GRACE_HOURS = 72 # 3 days offline grace
EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking
EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning
STATE_FILE = Path(__file__).parent.parent / "license_state.json"
def _load_persisted_state():
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text())
license_state.update(data)
logger.info("Loaded persisted license state: %s", data)
except Exception as e:
logger.warning("Could not load license state file: %s", e)
def _persist_state():
try:
STATE_FILE.write_text(json.dumps(license_state))
except Exception as e:
logger.warning("Could not persist license state: %s", e)
def _compute_expiry_fields(expires_at_str: str | None) -> dict:
"""Return days_until_expiry and grace_expires_at derived from expires_at."""
if not expires_at_str:
return {"days_until_expiry": None, "grace_expires_at": None}
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except ValueError:
return {"days_until_expiry": None, "grace_expires_at": None}
now = datetime.now(timezone.utc)
days_until = (expires_at - now).days # negative once expired
grace_expires_at = None
if days_until < 0:
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat()
return {
"days_until_expiry": days_until,
"grace_expires_at": grace_expires_at,
}
def _get_local_ip() -> str | None:
"""Best-effort detection of the host machine's LAN IP address.
When running inside Docker the socket trick returns the container/bridge IP,
so we honour HOST_IP if it is explicitly provided via the environment."""
import os
if host_ip := os.environ.get("HOST_IP", "").strip():
return host_ip
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return None
async def _sync_once():
if not settings.SITE_ID or not settings.CLOUD_URL:
logger.debug("No SITE_ID/CLOUD_URL configured — skipping cloud sync")
return
try:
local_ip = _get_local_ip()
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/heartbeat/",
headers={
"X-Site-ID": settings.SITE_ID,
"X-Site-Key": settings.SITE_KEY,
},
json={"version": settings.VERSION, "uptime_seconds": 0, "local_ip": local_ip},
)
resp.raise_for_status()
data = resp.json()
licensed = data.get("licensed", True)
cloud_locked = data.get("locked", False)
expires_at = data.get("expires_at")
expiry_fields = _compute_expiry_fields(expires_at)
# If cloud says locked, check whether a workday is currently open.
# No open workday → lock immediately.
# Open workday → defer to workday close (business_day router enforces it).
if cloud_locked:
from database import SessionLocal
from models.business_day import BusinessDay
db = SessionLocal()
try:
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
finally:
db.close()
if open_day:
if not license_state.get("lock_pending"):
license_state["lock_pending"] = True
logger.info("Cloud requested lock — workday open, deferring to workday close")
else:
license_state["lock_pending"] = False
license_state["locked"] = True
logger.info("Cloud requested lock — no open workday, locking immediately")
# If cloud lifts the lock, clear pending too
if not cloud_locked:
license_state["lock_pending"] = False
license_state["locked"] = False
license_state.update({
"licensed": licensed,
"expires_at": expires_at,
"latest_version": data.get("latest_version"),
"waiter_domain": data.get("waiter_domain"),
"site_numeric_id": data.get("site_numeric_id"),
"last_sync": datetime.now(timezone.utc).isoformat(),
"sync_failed": False,
**expiry_fields,
})
_persist_state()
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
except Exception as e:
logger.warning("Cloud sync failed: %s", e)
license_state["sync_failed"] = True
last_sync_str = license_state.get("last_sync")
if last_sync_str:
try:
last_sync = datetime.fromisoformat(last_sync_str)
grace_expires = last_sync + timedelta(hours=GRACE_HOURS)
if datetime.now(timezone.utc) > grace_expires:
logger.error("72-hour offline grace period expired — marking unlicensed")
license_state["licensed"] = False
except ValueError:
pass
# Recompute expiry fields from cached expires_at even when offline
expiry_fields = _compute_expiry_fields(license_state.get("expires_at"))
license_state.update(expiry_fields)
async def _push_menu_snapshot():
"""Serialize all digital-visible products+categories and POST to cloud."""
if not settings.SITE_ID or not settings.CLOUD_URL:
return
try:
from database import SessionLocal
from models.product import Category, Product
db = SessionLocal()
try:
categories = db.query(Category).filter(Category.parent_id == None).all()
payload_categories = []
for cat in categories:
products = (
db.query(Product)
.filter(
Product.category_id == cat.id,
Product.digital_visible == 1,
Product.lifecycle_status == "active",
)
.order_by(Product.sort_order)
.all()
)
product_list = []
for p in products:
product_list.append({
"id": p.id,
"name": p.name,
"digital_name": p.digital_name,
"digital_description": p.digital_description,
"digital_price": p.digital_price,
"base_price": p.base_price,
"digital_discount": p.digital_discount,
"digital_available": bool(p.digital_available),
"digital_image_url": p.digital_image_url,
"image_url": p.image_url,
"quick_options": [
{"id": o.id, "name": o.name, "price": o.price}
for o in p.quick_options
],
})
if product_list:
payload_categories.append({
"id": cat.id,
"name": cat.name,
"sort_order": cat.sort_order,
"products": product_list,
})
finally:
db.close()
snapshot_json = json.dumps({"categories": payload_categories})
# Resolve numeric site_id from license state (set by heartbeat response)
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
logger.debug("Menu push skipped — site_numeric_id not yet known")
return
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/menu/sync",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
json={"site_id": site_numeric_id, "snapshot_json": snapshot_json},
)
resp.raise_for_status()
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
except Exception as e:
logger.warning("Menu snapshot push failed: %s", e)
async def _pull_pending_orders():
"""Fetch online orders from cloud that haven't been synced to local yet."""
if not settings.SITE_ID or not settings.CLOUD_URL:
return
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
return
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{settings.CLOUD_URL}/api/orders/pending/{site_numeric_id}",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
)
resp.raise_for_status()
orders = resp.json()
if not orders:
return
from database import SessionLocal
from models.order import Order, OrderItem
from models.product import Product
from services.sse_bus import broadcast_sync
db = SessionLocal()
try:
# Use a system user id=1 (first manager/sysadmin) as the opener
from models.user import User
system_user = db.query(User).filter(User.role.in_(["sysadmin", "manager"])).first()
opener_id = system_user.id if system_user else 1
for cloud_order in orders:
# Create local Order row — no table_id for online orders
local_order = Order(
table_id=None,
opened_by=opener_id,
status="open",
source="online",
online_order_ref=cloud_order["public_ref"],
online_order_cloud_id=cloud_order["id"],
online_status="pending_acceptance",
online_customer_name=cloud_order.get("customer_name"),
online_customer_phone=cloud_order.get("customer_phone"),
online_customer_address=cloud_order.get("customer_address"),
online_customer_notes=cloud_order.get("customer_notes"),
online_order_type=cloud_order.get("order_type"),
)
db.add(local_order)
db.flush() # get local_order.id
# Create OrderItem rows
items = json.loads(cloud_order.get("items_json", "[]"))
for item in items:
product = db.query(Product).filter(
Product.id == item.get("product_id")
).first()
db.add(OrderItem(
order_id=local_order.id,
product_id=item.get("product_id"),
added_by=opener_id,
quantity=item.get("quantity", 1),
unit_price=item.get("unit_price", 0.0),
selected_options=json.dumps(item.get("options", [])),
status="active",
printed=False,
))
db.commit()
# Mark synced on cloud
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
f"{settings.CLOUD_URL}/api/orders/{cloud_order['id']}/synced",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
json={"local_order_id": local_order.id},
)
logger.info("Online order %s pulled and created as local order %d",
cloud_order["public_ref"], local_order.id)
db.close()
# Broadcast SSE so the manager dashboard lights up
broadcast_sync("online_order_received", {"count": len(orders)})
except Exception as e:
db.rollback()
db.close()
logger.error("Failed to process pulled orders: %s", e)
except Exception as e:
logger.warning("Order pull failed: %s", e)
async def _sync_loop():
_load_persisted_state()
while True:
await _sync_once()
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
async def _connect_loop():
"""Faster loop: pulls pending online orders every CONNECT_SYNC_INTERVAL_SECONDS.
Also pushes the menu snapshot every 5 minutes (piggybacking on this loop)."""
menu_push_every = max(1, SYNC_INTERVAL_SECONDS and (5 * 60) // ORDER_POLL_INTERVAL)
tick = 0
while True:
await asyncio.sleep(ORDER_POLL_INTERVAL)
await _pull_pending_orders()
tick += 1
if tick % menu_push_every == 0:
await _push_menu_snapshot()
async def start_cloud_sync() -> asyncio.Task:
heartbeat_task = asyncio.create_task(_sync_loop())
asyncio.create_task(_connect_loop())
return heartbeat_task