Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc

This commit is contained in:
2026-05-02 21:08:53 +03:00
parent 8e27b7666e
commit c9ad78ec71
50 changed files with 4441 additions and 643 deletions

View File

@@ -54,13 +54,32 @@ _DIVIDER_CHARS = {
"empty": "",
}
_PRINT_FONT_DEFAULTS = {
"print.font_item_name": "16:0",
"print.font_options": "0:0",
"print.font_table": "16:0",
"print.font_order_number": "48:1",
"print.font_header": "48:1",
_PRINT_SETTING_KEYS = [
"print.ticket_mode",
"print.divider_style",
"print.font_order_number",
"print.font_meta",
"print.font_item_name",
"print.font_quick",
"print.font_pref",
"print.font_extra",
"print.font_ingredient",
"print.font_item_note",
"print.font_order_note",
]
_PRINT_SETTING_DEFAULTS = {
"print.ticket_mode": "detailed",
"print.divider_style": "dash",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",
"print.font_quick": "0:0:0",
"print.font_pref": "0:0:0",
"print.font_extra": "0:0:0",
"print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0",
}
# SIZE byte values (ESC ! base, no bold bit):
@@ -68,27 +87,28 @@ _PRINT_FONT_DEFAULTS = {
# 16 = double-height (bit4)
# 32 = double-width (bit5)
# 48 = double-height + double-width (bits 4+5)
# Bold is applied separately via ESC E.
# Bold applied via ESC E, caps applied in software before encoding.
def _decode_font(value: str) -> tuple[int, bool]:
"""Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag)."""
def _decode_font(value: str) -> tuple[int, bool, bool]:
"""Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag)."""
try:
parts = str(value).split(":")
size = int(parts[0])
bold = len(parts) > 1 and parts[1] == "1"
return size, bold
caps = len(parts) > 2 and parts[2] == "1"
return size, bold, caps
except (ValueError, AttributeError):
return 0, False
return 0, False, False
def _load_print_fonts(db: Session) -> dict:
def _load_print_settings(db: Session) -> dict:
rows = db.query(PosSettings).filter(
PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys())
PosSettings.key.in_(_PRINT_SETTING_KEYS)
).all()
fonts = dict(_PRINT_FONT_DEFAULTS)
settings = dict(_PRINT_SETTING_DEFAULTS)
for row in rows:
fonts[row.key] = row.value
return fonts
settings[row.key] = row.value
return settings
def _divider(p: Network, style: str = "dash"):
@@ -100,14 +120,42 @@ def _divider(p: Network, style: str = "dash"):
p._raw(b'\n')
def _item_line(name: str, qty: int) -> str:
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
qty_str = str(qty)
gap = LINE_WIDTH - len(name) - len(qty_str)
if gap < 3:
return f"{name} {qty_str}"
dots = (". " * ((gap // 2) + 1))[:gap]
return f"{name}{dots}{qty_str}"
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
"""Build a dot-leader line ending with 'xN'.
line_width must reflect the effective width at the chosen font size
(double-width fonts halve the available char count to 24)."""
suffix = f"x{qty}"
available = line_width - len(name) - len(suffix)
if available < 2:
# Name alone is too long — put qty on same line with a single space
return f"{name} {suffix}"
dots = (". " * ((available // 2) + 1))[:available]
return f"{name}{dots}{suffix}"
def _apply_font(p: Network, size: int, bold: bool):
p._raw(bytes([0x1b, 0x21, size]))
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
def _reset_font(p: Network):
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
align: bytes = b'\x1b\x61\x00'):
"""Apply font, optionally capitalize, print text + newline, reset font."""
p._raw(align)
_apply_font(p, size, bold)
out = text.upper() if caps else text
_raw_text(p, out + "\n")
_reset_font(p)
def _greek_date(dt: datetime.datetime) -> str:
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
return dt.strftime("%H:%M %d-%m-%Y")
def check_printer(ip: str, port: int) -> bool:
@@ -152,88 +200,368 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
return False, str(e)
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
"""Print a fake order using the current font/layout settings — for settings preview."""
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping test order print")
return True, ""
# ── Fake data structures (no DB writes) ──────────────────────────────────
class _Table:
label = "O2"
number = 2
class _User:
nickname = "bonamin"
username = "bonamin"
class _Order:
id = 99
table = _Table()
opener = _User()
table_id = 2
opened_by = 1
notes = "Χωρις καψαλισμα παρακαλω"
class _Item:
def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes):
self.product_id = product_id
self.quantity = quantity
self.selected_options = selected_options
self.removed_ingredients = removed_ingredients
self.notes = notes
import json as _json
items = [
# Item 1: Freddo Espresso — quick options + preference + note
_Item(
product_id=1001,
quantity=2,
selected_options=_json.dumps([
{"name": "Διπλος", "price_delta": 0.5, "type": "quick"},
{"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"},
{"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"},
{"name": "Γαλα", "price_delta": 0.0, "type": "pref"},
{"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"},
]),
removed_ingredients=None,
notes="Πολυ κρυο παρακαλω",
),
# Item 2: Club Sandwich — extra with sub + removed ingredients
_Item(
product_id=1002,
quantity=1,
selected_options=_json.dumps([
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
{"name": "Ψωμι", "price_delta": 0.0, "type": "pref"},
{"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"},
]),
removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]),
notes=None,
),
# Item 3: Margherita — quick + extra + removed
_Item(
product_id=1003,
quantity=3,
selected_options=_json.dumps([
{"name": "Well Done", "price_delta": 0.0, "type": "quick"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
]),
removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]),
notes=None,
),
]
# Patch product lookup so _print_kitchen_ticket gets real names
_FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"}
# Monkey-patch db.query for Product only inside this call
_orig_query = db.query
class _FakeQuery:
def __init__(self, model):
self._model = model
self._filter_id = None
def filter(self, *args):
# extract id from the filter expression value
for arg in args:
try:
self._filter_id = arg.right.value
except Exception:
pass
return self
def first(self):
if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES:
class _P:
name = _FAKE_NAMES[self._filter_id]
return _P()
return _orig_query(self._model).filter(self._model.id == self._filter_id).first()
class _PatchedDB:
def query(self, model):
from models.product import Product as _Product
if model is _Product:
return _FakeQuery(model)
return _orig_query(model)
# delegate everything else to real db
def __getattr__(self, name):
return getattr(db, name)
try:
p = _get_printer(ip, port)
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
p.close()
return True, ""
except Exception as e:
logger.error("Test order print failed for %s:%s%s", ip, port, e)
return False, str(e)
# ── Receipt formatting ───────────────────────────────────────────────────────
def _font(p: Network, byte_val: int, bold: bool = False):
p._raw(bytes([0x1b, 0x21, byte_val]))
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
def _parse_options(item: OrderItem) -> dict:
"""
Parse selected_options JSON into grouped dict:
{ 'quick': [(name, qty)], 'pref': [(name, sub|None)],
'extra': [(name, sub|None, qty)], 'unknown': [name] }
Falls back gracefully when type tags are absent (old data).
"""
result = {"quick": [], "pref": [], "extra": [], "unknown": []}
if not item.selected_options:
return result
try:
raw = json.loads(item.selected_options)
except (json.JSONDecodeError, TypeError):
return result
if not isinstance(raw, list):
return result
i = 0
while i < len(raw):
entry = raw[i]
if not isinstance(entry, dict):
i += 1
continue
name = entry.get("name") or ""
etype = entry.get("type")
# Peek at next entry to collect sub-choice
sub = None
if i + 1 < len(raw):
nxt = raw[i + 1]
if isinstance(nxt, dict) and nxt.get("type") in ("pref_sub", "extra_sub"):
sub = nxt.get("name") or ""
i += 1 # consume sub
if etype == "quick":
# Collapse repeated quick entries into a single (name, qty) tuple
existing = next((q for q in result["quick"] if q[0] == name), None)
if existing:
result["quick"][result["quick"].index(existing)] = (name, existing[1] + 1)
else:
result["quick"].append((name, 1))
elif etype == "pref":
result["pref"].append((name, sub))
elif etype == "extra":
# Collapse repeated extra entries (same name+sub) → (name, sub, qty)
existing = next((e for e in result["extra"] if e[0] == name and e[1] == sub), None)
if existing:
result["extra"][result["extra"].index(existing)] = (name, sub, existing[2] + 1)
else:
result["extra"].append((name, sub, 1))
else:
# Legacy data without type tag — treat as unknown, display plainly
if name:
result["unknown"].append(name + (f" · {sub}" if sub else ""))
i += 1
return result
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
fonts = _load_print_fonts(db)
div = fonts["print.divider_style"]
cfg = _load_print_settings(db)
mode = cfg.get("print.ticket_mode", "detailed")
div = cfg.get("print.divider_style", "dash")
compact = (mode == "compact")
sz_order, bold_order = _decode_font(fonts["print.font_order_number"])
sz_table, bold_table = _decode_font(fonts["print.font_table"])
sz_item, bold_item = _decode_font(fonts["print.font_item_name"])
sz_opt, bold_opt = _decode_font(fonts["print.font_options"])
sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"])
sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"])
sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"])
sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"])
sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"])
sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"])
# Header — order number
p._raw(b'\x1b\x61\x01')
_font(p, sz_order, bold_order)
_raw_text(p, f"Παραγγελια #{order.id}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_divider(p, div)
# Resolve display names
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
now_str = _greek_date(datetime.datetime.now())
# Meta — table / waiter / time
p._raw(b'\x1b\x61\x00')
_font(p, sz_table, bold_table)
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
_raw_text(p, f"Date: {now}\n")
_raw_text(p, f"Table: {order.table_id}\n")
_raw_text(p, f"Waiter: {order.opened_by}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_divider(p, div)
# ── COMPACT header — single line ────────────────────────────────────────
if compact:
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_ord, b_ord)
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
_raw_text(p, (header.upper() if c_ord else header) + "\n")
_reset_font(p)
_divider(p, div)
# ── DETAILED header ──────────────────────────────────────────────────────
else:
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
align=b'\x1b\x61\x01')
_divider(p, div)
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_meta, b_meta)
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
_reset_font(p)
_divider(p, div)
# ── Items ────────────────────────────────────────────────────────────────
# Double-width fonts halve the effective character width
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
# Items
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"Product #{item.product_id}"
raw_name = product.name if product else f"Product #{item.product_id}"
item_name = raw_name.upper() if c_item else raw_name
_font(p, sz_item, bold_item)
_raw_text(p, _item_line(name, item.quantity) + "\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_item, b_item)
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
_reset_font(p)
_font(p, sz_opt, bold_opt)
opts = _parse_options(item)
# Quick options (* marker)
if opts["quick"]:
if compact:
parts = []
for name, qty in opts["quick"]:
n = name.upper() if c_qk else name
parts.append(f"{n} x{qty}" if qty > 1 else n)
_apply_font(p, sz_qk, b_qk)
_raw_text(p, "* " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, qty in opts["quick"]:
n = name.upper() if c_qk else name
line = f"* {n} x{qty}" if qty > 1 else f"* {n}"
_apply_font(p, sz_qk, b_qk)
_raw_text(p, line + "\n")
_reset_font(p)
# Preferences (> marker)
if opts["pref"]:
if compact:
parts = []
for name, sub in opts["pref"]:
n = name.upper() if c_pr else name
s = (sub.upper() if c_pr else sub) if sub else None
parts.append(f"{n} · {s}" if s else n)
_apply_font(p, sz_pr, b_pr)
_raw_text(p, "> " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, sub in opts["pref"]:
n = name.upper() if c_pr else name
s = (sub.upper() if c_pr else sub) if sub else None
line = f"> {n} · {s}" if s else f"> {n}"
_apply_font(p, sz_pr, b_pr)
_raw_text(p, line + "\n")
_reset_font(p)
# Extras (+ marker)
if opts["extra"]:
if compact:
parts = []
for name, sub, qty in opts["extra"]:
n = name.upper() if c_ex else name
s = (sub.upper() if c_ex else sub) if sub else None
part = f"{n} · {s}" if s else n
if qty > 1:
part += f" · x{qty}"
parts.append(part)
_apply_font(p, sz_ex, b_ex)
_raw_text(p, "+ " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, sub, qty in opts["extra"]:
n = name.upper() if c_ex else name
s = (sub.upper() if c_ex else sub) if sub else None
line = f"+ {n}"
if s:
line += f" · {s}"
if qty > 1:
line += f" · x{qty}"
_apply_font(p, sz_ex, b_ex)
_raw_text(p, line + "\n")
_reset_font(p)
# Legacy untagged options
for entry in opts["unknown"]:
_apply_font(p, sz_ex, b_ex)
_raw_text(p, f"+ {entry}\n")
_reset_font(p)
# Removed ingredients (- marker)
if item.removed_ingredients:
try:
removed_ids = json.loads(item.removed_ingredients)
if removed_ids:
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
except (json.JSONDecodeError, TypeError):
pass
if item.selected_options:
try:
option_ids = json.loads(item.selected_options)
if option_ids:
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
removed = json.loads(item.removed_ingredients)
if removed:
names = [n.upper() if c_ing else n for n in removed]
joined = " · ".join(names)
_apply_font(p, sz_ing, b_ing)
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
_reset_font(p)
except (json.JSONDecodeError, TypeError):
pass
# Per-item note
if item.notes:
_raw_text(p, f" (i) {item.notes}\n")
note_text = item.notes.upper() if c_note else item.notes
_apply_font(p, sz_note, b_note)
if compact:
_raw_text(p, f"! {note_text}\n")
else:
_raw_text(p, f"\n(!) {note_text}\n\n")
_reset_font(p)
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
# Blank line between items in detailed mode
if not compact:
p._raw(b'\n')
_divider(p, div)
# Order-level notes
if order.notes:
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Σημειωσεις:\n")
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"{order.notes}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
note_text = order.notes.upper() if c_onote else order.notes
_apply_font(p, sz_onote, b_onote)
_raw_text(p, f"Σημ: {note_text}\n")
_reset_font(p)
if not compact:
_divider(p, div)
# Footer (detailed only)
if not compact:
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Τελος Παραγγελιας\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Τελος Παραγγελιας\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()