feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
88
local_backend/services/chat_service.py
Normal file
88
local_backend/services/chat_service.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Chat service — system group bootstrap helpers.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.chat import Conversation, ConversationParticipant
|
||||
from models.user import User
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_system_group(db: Session) -> Conversation:
|
||||
"""
|
||||
Guarantee that the single system-wide 'Ομάδα' group conversation exists.
|
||||
If it is missing, create it and add all currently active users as participants.
|
||||
Returns the (possibly freshly created) Conversation.
|
||||
"""
|
||||
existing = (
|
||||
db.query(Conversation)
|
||||
.filter(Conversation.is_system == True) # noqa: E712
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Determine creator — first admin/manager user, or user id=1 as fallback
|
||||
creator = (
|
||||
db.query(User)
|
||||
.filter(User.perm_access_dashboard == True, User.is_active == True) # noqa: E712
|
||||
.order_by(User.id)
|
||||
.first()
|
||||
)
|
||||
creator_id = creator.id if creator else 1
|
||||
|
||||
conv = Conversation(
|
||||
type="group",
|
||||
name="Ομάδα",
|
||||
is_system=True,
|
||||
created_by=creator_id,
|
||||
)
|
||||
db.add(conv)
|
||||
db.flush() # get conv.id before adding participants
|
||||
|
||||
active_users = (
|
||||
db.query(User).filter(User.is_active == True).all() # noqa: E712
|
||||
)
|
||||
now = _utcnow()
|
||||
for u in active_users:
|
||||
participant = ConversationParticipant(
|
||||
conversation_id=conv.id,
|
||||
user_id=u.id,
|
||||
joined_at=now,
|
||||
)
|
||||
db.add(participant)
|
||||
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
def add_user_to_system_group(db: Session, user_id: int) -> None:
|
||||
"""
|
||||
Add a user to the system group if not already a participant.
|
||||
Safe to call even when the system group does not exist yet (it will be created).
|
||||
"""
|
||||
conv = ensure_system_group(db)
|
||||
|
||||
already = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv.id,
|
||||
ConversationParticipant.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if already:
|
||||
return
|
||||
|
||||
participant = ConversationParticipant(
|
||||
conversation_id=conv.id,
|
||||
user_id=user_id,
|
||||
joined_at=_utcnow(),
|
||||
)
|
||||
db.add(participant)
|
||||
db.commit()
|
||||
@@ -279,7 +279,7 @@ async def _pull_pending_orders():
|
||||
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()
|
||||
system_user = db.query(User).filter(User.perm_access_dashboard == True).first()
|
||||
opener_id = system_user.id if system_user else 1
|
||||
|
||||
for cloud_order in orders:
|
||||
|
||||
280
local_backend/services/fiscal_service.py
Normal file
280
local_backend/services/fiscal_service.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Fiscal printer service for dTEC100extra ΦΗΜ machines.
|
||||
|
||||
Protocol: plain TXT files dropped into an OUT folder. The fiscal driver reads
|
||||
them, processes the receipt, then writes a reply into the IN folder.
|
||||
Files must be encoded in CP737 (Greek) with CRLF line endings.
|
||||
All item text must be UPPER CASE.
|
||||
|
||||
File naming: fp-YYMMDD-HHMM-NNNNNNN.txt (NNNNNNN = zero-padded counter)
|
||||
The driver deletes the command file after reading it and will never re-read the
|
||||
same filename, so uniqueness is critical.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared counter — persisted only in-process; restarts reset to 0 but the
|
||||
# date+time prefix keeps filenames unique across restarts.
|
||||
_counter_lock = threading.Lock()
|
||||
_counter = 0
|
||||
|
||||
|
||||
def _next_counter() -> int:
|
||||
global _counter
|
||||
with _counter_lock:
|
||||
_counter += 1
|
||||
return _counter
|
||||
|
||||
|
||||
def _unique_filename() -> str:
|
||||
now = datetime.now()
|
||||
date_part = now.strftime("%y%m%d")
|
||||
time_part = now.strftime("%H%M")
|
||||
seq = _next_counter()
|
||||
return f"fp-{date_part}-{time_part}-{seq:07d}.txt"
|
||||
|
||||
|
||||
def _get_folders(db) -> tuple[str, str]:
|
||||
"""Return (out_folder, in_folder) from pos_settings. Raises if not configured."""
|
||||
from models.settings import PosSettings
|
||||
rows = {
|
||||
r.key: r.value
|
||||
for r in db.query(PosSettings).filter(
|
||||
PosSettings.key.in_(["fiscal.out_folder", "fiscal.in_folder"])
|
||||
).all()
|
||||
}
|
||||
out_folder = rows.get("fiscal.out_folder", "").strip()
|
||||
in_folder = rows.get("fiscal.in_folder", "").strip()
|
||||
if not out_folder or not in_folder:
|
||||
raise ValueError("Fiscal folder paths are not configured")
|
||||
return out_folder, in_folder
|
||||
|
||||
|
||||
def _get_setting(db, key: str, default: str = "") -> str:
|
||||
from models.settings import PosSettings
|
||||
row = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def is_fiscal_enabled(db) -> bool:
|
||||
return _get_setting(db, "fiscal.enabled", "false") == "true"
|
||||
|
||||
|
||||
def _build_command_file(items: list[dict], payment_method: str, total: float, db) -> str:
|
||||
"""
|
||||
Build the fiscal command file content as a string.
|
||||
|
||||
items: list of {"name": str, "qty": int|float, "unit_price": float, "vat_group_id": int}
|
||||
payment_method: "cash" | "card"
|
||||
total: sum of all items (already computed by caller)
|
||||
"""
|
||||
clerk_id = _get_setting(db, "fiscal.clerk_id", "2")
|
||||
eftpos_id = _get_setting(db, "fiscal.eftpos_id", "1")
|
||||
end_message_raw = _get_setting(db, "fiscal.end_message", "[]")
|
||||
try:
|
||||
end_lines: list[str] = json.loads(end_message_raw)
|
||||
except Exception:
|
||||
end_lines = []
|
||||
|
||||
lines = ["FR"]
|
||||
|
||||
for item in items:
|
||||
name = (item["name"] or "").upper().strip()
|
||||
qty = item["qty"]
|
||||
price = item["unit_price"]
|
||||
vat = item["vat_group_id"]
|
||||
# Quantity: integer if whole, otherwise 3 decimal places (kg/liter support)
|
||||
if isinstance(qty, float) and qty != int(qty):
|
||||
qty_str = f"{qty:.3f}"
|
||||
else:
|
||||
qty_str = str(int(qty))
|
||||
price_str = f"{price:.2f}"
|
||||
lines.append(f"SI|{name}|{qty_str}|{price_str}|{vat}")
|
||||
|
||||
# End message lines (FM commands go BEFORE the close command)
|
||||
for i, msg_line in enumerate(end_lines, start=1):
|
||||
if msg_line.strip():
|
||||
lines.append(f"FM|{i}|{msg_line.upper()}")
|
||||
|
||||
if payment_method == "card":
|
||||
total_str = f"{total:.2f}"
|
||||
lines.append(f"CD|{clerk_id}|{eftpos_id}|1|{total_str}")
|
||||
else:
|
||||
lines.append(f"CR|{clerk_id}")
|
||||
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
def _write_and_poll(items: list[dict], payment_method: str, total: float,
|
||||
out_folder: str, in_folder: str, content: str,
|
||||
filename: str, timeout_seconds: int) -> dict:
|
||||
"""Write the command file and poll for a reply. Pure logic, no DB access."""
|
||||
out_path = os.path.join(out_folder, filename)
|
||||
reply_filename = f"answer{filename}"
|
||||
in_path = os.path.join(in_folder, reply_filename)
|
||||
|
||||
try:
|
||||
with open(out_path, "w", encoding="cp737", errors="replace") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
return {"success": False, "filename": filename, "reply": None,
|
||||
"error": f"Failed to write fiscal file: {e}"}
|
||||
|
||||
logger.info("Fiscal: wrote %s (%d bytes)", out_path, len(content))
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
reply_content = None
|
||||
while time.monotonic() < deadline:
|
||||
if os.path.exists(in_path):
|
||||
try:
|
||||
with open(in_path, "r", encoding="cp737", errors="replace") as f:
|
||||
reply_content = f.read().strip()
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
if reply_content is None:
|
||||
return {"success": False, "filename": filename, "reply": None,
|
||||
"error": f"Fiscal timeout: no reply after {timeout_seconds}s"}
|
||||
|
||||
first_line = reply_content.splitlines()[0].strip() if reply_content else "1"
|
||||
success = first_line == "0"
|
||||
|
||||
logger.info("Fiscal: reply for %s — success=%s content=%r", filename, success, reply_content)
|
||||
return {"success": success, "filename": filename, "reply": reply_content,
|
||||
"error": None if success else f"ΦΗΜ error: {reply_content}"}
|
||||
|
||||
|
||||
def send_fiscal_receipt(items: list[dict], payment_method: str, total: float, db,
|
||||
timeout_seconds: int = 30) -> dict:
|
||||
"""
|
||||
Build and send a fiscal receipt, then block waiting for the reply.
|
||||
Returns: {"success": bool, "filename": str, "reply": str | None, "error": str | None}
|
||||
"""
|
||||
out_folder, in_folder = _get_folders(db)
|
||||
os.makedirs(out_folder, exist_ok=True)
|
||||
os.makedirs(in_folder, exist_ok=True)
|
||||
filename = _unique_filename()
|
||||
content = _build_command_file(items, payment_method, total, db)
|
||||
return _write_and_poll(items, payment_method, total, out_folder, in_folder,
|
||||
content, filename, timeout_seconds)
|
||||
|
||||
|
||||
def send_fiscal_receipt_background(items: list[dict], payment_method: str, total: float,
|
||||
db, order_id: int, db_factory,
|
||||
timeout_seconds: int = 60) -> None:
|
||||
"""
|
||||
Write the fiscal file and poll for the reply in a background thread.
|
||||
Updates order.fiscal_status in the DB when done (success or failure/timeout).
|
||||
|
||||
db_factory: a callable () -> Session (e.g. SessionLocal) so the thread can
|
||||
open its own DB session independently of the request session.
|
||||
"""
|
||||
try:
|
||||
out_folder, in_folder = _get_folders(db)
|
||||
os.makedirs(out_folder, exist_ok=True)
|
||||
os.makedirs(in_folder, exist_ok=True)
|
||||
filename = _unique_filename()
|
||||
content = _build_command_file(items, payment_method, total, db)
|
||||
except Exception as e:
|
||||
logger.error("Fiscal background setup failed for order %s: %s", order_id, e)
|
||||
_update_fiscal_status(order_id, "failed", db_factory)
|
||||
return
|
||||
|
||||
def _run():
|
||||
result = _write_and_poll(items, payment_method, total, out_folder, in_folder,
|
||||
content, filename, timeout_seconds)
|
||||
status = "success" if result["success"] else "failed"
|
||||
_update_fiscal_status(order_id, status, db_factory)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _update_fiscal_status(order_id: int, status: str, db_factory) -> None:
|
||||
"""Open a fresh DB session and set order.fiscal_status."""
|
||||
try:
|
||||
session = db_factory()
|
||||
try:
|
||||
from models.order import Order
|
||||
order = session.query(Order).filter(Order.id == order_id).first()
|
||||
if order:
|
||||
order.fiscal_status = status
|
||||
session.commit()
|
||||
logger.info("Fiscal: order %s fiscal_status → %s", order_id, status)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.error("Fiscal: failed to update fiscal_status for order %s: %s", order_id, e)
|
||||
|
||||
|
||||
def validate_items_for_fiscal(order_items, db) -> list[str]:
|
||||
"""
|
||||
Check that every non-cancelled item in the list has a fiscal_vat_group_id.
|
||||
Returns a list of product names that are missing it (empty = all OK).
|
||||
"""
|
||||
missing = []
|
||||
for item in order_items:
|
||||
product = item.product
|
||||
if product is None:
|
||||
missing.append(f"#product_{item.product_id}")
|
||||
continue
|
||||
vat_group = getattr(product, "fiscal_vat_group_id", None)
|
||||
if not vat_group:
|
||||
missing.append(product.name)
|
||||
return missing
|
||||
|
||||
|
||||
def build_fiscal_items(order_items) -> list[dict]:
|
||||
"""
|
||||
Convert a list of OrderItem objects into the dicts that send_fiscal_receipt expects.
|
||||
Uses fiscal_name if set, else falls back to product.name.
|
||||
Applies price_adjustment to the unit price.
|
||||
"""
|
||||
result = []
|
||||
for item in order_items:
|
||||
product = item.product
|
||||
name = (getattr(product, "fiscal_name", None) or "").strip() or (product.name if product else f"#{item.product_id}")
|
||||
adj = getattr(item, "price_adjustment", 0.0) or 0.0
|
||||
unit_price = item.unit_price + adj
|
||||
result.append({
|
||||
"name": name,
|
||||
"qty": item.quantity,
|
||||
"unit_price": unit_price,
|
||||
"vat_group_id": product.fiscal_vat_group_id,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def test_folders(out_folder: str, in_folder: str) -> dict:
|
||||
"""
|
||||
Verify the two folders are accessible (read + write).
|
||||
Returns {"ok": bool, "out_folder": str|None, "in_folder": str|None}
|
||||
where each value is None if accessible, or an error string if not.
|
||||
"""
|
||||
def check(path):
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
test_file = os.path.join(path, ".fiscal_test")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test")
|
||||
os.remove(test_file)
|
||||
return None
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
out_err = check(out_folder)
|
||||
in_err = check(in_folder)
|
||||
return {
|
||||
"ok": out_err is None and in_err is None,
|
||||
"out_folder_error": out_err,
|
||||
"in_folder_error": in_err,
|
||||
}
|
||||
727
local_backend/services/pricing.py
Normal file
727
local_backend/services/pricing.py
Normal file
@@ -0,0 +1,727 @@
|
||||
"""
|
||||
Pricing resolution engine.
|
||||
|
||||
resolve_price() is called once per OrderItem at add-to-order time.
|
||||
The result is the final snapshotted unit_price — it never changes after that.
|
||||
|
||||
Stacking algorithm (per design spec):
|
||||
1. Collect all passing modifiers for this product (item-scoped first, then global).
|
||||
2. Separate into stackable (allow_stack=1) and non-stackable piles.
|
||||
3. From the non-stackable pile, apply only the first passing one (lowest sort_order).
|
||||
4. From the stackable pile, apply ALL passing ones in sort_order order,
|
||||
each on the running price result.
|
||||
5. Apply system-wide rounding to nearest 0.10 as a final pass.
|
||||
|
||||
Deal evaluation:
|
||||
evaluate_deals() is called after items are added. It returns a list of DealOffer
|
||||
objects that the API caller surfaces to the waiter as prompts.
|
||||
"""
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, time as dtime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from tz import to_local
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures (no ORM dependency — safe to import anywhere)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class OrderContext:
|
||||
"""Snapshot of order-level facts needed to evaluate conditions."""
|
||||
channel: str # 'pos' | 'online' | 'qr' | 'takeaway'
|
||||
now: datetime # current UTC time
|
||||
cart_items: list # list of {product_id, category_id, quantity, unit_price}
|
||||
active_price_group_ids: set # set of PriceGroup.id that are currently active
|
||||
user_tier: Optional[str] = None # 'bronze'|'silver'|'gold' — placeholder
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceEvent:
|
||||
"""In-memory representation of a price event, before it's written to DB."""
|
||||
order_id: int
|
||||
order_item_id: Optional[int] # set after OrderItem is committed
|
||||
event_type: str
|
||||
modifier_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
price_before: Optional[float] = None
|
||||
price_after: Optional[float] = None
|
||||
delta_amount: Optional[float] = None
|
||||
applied_by_user_id: Optional[int] = None
|
||||
waiter_note: Optional[str] = None
|
||||
conditions_snapshot: Optional[dict] = None
|
||||
selected_item_ids: Optional[list] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DealOffer:
|
||||
"""Returned to the API when a deal is triggered and needs waiter confirmation."""
|
||||
deal_id: int
|
||||
deal_name: str
|
||||
action_type: str # mirrors Deal.action_type
|
||||
# For free_item: the single product to add
|
||||
free_item_id: Optional[int] = None
|
||||
free_item_name: Optional[str] = None
|
||||
# For free_choice: pool of selectable products
|
||||
free_choices: list = field(default_factory=list) # [{id, name}]
|
||||
free_quantity: int = 1
|
||||
# For price-modifying deals applied to a specific item
|
||||
target_order_item_id: Optional[int] = None
|
||||
price_before: Optional[float] = None
|
||||
price_after: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition evaluators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _eval_condition(ctype: str, params: dict, ctx: OrderContext,
|
||||
product_id: int, quantity: float) -> tuple[bool, dict]:
|
||||
"""
|
||||
Evaluate one condition. Returns (passed, snapshot_dict).
|
||||
snapshot_dict records the actual runtime value — written to price_event_log for audit.
|
||||
Unknown or placeholder conditions pass silently (forward-compatible).
|
||||
"""
|
||||
now_local = to_local(ctx.now)
|
||||
current_time = now_local.time()
|
||||
current_date = now_local.date()
|
||||
current_weekday = now_local.weekday() # Mon=0 Sun=6
|
||||
|
||||
if ctype == "time_range":
|
||||
t_from = dtime.fromisoformat(params["from"])
|
||||
t_to = dtime.fromisoformat(params["to"])
|
||||
if t_from <= t_to:
|
||||
passed = t_from <= current_time <= t_to
|
||||
else:
|
||||
# Crosses midnight
|
||||
passed = current_time >= t_from or current_time <= t_to
|
||||
return passed, {"time_range": current_time.strftime("%H:%M")}
|
||||
|
||||
if ctype == "date_range":
|
||||
from datetime import date
|
||||
d_from = date.fromisoformat(params["from"])
|
||||
d_to = date.fromisoformat(params["to"])
|
||||
passed = d_from <= current_date <= d_to
|
||||
return passed, {"date_range": current_date.isoformat()}
|
||||
|
||||
if ctype == "specific_date":
|
||||
from datetime import date
|
||||
passed = current_date.isoformat() in params.get("dates", [])
|
||||
return passed, {"specific_date": current_date.isoformat()}
|
||||
|
||||
if ctype == "day_of_week":
|
||||
passed = current_weekday in params.get("days", [])
|
||||
return passed, {"day_of_week": current_weekday}
|
||||
|
||||
if ctype == "min_item_quantity":
|
||||
cart_qty = sum(
|
||||
ci["quantity"] for ci in ctx.cart_items
|
||||
if ci["product_id"] == product_id
|
||||
)
|
||||
passed = cart_qty >= params.get("min", 1)
|
||||
return passed, {"min_item_quantity": cart_qty}
|
||||
|
||||
if ctype == "min_category_qty":
|
||||
cat_id = params.get("category_id")
|
||||
cart_qty = sum(
|
||||
ci["quantity"] for ci in ctx.cart_items
|
||||
if ci.get("category_id") == cat_id
|
||||
)
|
||||
passed = cart_qty >= params.get("min", 1)
|
||||
return passed, {"min_category_qty": cart_qty}
|
||||
|
||||
if ctype == "min_order_value":
|
||||
total = sum(ci["quantity"] * ci["unit_price"] for ci in ctx.cart_items)
|
||||
passed = total >= params.get("min", 0.0)
|
||||
return passed, {"min_order_value": round(total, 2)}
|
||||
|
||||
if ctype == "order_channel":
|
||||
passed = ctx.channel in params.get("channels", [])
|
||||
return passed, {"order_channel": ctx.channel}
|
||||
|
||||
if ctype == "price_group_active":
|
||||
pgid = params.get("price_group_id")
|
||||
passed = pgid in ctx.active_price_group_ids
|
||||
return passed, {"price_group_active": passed}
|
||||
|
||||
if ctype == "user_tier":
|
||||
passed = ctx.user_tier in params.get("tiers", [])
|
||||
return passed, {"user_tier": ctx.user_tier}
|
||||
|
||||
if ctype == "low_stock":
|
||||
# Placeholder until inventory is implemented — always passes silently
|
||||
return True, {"low_stock": "placeholder"}
|
||||
|
||||
# Unknown condition type — pass silently for forward compatibility
|
||||
return True, {ctype: "unknown_type_skipped"}
|
||||
|
||||
|
||||
def _all_conditions_pass(conditions, ctx: OrderContext,
|
||||
product_id: int, quantity: float) -> tuple[bool, dict]:
|
||||
"""AND-evaluate all conditions. Returns (all_passed, merged_snapshot)."""
|
||||
snapshot = {}
|
||||
for cond in conditions:
|
||||
params = json.loads(cond.params) if isinstance(cond.params, str) else cond.params
|
||||
passed, snap = _eval_condition(cond.condition_type, params, ctx, product_id, quantity)
|
||||
snapshot.update(snap)
|
||||
if not passed:
|
||||
return False, snapshot
|
||||
return True, snapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modifier action math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _apply_action(price: float, action_type: str, action_value: float,
|
||||
round_to: Optional[str]) -> float:
|
||||
if action_type == "set":
|
||||
result = action_value
|
||||
elif action_type == "add_amount":
|
||||
result = price + action_value
|
||||
elif action_type == "add_percent":
|
||||
result = price * (1 + action_value / 100.0)
|
||||
else:
|
||||
result = price # unknown action — no-op
|
||||
|
||||
result = max(result, 0.0) # price can never go below 0
|
||||
|
||||
if round_to:
|
||||
result = _round_to(result, round_to)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _round_to(value: float, mode: str) -> float:
|
||||
if mode == "0.05":
|
||||
return round(value / 0.05) * 0.05
|
||||
if mode == "0.10":
|
||||
return round(value / 0.10) * 0.10
|
||||
if mode == "0.20":
|
||||
return round(value / 0.20) * 0.20
|
||||
if mode == "0.50":
|
||||
return round(value / 0.50) * 0.50
|
||||
if mode == "x.99":
|
||||
return float(int(value)) + 0.99 if value >= 1 else value
|
||||
if mode == "x.00":
|
||||
return float(round(value))
|
||||
return value
|
||||
|
||||
|
||||
def _system_round(value: float) -> float:
|
||||
"""System-wide final rounding: nearest 0.10."""
|
||||
return round(round(value / 0.10) * 0.10, 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PriceGroup auto-schedule evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _price_group_is_active(pg, now: datetime) -> bool:
|
||||
"""
|
||||
Returns True if this price group should currently be considered active.
|
||||
Checks is_active flag, then auto-schedule if configured.
|
||||
"""
|
||||
if pg.auto_enable_time and pg.auto_disable_time and pg.auto_days:
|
||||
now_local = now.astimezone()
|
||||
weekday = now_local.weekday()
|
||||
try:
|
||||
auto_days = json.loads(pg.auto_days)
|
||||
except (ValueError, TypeError):
|
||||
auto_days = []
|
||||
if weekday in auto_days:
|
||||
t_enable = dtime.fromisoformat(pg.auto_enable_time)
|
||||
t_disable = dtime.fromisoformat(pg.auto_disable_time)
|
||||
current = now_local.time()
|
||||
if t_enable <= t_disable:
|
||||
return t_enable <= current <= t_disable
|
||||
else:
|
||||
return current >= t_enable or current <= t_disable
|
||||
return bool(pg.is_active)
|
||||
|
||||
|
||||
def build_active_price_group_ids(db: Session, now: datetime) -> set:
|
||||
"""Fetch all price groups once per request and return active IDs."""
|
||||
from models.pricing import PriceGroup
|
||||
groups = db.query(PriceGroup).all()
|
||||
return {pg.id for pg in groups if _price_group_is_active(pg, now)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modifier target matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_ids(raw) -> list:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _modifier_targets_product(modifier, product, db: Session) -> bool:
|
||||
"""Return True if any target row on a global modifier covers this product."""
|
||||
from models.product import Product
|
||||
for t in modifier.targets:
|
||||
if t.target_type == "all":
|
||||
return True
|
||||
if t.target_type == "item":
|
||||
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
||||
if product.id in ids:
|
||||
return True
|
||||
if t.target_type == "category":
|
||||
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
||||
if product.category_id in ids:
|
||||
return True
|
||||
if t.target_type == "prep_zone":
|
||||
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
||||
zone_ids = {pz.id for pz in product.prep_zones}
|
||||
if any(i in zone_ids for i in ids):
|
||||
return True
|
||||
if t.target_type == "tag":
|
||||
prod_tags = set(json.loads(product.tags) if product.tags else [])
|
||||
tags = _parse_ids(t.target_tags) or ([t.target_tag] if t.target_tag else [])
|
||||
if any(tag in prod_tags for tag in tags):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_price(
|
||||
product,
|
||||
quantity: float,
|
||||
options_extra: float,
|
||||
ctx: OrderContext,
|
||||
db: Session,
|
||||
) -> tuple[float, list[PriceEvent]]:
|
||||
"""
|
||||
Compute the final unit_price for one order item.
|
||||
|
||||
Returns (final_unit_price, list_of_PriceEvent_to_log).
|
||||
PriceEvents have order_item_id=None — caller sets it after the OrderItem is committed.
|
||||
|
||||
order_id must be set on the PriceEvents before writing them; the caller does this.
|
||||
"""
|
||||
from models.pricing import PriceModifier
|
||||
|
||||
base = product.base_price + options_extra
|
||||
events: list[PriceEvent] = []
|
||||
|
||||
# --- Load modifiers ---
|
||||
# Item-scoped modifiers for this product
|
||||
item_modifiers = (
|
||||
db.query(PriceModifier)
|
||||
.filter(
|
||||
PriceModifier.scope == "item",
|
||||
PriceModifier.item_id == product.id,
|
||||
PriceModifier.is_active == 1,
|
||||
)
|
||||
.order_by(PriceModifier.sort_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Global modifiers (all active ones, filtered by target in Python)
|
||||
all_global = (
|
||||
db.query(PriceModifier)
|
||||
.filter(PriceModifier.scope == "global", PriceModifier.is_active == 1)
|
||||
.order_by(PriceModifier.sort_order)
|
||||
.all()
|
||||
)
|
||||
global_modifiers = [
|
||||
m for m in all_global
|
||||
if _modifier_targets_product(m, product, db)
|
||||
]
|
||||
|
||||
# Item-scoped take priority: evaluated first, then global appended
|
||||
all_modifiers = item_modifiers + global_modifiers
|
||||
|
||||
if not all_modifiers:
|
||||
return _system_round(base), events
|
||||
|
||||
# --- Separate stackable vs non-stackable ---
|
||||
stackable = []
|
||||
non_stackable = []
|
||||
for m in all_modifiers:
|
||||
passed, snapshot = _all_conditions_pass(m.conditions, ctx, product.id, quantity)
|
||||
if passed:
|
||||
if m.allow_stack:
|
||||
stackable.append((m, snapshot))
|
||||
else:
|
||||
non_stackable.append((m, snapshot))
|
||||
|
||||
price = base
|
||||
|
||||
# --- Apply first non-stackable ---
|
||||
if non_stackable:
|
||||
m, snapshot = non_stackable[0]
|
||||
price_before = price
|
||||
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
|
||||
events.append(PriceEvent(
|
||||
order_id=0, # caller will fill in
|
||||
order_item_id=None,
|
||||
event_type="modifier_applied",
|
||||
modifier_id=m.id,
|
||||
price_before=price_before,
|
||||
price_after=price,
|
||||
delta_amount=round(price - price_before, 4),
|
||||
conditions_snapshot=snapshot,
|
||||
))
|
||||
|
||||
# --- Apply all stackable modifiers ---
|
||||
for m, snapshot in stackable:
|
||||
price_before = price
|
||||
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
|
||||
events.append(PriceEvent(
|
||||
order_id=0,
|
||||
order_item_id=None,
|
||||
event_type="modifier_applied",
|
||||
modifier_id=m.id,
|
||||
price_before=price_before,
|
||||
price_after=price,
|
||||
delta_amount=round(price - price_before, 4),
|
||||
conditions_snapshot=snapshot,
|
||||
))
|
||||
|
||||
final = _system_round(price)
|
||||
|
||||
# Update the last event's price_after to reflect the final rounded value
|
||||
if events:
|
||||
events[-1].price_after = final
|
||||
events[-1].delta_amount = round(final - events[-1].price_before, 4)
|
||||
|
||||
return final, events
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deal evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _deal_targets_met(deal, ctx: OrderContext, db: Session) -> bool:
|
||||
"""Return True if the cart contains items that satisfy the deal's target rules."""
|
||||
from models.product import Product
|
||||
|
||||
if not deal.targets:
|
||||
return True # no target = always matches
|
||||
|
||||
for target in deal.targets:
|
||||
if target.target_type == "any":
|
||||
if ctx.cart_items:
|
||||
return True
|
||||
if target.target_type == "item":
|
||||
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
|
||||
if any(ci["product_id"] in ids for ci in ctx.cart_items):
|
||||
return True
|
||||
if target.target_type == "category":
|
||||
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
|
||||
if any(ci.get("category_id") in ids for ci in ctx.cart_items):
|
||||
return True
|
||||
if target.target_type == "tag":
|
||||
tags = _parse_ids(target.target_tags) or ([target.target_tag] if target.target_tag else [])
|
||||
if tags:
|
||||
for ci in ctx.cart_items:
|
||||
prod = db.query(Product).filter(Product.id == ci["product_id"]).first()
|
||||
if prod:
|
||||
prod_tags = set(json.loads(prod.tags) if prod.tags else [])
|
||||
if any(tag in prod_tags for tag in tags):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_deals(
|
||||
order_id: int,
|
||||
ctx: OrderContext,
|
||||
already_fired_deal_ids: set,
|
||||
db: Session,
|
||||
) -> list[DealOffer]:
|
||||
"""
|
||||
Check all active deals against the current cart. Returns DealOffer objects
|
||||
for deals that are newly triggered (not already fired this order).
|
||||
|
||||
already_fired_deal_ids: set of deal IDs that have already been offered/applied
|
||||
on this order (derived from price_event_log by the caller).
|
||||
"""
|
||||
from models.pricing import Deal
|
||||
from models.product import Product
|
||||
|
||||
deals = db.query(Deal).filter(Deal.is_active == 1).order_by(Deal.sort_order).all()
|
||||
offers: list[DealOffer] = []
|
||||
|
||||
for deal in deals:
|
||||
if deal.id in already_fired_deal_ids:
|
||||
continue
|
||||
|
||||
# Check conditions
|
||||
conditions_pass, _ = _all_conditions_pass(deal.conditions, ctx, 0, 0)
|
||||
if not conditions_pass:
|
||||
continue
|
||||
|
||||
# Check cart targets
|
||||
if not _deal_targets_met(deal, ctx, db):
|
||||
continue
|
||||
|
||||
# Build the offer
|
||||
offer = DealOffer(
|
||||
deal_id=deal.id,
|
||||
deal_name=deal.name,
|
||||
action_type=deal.action_type,
|
||||
)
|
||||
|
||||
if deal.action_type == "free_item" and deal.action_free_item_id:
|
||||
prod = db.query(Product).filter(Product.id == deal.action_free_item_id).first()
|
||||
offer.free_item_id = deal.action_free_item_id
|
||||
offer.free_item_name = prod.name if prod else None
|
||||
offer.free_quantity = deal.action_free_quantity
|
||||
|
||||
elif deal.action_type == "free_choice" and deal.action_free_target_ids:
|
||||
try:
|
||||
target_ids = json.loads(deal.action_free_target_ids)
|
||||
except (ValueError, TypeError):
|
||||
target_ids = []
|
||||
|
||||
if deal.action_free_target_type == "item":
|
||||
prods = db.query(Product).filter(Product.id.in_(target_ids)).all()
|
||||
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
|
||||
elif deal.action_free_target_type == "category":
|
||||
prods = db.query(Product).filter(
|
||||
Product.category_id.in_(target_ids),
|
||||
Product.lifecycle_status == "active",
|
||||
Product.is_available == True, # noqa: E712
|
||||
).all()
|
||||
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
|
||||
elif deal.action_free_target_type == "tag":
|
||||
all_prods = db.query(Product).filter(
|
||||
Product.lifecycle_status == "active",
|
||||
Product.is_available == True, # noqa: E712
|
||||
).all()
|
||||
offer.free_choices = [
|
||||
{"id": p.id, "name": p.name}
|
||||
for p in all_prods
|
||||
if any(tag in (json.loads(p.tags) if p.tags else []) for tag in target_ids)
|
||||
]
|
||||
offer.free_quantity = deal.action_free_quantity
|
||||
|
||||
offers.append(offer)
|
||||
|
||||
return offers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waiter discount limit enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_waiter_discount_limits(
|
||||
waiter_id: int,
|
||||
order_id: int,
|
||||
item_ids: list[int],
|
||||
discount_percent: float,
|
||||
item_unit_prices: dict[int, float], # {order_item_id: current_unit_price}
|
||||
db: Session,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate that applying the given discount does not exceed any configured limit.
|
||||
Returns (ok, error_message). error_message is empty string when ok=True.
|
||||
"""
|
||||
from models.pricing import WaiterDiscountSettings
|
||||
from models.order import OrderDiscount
|
||||
from models.settings import PosSettings
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
|
||||
# --- Global kill-switch ---
|
||||
global_row = db.query(PosSettings).filter(PosSettings.key == "discounts.enabled").first()
|
||||
if global_row and global_row.value == "false":
|
||||
return False, "Discounts are globally disabled"
|
||||
|
||||
# --- Per-waiter settings ---
|
||||
settings = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == waiter_id
|
||||
).first()
|
||||
|
||||
if not settings or not settings.can_apply_discounts:
|
||||
return False, "You do not have permission to apply discounts"
|
||||
|
||||
# --- Validate percent limits ---
|
||||
if settings.max_discount_percent is not None:
|
||||
if discount_percent > settings.max_discount_percent:
|
||||
return False, f"Discount exceeds your maximum of {settings.max_discount_percent:.0f}%"
|
||||
|
||||
# --- Compute proposed euro amounts ---
|
||||
proposed_amounts = {
|
||||
item_id: round(unit_price * discount_percent / 100.0, 2)
|
||||
for item_id, unit_price in item_unit_prices.items()
|
||||
if item_id in item_ids
|
||||
}
|
||||
proposed_total = sum(proposed_amounts.values())
|
||||
proposed_count = len(item_ids)
|
||||
|
||||
# --- Per-item amount limit ---
|
||||
if settings.max_discount_amount is not None:
|
||||
for item_id, amount in proposed_amounts.items():
|
||||
if amount > settings.max_discount_amount:
|
||||
return False, f"Single-item discount of €{amount:.2f} exceeds your limit of €{settings.max_discount_amount:.2f}"
|
||||
|
||||
# --- Items per order ---
|
||||
if settings.max_items_per_order is not None:
|
||||
existing_count_order = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.order_id == order_id
|
||||
).count()
|
||||
if existing_count_order + proposed_count > settings.max_items_per_order:
|
||||
remaining = settings.max_items_per_order - existing_count_order
|
||||
return False, f"You can only discount {remaining} more item(s) on this order"
|
||||
|
||||
# --- Current workday ---
|
||||
bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).first() # noqa: E711
|
||||
if bd:
|
||||
# Items per workday
|
||||
if settings.max_items_per_workday is not None:
|
||||
from models.order import Order
|
||||
wd_order_ids = [
|
||||
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
|
||||
]
|
||||
wd_count = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(wd_order_ids),
|
||||
).count()
|
||||
if wd_count + proposed_count > settings.max_items_per_workday:
|
||||
remaining = settings.max_items_per_workday - wd_count
|
||||
return False, f"You can only discount {remaining} more item(s) today"
|
||||
|
||||
# Total value per workday
|
||||
if settings.max_total_value_workday is not None:
|
||||
from models.order import Order
|
||||
wd_order_ids = [
|
||||
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
|
||||
]
|
||||
existing_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(wd_order_ids),
|
||||
).all()
|
||||
existing_wd_total = sum(_discount_euro(r) for r in existing_rows)
|
||||
if existing_wd_total + proposed_total > settings.max_total_value_workday:
|
||||
remaining = settings.max_total_value_workday - existing_wd_total
|
||||
return False, f"You have only €{remaining:.2f} of discount budget left today"
|
||||
|
||||
# --- Current shift ---
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == waiter_id,
|
||||
WaiterShift.ended_at == None, # noqa: E711
|
||||
).order_by(WaiterShift.started_at.desc()).first()
|
||||
|
||||
if shift:
|
||||
# Items per shift
|
||||
if settings.max_items_per_shift is not None:
|
||||
from models.order import Order
|
||||
shift_order_ids = [
|
||||
o.id for o in db.query(Order).filter(
|
||||
Order.opened_at >= shift.started_at
|
||||
).all()
|
||||
]
|
||||
shift_count = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(shift_order_ids),
|
||||
).count()
|
||||
if shift_count + proposed_count > settings.max_items_per_shift:
|
||||
remaining = settings.max_items_per_shift - shift_count
|
||||
return False, f"You can only discount {remaining} more item(s) this shift"
|
||||
|
||||
# Total value per shift
|
||||
if settings.max_total_value_shift is not None:
|
||||
from models.order import Order
|
||||
shift_order_ids = [
|
||||
o.id for o in db.query(Order).filter(
|
||||
Order.opened_at >= shift.started_at
|
||||
).all()
|
||||
]
|
||||
existing_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(shift_order_ids),
|
||||
).all()
|
||||
existing_shift_total = sum(_discount_euro(r) for r in existing_rows)
|
||||
if existing_shift_total + proposed_total > settings.max_total_value_shift:
|
||||
remaining = settings.max_total_value_shift - existing_shift_total
|
||||
return False, f"You have only €{remaining:.2f} of discount budget left this shift"
|
||||
|
||||
# --- Global limits from pos_settings ---
|
||||
def _gs(key):
|
||||
row = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
return float(row.value) if row and row.value else None
|
||||
|
||||
g_max_wd_value = _gs("discounts.max_total_value_workday")
|
||||
g_max_shift_val = _gs("discounts.max_total_value_shift")
|
||||
g_max_shift_cnt = _gs("discounts.max_items_per_shift")
|
||||
|
||||
if bd and g_max_wd_value is not None:
|
||||
from models.order import Order
|
||||
wd_order_ids = [o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()]
|
||||
all_wd_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.order_id.in_(wd_order_ids)
|
||||
).all()
|
||||
all_wd_total = sum(_discount_euro(r) for r in all_wd_rows)
|
||||
if all_wd_total + proposed_total > g_max_wd_value:
|
||||
remaining = g_max_wd_value - all_wd_total
|
||||
return False, f"Store-wide discount budget has only €{remaining:.2f} left today"
|
||||
|
||||
if shift and (g_max_shift_val is not None or g_max_shift_cnt is not None):
|
||||
from models.order import Order
|
||||
shift_order_ids = [
|
||||
o.id for o in db.query(Order).filter(Order.opened_at >= shift.started_at).all()
|
||||
]
|
||||
all_shift_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.order_id.in_(shift_order_ids)
|
||||
).all()
|
||||
if g_max_shift_val is not None:
|
||||
total = sum(_discount_euro(r) for r in all_shift_rows)
|
||||
if total + proposed_total > g_max_shift_val:
|
||||
remaining = g_max_shift_val - total
|
||||
return False, f"Store-wide shift discount budget has only €{remaining:.2f} left"
|
||||
if g_max_shift_cnt is not None:
|
||||
count = len(all_shift_rows)
|
||||
if count + proposed_count > int(g_max_shift_cnt):
|
||||
remaining = int(g_max_shift_cnt) - count
|
||||
return False, f"Store-wide shift item limit: only {remaining} discount(s) left"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _discount_euro(row) -> float:
|
||||
"""Convert an OrderDiscount row to a euro amount."""
|
||||
if row.price_before is not None and row.price_after is not None:
|
||||
return max(0.0, row.price_before - row.price_after)
|
||||
# Fallback for legacy rows without price_before/after
|
||||
if row.discount_type == "fixed":
|
||||
return row.discount_value
|
||||
return 0.0 # percent rows without snapshots can't be computed retroactively
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_price_events(events: list[PriceEvent], order_id: int,
|
||||
order_item_id: int, db: Session):
|
||||
"""Persist PriceEvent objects to price_event_log. Call after OrderItem is committed."""
|
||||
from models.pricing import PriceEventLog
|
||||
for ev in events:
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=order_item_id,
|
||||
event_type=ev.event_type,
|
||||
modifier_id=ev.modifier_id,
|
||||
deal_id=ev.deal_id,
|
||||
price_before=ev.price_before,
|
||||
price_after=ev.price_after,
|
||||
delta_amount=ev.delta_amount,
|
||||
applied_by_user_id=ev.applied_by_user_id,
|
||||
waiter_note=ev.waiter_note,
|
||||
conditions_snapshot=json.dumps(ev.conditions_snapshot) if ev.conditions_snapshot else None,
|
||||
selected_item_ids=json.dumps(ev.selected_item_ids) if ev.selected_item_ids else None,
|
||||
))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,8 +54,14 @@ async def unsubscribe(user_id: int, q: asyncio.Queue) -> None:
|
||||
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||
"""
|
||||
Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker).
|
||||
Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread.
|
||||
Delegates to ws_bus which persists the event and pushes to WS clients.
|
||||
Also pushes to legacy SSE clients so the old endpoint keeps working during transition.
|
||||
"""
|
||||
# WS bus handles persistence + WS delivery
|
||||
from services.ws_bus import broadcast_sync as ws_broadcast_sync
|
||||
ws_broadcast_sync(event_type, data, user_ids=user_ids)
|
||||
|
||||
# Legacy SSE push (no persistence needed — WS is the source of truth)
|
||||
if _main_loop is None:
|
||||
return
|
||||
_main_loop.call_soon_threadsafe(
|
||||
|
||||
170
local_backend/services/ws_bus.py
Normal file
170
local_backend/services/ws_bus.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
WebSocket Event Bus — replaces sse_bus.py for real-time communication.
|
||||
|
||||
Drop-in replacement: all routers continue calling broadcast_sync() unchanged.
|
||||
The WS endpoint uses connect()/disconnect()/replay_missed() to manage clients.
|
||||
|
||||
Protocol (JSON frames):
|
||||
Server → Client: { "seq": 123, "type": "order_updated", "data": { ... } }
|
||||
Client → Server: { "cursor": 120 } (sent immediately after connect)
|
||||
Server → Client: { "type": "ping" } (every 25s keepalive)
|
||||
Client → Server: { "type": "pong" } (optional, ignored if not sent)
|
||||
|
||||
On connect the client sends its last known seq. The server replays everything
|
||||
it has stored since that seq, then switches to live streaming. Events are stored
|
||||
in the sync_events SQLite table (written here, pruned on startup).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Set
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Event loop (captured once at startup) ────────────────────────────────────
|
||||
|
||||
_main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _main_loop
|
||||
_main_loop = loop
|
||||
|
||||
|
||||
# ── Connected clients: user_id → set of asyncio.Queue ────────────────────────
|
||||
|
||||
_connections: Dict[int, Set[asyncio.Queue]] = {}
|
||||
|
||||
|
||||
async def connect(user_id: int) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
if user_id not in _connections:
|
||||
_connections[user_id] = set()
|
||||
_connections[user_id].add(q)
|
||||
return q
|
||||
|
||||
|
||||
async def disconnect(user_id: int, q: asyncio.Queue) -> None:
|
||||
if user_id in _connections:
|
||||
_connections[user_id].discard(q)
|
||||
if not _connections[user_id]:
|
||||
del _connections[user_id]
|
||||
|
||||
|
||||
# ── Persistence helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _get_db():
|
||||
from database import SessionLocal
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _persist_event(event_type: str, data: dict, user_ids: list[int] | None) -> int:
|
||||
"""Write event to sync_events table, return the new seq_id."""
|
||||
from sqlalchemy import text
|
||||
db = _get_db()
|
||||
try:
|
||||
result = db.execute(
|
||||
text(
|
||||
"INSERT INTO sync_events (event_type, payload, target_user_ids, created_at) "
|
||||
"VALUES (:et, :payload, :uids, :now)"
|
||||
),
|
||||
{
|
||||
"et": event_type,
|
||||
"payload": json.dumps(data),
|
||||
"uids": json.dumps(user_ids) if user_ids is not None else None,
|
||||
"now": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return result.lastrowid
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_events_since(cursor: int, user_id: int) -> list[dict]:
|
||||
"""Return all events with seq_id > cursor that are visible to user_id."""
|
||||
from sqlalchemy import text
|
||||
db = _get_db()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT id, event_type, payload, target_user_ids FROM sync_events "
|
||||
"WHERE id > :cursor ORDER BY id ASC LIMIT 500"
|
||||
),
|
||||
{"cursor": cursor},
|
||||
).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
events = []
|
||||
for row in rows:
|
||||
target_user_ids = json.loads(row[3]) if row[3] else None
|
||||
if target_user_ids is None or user_id in target_user_ids:
|
||||
events.append({
|
||||
"seq": row[0],
|
||||
"type": row[1],
|
||||
"data": json.loads(row[2]),
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def prune_old_events(hours: int = 24) -> int:
|
||||
"""Delete events older than `hours`. Called on startup."""
|
||||
from sqlalchemy import text
|
||||
db = _get_db()
|
||||
try:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
result = db.execute(
|
||||
text("DELETE FROM sync_events WHERE created_at < :cutoff"),
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── Broadcast ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||
"""
|
||||
Called from sync route thread-pool workers.
|
||||
Persists the event to DB (so reconnecting clients can replay it),
|
||||
then schedules an async push to all currently connected sockets.
|
||||
"""
|
||||
try:
|
||||
seq_id = _persist_event(event_type, data, user_ids)
|
||||
except Exception:
|
||||
logger.exception("ws_bus: failed to persist event %s", event_type)
|
||||
seq_id = 0
|
||||
|
||||
if _main_loop is None:
|
||||
return
|
||||
_main_loop.call_soon_threadsafe(
|
||||
_main_loop.create_task,
|
||||
_broadcast_live(seq_id, event_type, data, user_ids),
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_live(
|
||||
seq_id: int,
|
||||
event_type: str,
|
||||
data: dict,
|
||||
user_ids: list[int] | None,
|
||||
) -> None:
|
||||
frame = json.dumps({"seq": seq_id, "type": event_type, "data": data})
|
||||
targets = (
|
||||
{uid: qs for uid, qs in _connections.items() if uid in user_ids}
|
||||
if user_ids is not None
|
||||
else dict(_connections)
|
||||
)
|
||||
for qs in targets.values():
|
||||
for q in list(qs):
|
||||
try:
|
||||
q.put_nowait(frame)
|
||||
except asyncio.QueueFull:
|
||||
pass # slow client — drop live frame; they'll replay on reconnect
|
||||
Reference in New Issue
Block a user