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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View 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()

View File

@@ -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:

View 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,
}

View 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

View File

@@ -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(

View 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