Hi / server.py
pubgbc9799
upload hoot pos
5138037
Raw History Blame Contribute Delete
52.8 kB
from flask import Flask, render_template, request, jsonify, send_from_directory, session, redirect, g, Response
import sqlite3, json, os, re, socket, threading, textwrap, time, base64, hmac, ipaddress
from datetime import datetime, timedelta
from urllib.parse import quote
from werkzeug.security import generate_password_hash, check_password_hash
from pathlib import Path
BASE = Path(__file__).resolve().parent
def _data_dir():
"""DATA_DIR env (e.g. /data on Hugging Face persistent storage) holds the database, config, secret key and saved bills.
If it is not set or not writable, everything stays next to server.py as before."""
d = os.environ.get('DATA_DIR', '').strip()
if d:
try:
p = Path(d); p.mkdir(parents=True, exist_ok=True)
if os.access(p, os.W_OK): return p
except Exception: pass
return BASE
DATA = _data_dir()
DB_PATH = DATA / 'restaurant.db'
CONFIG_PATH = DATA / 'config.json'
if DATA != BASE and not CONFIG_PATH.exists(): # first run: copy the shipped config into the data folder
try: CONFIG_PATH.write_text((BASE / 'config.json').read_text(encoding='utf-8'), encoding='utf-8')
except Exception: CONFIG_PATH = BASE / 'config.json'
app = Flask(__name__)
def _secret():
f = DATA / 'secret.key'
try: return f.read_bytes()
except Exception:
k = os.urandom(32)
try: f.write_bytes(k)
except Exception: pass
return k
app.secret_key = _secret()
app.permanent_session_lifetime = timedelta(days=365) # login stays until Logout
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax')
def get_config():
try: return json.loads(CONFIG_PATH.read_text(encoding='utf-8'))
except Exception: return {"restaurant_name":"मेरा ढाबा", "host":"0.0.0.0", "port":5000, "printers":{"kitchen":{"ip":"", "port":9100}, "bar":{"ip":"", "port":9100}}}
def db():
c = sqlite3.connect(DB_PATH, timeout=10)
c.row_factory = sqlite3.Row
c.execute('PRAGMA foreign_keys=ON')
return c
def init_db():
with db() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS items(id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, department TEXT NOT NULL DEFAULT 'kitchen', active INTEGER NOT NULL DEFAULT 1, category TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS orders(id INTEGER PRIMARY KEY AUTOINCREMENT, table_name TEXT NOT NULL, waiter TEXT DEFAULT '', notes TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL, total REAL NOT NULL DEFAULT 0, payment TEXT DEFAULT '', paid_at TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS order_items(id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, item_id INTEGER, name TEXT NOT NULL, price REAL NOT NULL, qty INTEGER NOT NULL, department TEXT NOT NULL, notes TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'waiter', pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS print_jobs(id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, ip TEXT NOT NULL, port INTEGER NOT NULL, data TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', taken_at TEXT DEFAULT '', tries INTEGER NOT NULL DEFAULT 0, message TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS kot_prints(id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, department TEXT NOT NULL, printed_at TEXT NOT NULL, success INTEGER NOT NULL, message TEXT DEFAULT '');
''')
cols = [r[1] for r in c.execute('PRAGMA table_info(items)').fetchall()]
if 'category' not in cols: c.execute("ALTER TABLE items ADD COLUMN category TEXT NOT NULL DEFAULT ''")
if 'description' not in cols: c.execute("ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT ''")
if 'in_stock' not in cols: c.execute("ALTER TABLE items ADD COLUMN in_stock INTEGER NOT NULL DEFAULT 1")
install_menu(c)
MENU_FILE = BASE / 'hoot_menu.json'
def install_menu(c):
"""Load The Hoot's real menu (hoot_menu.json).
First time only: the old sample menu is switched off (order history is kept).
Whenever 'version' in the file changes: items are added / updated by name; items you added yourself stay."""
try: data = json.loads(MENU_FILE.read_text(encoding='utf-8'))
except Exception: return
version = str(data.get('version', '')); items = data.get('items', [])
if not version or not items: return
row = c.execute("SELECT value FROM meta WHERE key='menu_version'").fetchone()
if row and row[0] == version: return
if row is None:
c.execute('UPDATE items SET active=0') # remove the old menu (hidden, not erased)
for it in items:
name = str(it['name']).strip(); price = float(it['price']); dept = it.get('department', 'kitchen')
cat = str(it.get('category', '')); desc = str(it.get('description', ''))
found = c.execute('SELECT id FROM items WHERE name=? AND category=? AND active=1', (name, cat)).fetchone()
if found: c.execute('UPDATE items SET price=?,department=?,description=? WHERE id=?', (price, dept, desc, found[0]))
else: c.execute('INSERT INTO items(name,price,department,category,description) VALUES(?,?,?,?,?)', (name, price, dept, cat, desc))
c.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('menu_version',?)", (version,))
def money(v): return f'₹{float(v):.2f}'
def escpos_text(s):
# Send printer-safe ASCII. Devanagari is not reliably supported by raw ESC/POS text mode.
return str(s).encode('ascii', errors='replace')
def printer_cfg(name):
cfg = get_config()
p = (cfg.get('printers', {}) or {}).get(name, {}) or {}
ip = str(p.get('ip', '')).strip()
try: port = int(p.get('port', 9100) or 9100)
except Exception: port = 9100
try: width = int(p.get('width', cfg.get('paper_width', 32)) or 32)
except Exception: width = 32
return ip, port, max(width, 24)
class Slip:
"""Small ESC/POS builder. Text is printer-safe ASCII (see escpos_text)."""
def __init__(self, width):
self.w = width
self.b = bytearray(b'\x1b@')
def text(self, t='', align=0, bold=False, size=0, rev=False):
self.b += b'\x1ba' + bytes([align]) + b'\x1bE' + bytes([1 if bold else 0]) + b'\x1d!' + bytes([size]) + b'\x1dB' + bytes([1 if rev else 0])
self.b += escpos_text(t) + b'\n'
self.b += b'\x1bE\x00\x1d!\x00\x1ba\x00\x1dB\x00'
def bar(self, t, size=0, bold=True):
# full-width white-on-black strip
cols = max(1, self.w // ((size >> 4) + 1))
self.text(str(t).center(cols)[:cols], 0, bold, size, True)
def line(self, ch='-'): self.text(ch * self.w)
def cut(self): self.b += b'\n\n\n\x1dV\x00'
def data(self): return bytes(self.b)
def queue_job(ip, port, data):
"""print_mode='agent': cloud server cannot reach the shop LAN, so the job waits here
and print_agent.py (running inside the shop WiFi) fetches it and sends it to the printer."""
try:
if not ipaddress.ip_address(ip).is_private: raise ValueError
except ValueError:
return False, 'Agent mode: printer IP must be a local IP like 192.168.x.x'
with db() as c:
c.execute('INSERT INTO print_jobs(created_at,ip,port,data) VALUES(?,?,?,?)',
(datetime.now().isoformat(timespec='seconds'), ip, int(port), base64.b64encode(data).decode('ascii')))
return True, f'Print queued for shop agent ({ip}:{port})'
def send_raw(ip, port, data, blank_msg):
if not ip: return False, blank_msg
if str(get_config().get('print_mode', 'direct')).lower() == 'agent':
return queue_job(ip, port, data)
try:
with socket.create_connection((ip, port), timeout=3) as sock:
sock.sendall(data)
return True, f'Print sent to {ip}:{port}'
except Exception as e:
return False, f'Print failed: {e}'
def pick_size(text, w, options=(0x22, 0x11, 0x01)):
# biggest ESC/POS size (3x, 2x, tall) that still fits the paper width
for n in options:
if len(text) * ((n >> 4) + 1) <= w: return n
return 0
def lr(left, right, w):
left = str(left)[:max(1, w - len(str(right)) - 1)]
return left + ' ' * max(1, w - len(left) - len(str(right))) + str(right)
def kot_ctx(department, order, rows):
tname = str(order.get('table_name', ''))
label = f"TABLE {tname[6:]}" if tname.startswith('Table ') else (tname.upper() or 'TABLE -')
return dict(now=datetime.now(), label=label, captain=str(order.get('waiter', '') or '-'),
qty=sum(int(r['qty']) for r in rows), dept=department.upper(),
name=str(get_config().get('restaurant_name', 'THE HOOT')).upper())
def item_lines(w, r, lead=0):
"""('main'|'note', text) lines for one item: '2 x NAME' + wrapped notes."""
out = []; pre = f"{int(r['qty']):>2} x "
for i, part in enumerate(textwrap.wrap(str(r['name']).upper(), w - lead - len(pre)) or ['']):
out.append(('main', (pre if i == 0 else ' ' * len(pre)) + part))
if r.get('notes'):
for part in textwrap.wrap(str(r['notes']), w - lead - len(pre) - 3):
out.append(('note', ' ' * len(pre) + '>> ' + part))
return out
def kot_badge(s, w, order, rows, kot_no, cancel, reason, c):
s.bar(c['name'], 0x11 if len(c['name']) * 2 <= w else 0)
if cancel: s.bar('CANCELLED', 0x11)
s.text(f"{c['dept']} KOT #{kot_no}", 1, True, 0x01)
s.text(c['label'], 1, True, pick_size(c['label'], w))
s.line('=')
s.text(lr(f"Captain: {c['captain']}", c['now'].strftime('%d %b %I:%M%p'), w))
s.line()
for r in rows:
for kind, t in item_lines(w, r):
if kind == 'main': s.text(t, 0, True, 0x01)
else: s.text(t.ljust(w), 0, True, 0, True)
s.line()
s.text(lr(f"Items: {c['qty']}", f"Order #{order['id']}", w))
if cancel and reason:
for part in textwrap.wrap('Reason: ' + reason, w): s.text(part, 0, True)
elif order.get('notes'):
for part in textwrap.wrap('Order note: ' + str(order['notes']), w): s.text(part, 0, True)
s.text('* * *', 1)
def kot_classic(s, w, order, rows, kot_no, cancel, reason, c):
inner = w - 4
def rule(): s.text('+' + '-' * (w - 2) + '+')
def row(t, bold=False, size=0, align='l'):
t = str(t)[:inner]; t = t.center(inner) if align == 'c' else t.ljust(inner)
s.text('| ' + t + ' |', 0, bold, size)
rule(); row(c['name'], True, 0, 'c')
if cancel: row('*** CANCELLED ***', True, 0x01, 'c')
rule()
row(lr(f"{c['dept']} KOT #{kot_no}", c['now'].strftime('%I:%M%p'), inner), True)
row(lr(c['label'], c['captain'], inner), True, 0x01)
rule()
for r in rows:
for kind, t in item_lines(inner, r):
row(t, True, 0x01) if kind == 'main' else row(t)
rule()
row(lr(f"Items: {c['qty']}", f"Order #{order['id']}", inner))
if cancel and reason:
for part in textwrap.wrap('Reason: ' + reason, inner): row(part, True)
elif order.get('notes'):
for part in textwrap.wrap('Note: ' + str(order['notes']), inner): row(part, True)
rule()
def kot_compact(s, w, order, rows, kot_no, cancel, reason, c):
if cancel: s.bar('*** CANCELLED ***')
s.text(lr(c['name'], f"KOT #{kot_no}", w), 0, True)
s.text(c['label'], 0, True, pick_size(c['label'], w, (0x11, 0x01)))
s.text(lr(f"{c['dept']} - {c['captain']}", c['now'].strftime('%d %b %I:%M%p'), w))
s.line()
for r in rows:
for kind, t in item_lines(w, r):
s.text(t, 0, True) if kind == 'main' else s.text(t)
s.line()
if cancel and reason:
for part in textwrap.wrap('Reason: ' + reason, w): s.text(part, 0, True)
elif order.get('notes'):
for part in textwrap.wrap('Order note: ' + str(order['notes']), w): s.text(part, 0, True)
KOT_STYLES = {'badge': kot_badge, 'classic': kot_classic, 'compact': kot_compact}
def print_kot(department, order, rows, kot_no, cancel=False, reason=''):
"""KOT slip. Layout comes from config.json -> kot_style: badge (default) | classic | compact."""
ip, port, w = printer_cfg(department)
blank = 'Printer IP is blank in config.json; KOT remains available on screen.'
if not ip: return False, blank
s = Slip(w)
layout = KOT_STYLES.get(str(get_config().get('kot_style', 'badge')).lower(), kot_badge)
layout(s, w, order, rows, kot_no, cancel, reason, kot_ctx(department, order, rows))
s.cut()
return send_raw(ip, port, s.data(), blank)
def split_rows(rows):
food = [r for r in rows if r.get('department') != 'bar']
bar = [r for r in rows if r.get('department') == 'bar']
return food, bar
def rows_amount(rs): return sum(int(r['qty']) * float(r['price']) for r in rs)
def bill_doc(order, rows, part, w, duplicate=False):
"""Bill layout shared by the thermal printer and the PDF. part: food | bar | all.
Returns a list of (kind, text, align, bold, size) or None if that part has no items."""
food, bar = split_rows(rows)
sel = {'food': food, 'bar': bar, 'all': rows}[part]
if not sel: return None
name = str(get_config().get('restaurant_name', 'THE HOOT')).upper()
suffix = {'food': '-F', 'bar': '-B', 'all': ''}[part]
title = {'food': 'FOOD BILL', 'bar': 'BAR BILL', 'all': 'CASH MEMO / BILL'}[part]
D = []
def T(t='', a=0, b=False, sz=0): D.append(('t', str(t), a, b, sz))
def LN(): D.append(('l', '-', 0, False, 0))
T(name, 1, True, 2); T(title, 1, True)
if duplicate: T('*** DUPLICATE COPY ***', 1, True)
LN()
T(f"Bill #{order['id']}{suffix}"); T(f"Table: {order['table_name']}"); T(f"Date: {order['created_at']}")
if order.get('waiter'): T(f"Waiter: {order['waiter']}")
LN()
def block(rs):
for r in rs:
for t in textwrap.wrap(str(r['name']), w) or ['']: T(t)
T(lr(f" {r['qty']} x {float(r['price']):.2f}", f"{int(r['qty']) * float(r['price']):.2f}", w))
if part == 'all':
for label, rs in (('FOOD', food), ('BAR', bar)):
if not rs: continue
T(f'[ {label} ]', 0, True); block(rs)
T(lr(f'{label.title()} subtotal', f'{rows_amount(rs):.2f}', w), 0, True); LN()
else:
block(sel); LN()
T(lr('GRAND TOTAL' if part == 'all' else 'TOTAL', f'Rs.{rows_amount(sel):.2f}', w), 0, True, 1)
if order.get('status') == 'paid' and order.get('payment'): T(f"Paid by: {order['payment']}")
LN(); T('Thank you! Visit again', 1)
return D
def print_bill(order, rows, duplicate=False, part='both'):
"""Bill goes straight to the bill printer. part: both (Food slip + Bar slip, separate) | food | bar | all (one combined slip)."""
ip, port, w = printer_cfg('bill')
blank = 'Bill printer IP is blank in config.json (printers.bill.ip).'
if not ip: return False, blank
s = Slip(w); sent = 0
for p in (['food', 'bar'] if part == 'both' else [part]):
D = bill_doc(order, rows, p, w, duplicate)
if not D: continue
for kind, t, a, b, sz in D:
if kind == 'l': s.line()
else: s.text(t, a, b, 0x11 if (sz == 2 and len(t) * 2 <= w) else (0x01 if sz == 1 else 0))
s.cut(); sent += 1
if not sent: return False, 'No items to print for this bill'
return send_raw(ip, port, s.data(), blank)
PDF_W = 38 # characters per line on the PDF receipt (Courier)
def pdf_from_doc(D):
pw, fs = 226.8, 8.5; cw = 0.6 * fs; mx = (pw - PDF_W * cw) / 2
def metrics(sz, t):
if sz == 2: size = 13 if len(t) * 7.8 <= pw - 2 * mx else 10
else: size = fs
return size, {0: 12.5, 1: 18, 2: 22}[sz]
mt = 16; H = 2 * mt + sum(9 if k == 'l' else metrics(sz, t)[1] for k, t, a, b, sz in D)
def pdf_str(t):
t = str(t).replace('\u20b9', 'Rs.').replace('\u2605', '*')
t = t.encode('cp1252', 'replace').decode('latin-1')
return t.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
ops = []; y = H - mt
for k, t, a, b, sz in D:
if k == 'l':
y -= 9; ops.append(f'[2 2] 0 d 0.5 w {mx:.2f} {y + 4.5:.2f} m {pw - mx:.2f} {y + 4.5:.2f} l S'); continue
size, adv = metrics(sz, t); y -= adv
wd = len(t) * 0.6 * size
x = mx if a == 0 else ((pw - wd) / 2 if a == 1 else pw - mx - wd)
ops.append(f'BT /{"F2" if (b or sz) else "F1"} {size} Tf {x:.2f} {y + (adv - size) / 2 + 1:.2f} Td ({pdf_str(t)}) Tj ET')
stream = '\n'.join(ops).encode('latin-1')
objs = [b'<< /Type /Catalog /Pages 2 0 R >>', b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2f} {H:.2f}] /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> /Contents 4 0 R >>'.encode(),
b'<< /Length %d >>\nstream\n' % len(stream) + stream + b'\nendstream',
b'<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>',
b'<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>']
out = bytearray(b'%PDF-1.4\n'); offs = []
for i, o in enumerate(objs, 1):
offs.append(len(out)); out += f'{i} 0 obj\n'.encode() + o + b'\nendobj\n'
xr = len(out); out += f'xref\n0 {len(objs) + 1}\n0000000000 65535 f \n'.encode()
for o in offs: out += f'{o:010d} 00000 n \n'.encode()
out += f'trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\nstartxref\n{xr}\n%%EOF'.encode()
return bytes(out)
def bill_pdf(order, rows, part):
D = bill_doc(order, rows, part, PDF_W)
return pdf_from_doc(D) if D else None
def bill_filename(order, part):
base = re.sub(r'[^A-Za-z0-9]+', '_', f"Hoot_Bill_{order['id']}_{order['table_name']}_{ {'food': 'Food', 'bar': 'Bar', 'all': 'Full'}[part] }").strip('_')
return base + '.pdf'
def save_bill_files(oid):
"""Backup copy of every paid bill (Full / Food / Bar PDF) in saved_bills/<date>/."""
try:
with db() as c: order, rows = get_order(c, oid)
if not order: return
d = DATA / 'saved_bills' / str(order.get('paid_at') or order['created_at'])[:10]
d.mkdir(parents=True, exist_ok=True)
for part in ('all', 'food', 'bar'):
data = bill_pdf(order, rows, part)
if data: (d / bill_filename(order, part)).write_bytes(data)
except Exception: pass
def kot_row_new(oid, dept, tag='pending'):
with db() as c:
cur = c.execute('INSERT INTO kot_prints(order_id,department,printed_at,success,message) VALUES(?,?,?,?,?)', (oid, dept, datetime.now().isoformat(timespec='seconds'), 0, tag))
return cur.lastrowid
def kot_row_done(kid, ok, msg):
with db() as c:
c.execute('UPDATE kot_prints SET success=?, message=? WHERE id=?', (1 if ok else 0, msg, kid))
def print_cancel_kots(oid, items, reason=''):
with db() as c:
order, _ = get_order(c, oid)
if not order: return True, ''
all_ok = True; failed = []
for dept in ('kitchen', 'bar'):
subset = [r for r in items if r['department'] == dept]
if not subset: continue
kid = kot_row_new(oid, dept, 'CANCEL KOT')
ok, msg = print_kot(dept, order, subset, kid, cancel=True, reason=reason)
kot_row_done(kid, ok, 'CANCEL KOT: ' + msg)
if not ok: all_ok = False; failed.append(f'{dept}: {msg}')
return all_ok, ('; '.join(failed) if failed else 'Cancel KOT sent')
def get_order(c, oid):
o = c.execute('SELECT * FROM orders WHERE id=?',(oid,)).fetchone()
if not o: return None, []
rows = c.execute('SELECT * FROM order_items WHERE order_id=? ORDER BY id',(oid,)).fetchall()
return dict(o), [dict(x) for x in rows]
def dispatch_kots(oid, new_item_ids=None):
# Print only newly-added items, while keeping them under the same open table bill.
with db() as c:
order, all_rows = get_order(c, oid)
if new_item_ids:
marks = ','.join('?' for _ in new_item_ids)
rows = [dict(r) for r in c.execute(f'SELECT * FROM order_items WHERE id IN ({marks}) ORDER BY id', tuple(new_item_ids)).fetchall()]
else:
rows = all_rows
if not order: return
for dept in ('kitchen','bar'):
subset = [r for r in rows if r['department'] == dept]
if not subset: continue
kid = kot_row_new(oid, dept) # the row id is the running KOT number
ok, msg = print_kot(dept, order, subset, kid)
kot_row_done(kid, ok, msg)
def serialize_order(c, row):
d = dict(row)
d['items'] = [dict(x) for x in c.execute('SELECT * FROM order_items WHERE order_id=? ORDER BY id',(d['id'],)).fetchall()]
return d
@app.route('/service-worker.js')
def service_worker():
response = send_from_directory(BASE, 'service-worker.js', mimetype='application/javascript')
response.headers['Service-Worker-Allowed'] = '/'
response.headers['Cache-Control'] = 'no-cache'
return response
@app.route('/')
def home(): return render_template('pos.html', config=get_config())
@app.route('/waiter')
def waiter(): return render_template('waiter.html', config=get_config())
@app.route('/kitchen')
def kitchen(): return render_template('station.html', dept='kitchen', title='Kitchen KOT', config=get_config())
@app.route('/bar')
def bar(): return render_template('station.html', dept='bar', title='Bar KOT', config=get_config())
@app.route('/api/config')
def api_config(): return jsonify(get_config())
@app.route('/api/items', methods=['GET','POST'])
def api_items():
with db() as c:
if request.method == 'POST':
x=request.get_json(force=True); name=str(x.get('name','')).strip(); price=float(x.get('price',0)); dept=x.get('department','kitchen')
if not name or price < 0 or dept not in ('kitchen','bar'): return jsonify(error='आइटम, सही रेट और विभाग जरूरी हैं'),400
c.execute('INSERT INTO items(name,price,department,category,description) VALUES(?,?,?,?,?)',(name,price,dept,str(x.get('category','')).strip()[:60],str(x.get('description','')).strip()[:300]))
return jsonify(ok=True)
return jsonify([dict(x) for x in c.execute('SELECT * FROM items WHERE active=1 ORDER BY id').fetchall()])
@app.route('/api/items/<int:item_id>', methods=['PATCH','DELETE'])
def api_item_edit(item_id):
with db() as c:
if request.method == 'DELETE': c.execute('UPDATE items SET active=0 WHERE id=?',(item_id,)); return jsonify(ok=True)
x=request.get_json(force=True); dept=x.get('department','kitchen'); price=float(x.get('price',0)); name=str(x.get('name','')).strip()
if not name or price < 0 or dept not in ('kitchen','bar'): return jsonify(error='गलत जानकारी'),400
c.execute('UPDATE items SET name=?,price=?,department=?,category=COALESCE(?,category),description=COALESCE(?,description) WHERE id=?',(name,price,dept,(str(x['category']).strip()[:60] if 'category' in x else None),(str(x['description']).strip()[:300] if 'description' in x else None),item_id)); return jsonify(ok=True)
@app.route('/api/orders', methods=['GET','POST'])
def api_orders():
if request.method == 'POST':
x=request.get_json(force=True); table=str(x.get('table_name','')).strip(); items=x.get('items',[])
if not table or not items: return jsonify(error='टेबल/ऑर्डर और आइटम जरूरी हैं'),400
with db() as c:
total=0; valid=[]; oos=[]
for i in items:
iid=int(i.get('item_id',0)); qty=int(i.get('qty',0))
item=c.execute('SELECT * FROM items WHERE id=? AND active=1',(iid,)).fetchone()
if not item or qty<1 or qty>500: continue
if not item['in_stock']: oos.append(item['name']); continue
note=str(i.get('notes',''))[:200]; total += item['price']*qty
valid.append((item,qty,note))
if oos: return jsonify(error='Out of stock: ' + ', '.join(oos) + ' - remove from cart and send again'),400
if not valid: return jsonify(error='कोई वैध आइटम नहीं मिला'),400
now=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
waiter=str(x.get('waiter',''))[:80]
me=g.get('user') or {}
if me.get('role')=='waiter' or not waiter: waiter=str(me.get('name',waiter))[:80]
order_note=str(x.get('notes',''))[:300]
# Reuse the same open bill for the selected table. A new bill starts only
# after the previous one is paid or cancelled.
existing=c.execute("SELECT * FROM orders WHERE table_name=? AND status NOT IN ('paid','cancelled') ORDER BY id DESC LIMIT 1",(table,)).fetchone()
if existing:
oid=existing['id']
c.execute("UPDATE orders SET total=total+?, status='pending', waiter=CASE WHEN ?!='' THEN ? ELSE waiter END, notes=CASE WHEN ?!='' THEN CASE WHEN notes='' THEN ? ELSE notes || ' | ' || ? END ELSE notes END WHERE id=?",(total,waiter,waiter,order_note,order_note,order_note,oid))
else:
cur=c.execute('INSERT INTO orders(table_name,waiter,notes,status,created_at,total) VALUES(?,?,?,?,?,?)',(table,waiter,order_note,'pending',now,total))
oid=cur.lastrowid
c.executemany('INSERT INTO order_items(order_id,item_id,name,price,qty,department,notes) VALUES(?,?,?,?,?,?,?)',[(oid,it['id'],it['name'],it['price'],qty,it['department'],note) for it,qty,note in valid])
# Capture only this submission's rows so the KOT printer doesn't reprint
# earlier items when the same table orders again.
new_item_ids=[r['id'] for r in c.execute('SELECT id FROM order_items WHERE order_id=? ORDER BY id DESC LIMIT ?', (oid,len(valid))).fetchall()]
threading.Thread(target=dispatch_kots,args=(oid,new_item_ids),daemon=True).start()
return jsonify(ok=True,order_id=oid,total=total,message='ऑर्डर टेबल के चालू बिल में जोड़ दिया गया; नए आइटम का KOT भेजा गया।')
dept=request.args.get('department'); status=request.args.get('status'); limit=min(max(int(request.args.get('limit',100)),1),300)
with db() as c:
sql='SELECT * FROM orders'; cond=[]; args=[]
if status: cond.append('status=?'); args.append(status)
if cond: sql+=' WHERE '+' AND '.join(cond)
sql+=' ORDER BY id DESC LIMIT ?'; args.append(limit)
out=[]
for row in c.execute(sql,args).fetchall():
d=serialize_order(c,row)
if dept: d['items']=[i for i in d['items'] if i['department']==dept]
if d['items'] or not dept: out.append(d)
return jsonify(out)
@app.route('/api/orders/<int:oid>/move',methods=['POST'])
def move_order(oid):
x=request.get_json(force=True); target=str(x.get('table_name','')).strip()
if not target or not target.startswith('Table '): return jsonify(error='टेबल चुनें'),400
try: number=int(target.split(' ',1)[1])
except Exception: return jsonify(error='गलत टेबल नंबर'),400
if number < 1 or number > 100 or number == 13: return jsonify(error='यह टेबल नंबर उपलब्ध नहीं है'),400
with db() as c:
source=c.execute("SELECT * FROM orders WHERE id=? AND status NOT IN ('paid','cancelled')",(oid,)).fetchone()
if not source: return jsonify(error='ऑर्डर नहीं मिला या बंद हो चुका है'),404
if source['table_name'] == target:
return jsonify(ok=True,table_name=target,message='ऑर्डर इसी टेबल पर है।')
target_order=c.execute("SELECT * FROM orders WHERE table_name=? AND id!=? AND status NOT IN ('paid','cancelled') ORDER BY id DESC LIMIT 1",(target,oid)).fetchone()
if target_order:
target_id=target_order['id']
notes=' | '.join(v for v in [target_order['notes'],source['notes']] if v)
c.execute("UPDATE orders SET total=total+?, status='pending', notes=? WHERE id=?",(source['total'],notes,target_id))
c.execute('UPDATE order_items SET order_id=? WHERE order_id=?',(target_id,oid))
c.execute('UPDATE kot_prints SET order_id=? WHERE order_id=?',(target_id,oid))
c.execute("UPDATE orders SET status='cancelled', total=0 WHERE id=?",(oid,))
return jsonify(ok=True,table_name=target,merged_into=target_id,message='ऑर्डर मौजूदा टेबल बिल में जोड़ दिया गया।')
c.execute('UPDATE orders SET table_name=? WHERE id=?',(target,oid))
return jsonify(ok=True,table_name=target)
@app.route('/api/orders/<int:oid>/status',methods=['POST'])
def order_status(oid):
x=request.get_json(force=True); status=x.get('status')
if status not in ('pending','preparing','ready','served','paid','cancelled'): return jsonify(error='गलत स्थिति'),400
if status=='cancelled': return cancel_order(oid)
with db() as c:
cur=c.execute('UPDATE orders SET status=? WHERE id=?',(status,oid))
if cur.rowcount==0: return jsonify(error='ऑर्डर नहीं मिला'),404
return jsonify(ok=True)
@app.route('/api/orders/<int:oid>/pay',methods=['POST'])
def pay_order(oid):
x=request.get_json(force=True); payment=str(x.get('payment','Cash'))[:30]
with db() as c:
cur=c.execute("UPDATE orders SET status='paid',payment=?,paid_at=? WHERE id=? AND status NOT IN ('paid','cancelled')",(payment,datetime.now().strftime('%Y-%m-%d %H:%M:%S'),oid))
if cur.rowcount==0: return jsonify(error='ऑर्डर नहीं मिला, रद्द है या पहले ही भुगतान हो चुका है'),400
order,rows=get_order(c,oid)
threading.Thread(target=save_bill_files,args=(oid,),daemon=True).start()
if get_config().get('auto_print_bill_on_pay', True):
ok,msg=print_bill(order,rows,duplicate=False,part='both')
return jsonify(ok=True,printed=ok,print_message=msg)
return jsonify(ok=True,printed=None,print_message='')
@app.route('/api/orders/<int:oid>/print-bill',methods=['POST'])
def print_bill_route(oid):
with db() as c:
order,rows=get_order(c,oid)
if not order: return jsonify(error='ऑर्डर नहीं मिला'),404
if order['status']=='cancelled': return jsonify(error='रद्द ऑर्डर का बिल प्रिंट नहीं हो सकता'),400
x=request.get_json(silent=True) or {}; part=x.get('part','both')
if part not in ('both','food','bar','all'): part='both'
ok,msg=print_bill(order,rows,duplicate=(order['status']=='paid'),part=part)
return jsonify(ok=ok,message=msg)
def do_cancel_order(oid, reason):
with db() as c:
o=c.execute("SELECT * FROM orders WHERE id=? AND status NOT IN ('paid','cancelled')",(oid,)).fetchone()
if not o: return None
items=[dict(r) for r in c.execute('SELECT * FROM order_items WHERE order_id=? ORDER BY id',(oid,)).fetchall()]
c.execute("UPDATE orders SET status='cancelled', total=0 WHERE id=?",(oid,))
return print_cancel_kots(oid, items, reason)
@app.route('/api/orders/<int:oid>/cancel',methods=['POST'])
def cancel_order(oid):
x=request.get_json(silent=True) or {}
reason=str(x.get('reason',''))[:120].strip()
res=do_cancel_order(oid,reason)
if res is None: return jsonify(error='ऑर्डर नहीं मिला या पहले ही बंद/रद्द हो चुका है'),404
ok,msg=res
return jsonify(ok=True,printed=ok,print_message=msg)
@app.route('/api/orders/<int:oid>/items/<int:iid>/cancel',methods=['POST'])
def cancel_item(oid,iid):
x=request.get_json(silent=True) or {}
reason=str(x.get('reason',''))[:120].strip()
with db() as c:
o=c.execute("SELECT * FROM orders WHERE id=? AND status NOT IN ('paid','cancelled')",(oid,)).fetchone()
if not o: return jsonify(error='ऑर्डर नहीं मिला या पहले ही बंद/रद्द हो चुका है'),404
row=c.execute('SELECT * FROM order_items WHERE id=? AND order_id=?',(iid,oid)).fetchone()
if not row: return jsonify(error='आइटम नहीं मिला'),404
try: qty=int(x.get('qty',row['qty']))
except Exception: qty=row['qty']
qty=min(max(qty,1),row['qty'])
snap=dict(row); snap['qty']=qty
if qty==row['qty']: c.execute('DELETE FROM order_items WHERE id=?',(iid,))
else: c.execute('UPDATE order_items SET qty=qty-? WHERE id=?',(qty,iid))
c.execute('UPDATE orders SET total=MAX(total-?,0) WHERE id=?',(row['price']*qty,oid))
left=c.execute('SELECT COUNT(*) FROM order_items WHERE order_id=?',(oid,)).fetchone()[0]
if left==0: c.execute("UPDATE orders SET status='cancelled', total=0 WHERE id=?",(oid,))
ok,msg=print_cancel_kots(oid,[snap],reason)
return jsonify(ok=True,printed=ok,print_message=msg,order_cancelled=(left==0))
@app.route('/api/reports/today')
def report_today():
day=datetime.now().strftime('%Y-%m-%d')
with db() as c:
row=c.execute("SELECT COUNT(*) count, COALESCE(SUM(total),0) sales FROM orders WHERE substr(created_at,1,10)=? AND status='paid'",(day,)).fetchone()
return jsonify(count=row['count'],sales=row['sales'],average=(row['sales']/row['count'] if row['count'] else 0))
# ---------------- Login / roles ----------------
ROLES = ('admin', 'cashier', 'waiter', 'kitchen')
ROLE_PAGES = {
'admin': None,
'cashier': {'/', '/waiter', '/kitchen', '/bar', '/pending', '/failed-kot', '/sales', '/stock', '/menu-manage', '/saved-bills', '/server-ip'},
'waiter': {'/waiter'},
'kitchen': {'/kitchen', '/bar', '/stock'},
}
HOME = {'admin': '/', 'cashier': '/', 'waiter': '/waiter', 'kitchen': '/kitchen'}
PUBLIC = ('/login', '/api/login', '/api/setup', '/service-worker.js')
LOGIN_FAILS = {}
def api_allowed(role, method, path):
if role == 'admin': return True
if path in ('/api/me', '/api/logout', '/api/server-info') or (method == 'GET' and path == '/api/items'): return True
if role == 'cashier': return not (path.startswith('/api/users') or path.startswith('/api/printers') or path == '/api/config')
if role == 'waiter': return method == 'POST' and path == '/api/orders'
if role == 'kitchen':
if path.startswith('/api/items/') and path.endswith('/stock'): return True
if path.startswith('/api/orders') and not (path.endswith('/pay') or path.endswith('/print-bill')): return True
return False
@app.before_request
def auth_gate():
g.user = None
uid = session.get('uid')
if uid:
with db() as c:
r = c.execute('SELECT id,username,name,role FROM users WHERE id=? AND active=1', (uid,)).fetchone()
if r: g.user = dict(r)
p = request.path
if p in PUBLIC or p.startswith('/static/') or p.startswith('/api/agent/jobs'): return # agent routes check their own token
if not g.user:
if p.startswith('/api/'): return jsonify(error='Login required'), 401
return redirect('/login?next=' + quote(p))
role = g.user['role']
if p.startswith('/api/'):
if not api_allowed(role, request.method, p): return jsonify(error='Permission denied'), 403
elif ROLE_PAGES.get(role) is not None and p not in ROLE_PAGES[role]:
return redirect(HOME.get(role, '/login'))
@app.context_processor
def inject_me(): return {'me': g.get('user') or {}}
@app.route('/login')
def login_page():
if g.get('user'): return redirect(HOME.get(g.user['role'], '/'))
with db() as c: n = c.execute('SELECT COUNT(*) FROM users').fetchone()[0]
return render_template('login.html', setup=(n == 0), config=get_config(), next=request.args.get('next', ''))
def _login(uid):
session.clear(); session['uid'] = uid; session.permanent = True
@app.route('/api/login', methods=['POST'])
def api_login():
x = request.get_json(silent=True) or {}; ip = request.remote_addr or '-'; now = time.time()
cnt, until = LOGIN_FAILS.get(ip, (0, 0))
if until > now: return jsonify(error='Bahut galat try ho gaye - 1 minute baad try karo'), 429
with db() as c:
u = c.execute('SELECT * FROM users WHERE username=? AND active=1', (str(x.get('username', '')).strip().lower(),)).fetchone()
if not u or not check_password_hash(u['pw_hash'], str(x.get('pin', ''))):
cnt += 1; LOGIN_FAILS[ip] = (0, now + 60) if cnt >= 6 else (cnt, 0)
return jsonify(error='Username ya PIN galat hai'), 401
LOGIN_FAILS.pop(ip, None); _login(u['id'])
return jsonify(ok=True, home=HOME.get(u['role'], '/'), name=u['name'], role=u['role'])
@app.route('/api/setup', methods=['POST'])
def api_setup():
x = request.get_json(silent=True) or {}
with db() as c:
if c.execute('SELECT COUNT(*) FROM users').fetchone()[0]: return jsonify(error='Setup ho chuka hai'), 400
err = _user_check(x, need_pin=True)
if err: return jsonify(error=err), 400
cur = c.execute('INSERT INTO users(username,name,role,pw_hash,created_at) VALUES(?,?,?,?,?)',
(str(x['username']).strip().lower(), str(x['name']).strip()[:60], 'admin', generate_password_hash(str(x['pin'])), datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
_login(cur.lastrowid)
return jsonify(ok=True, home='/')
@app.route('/api/me')
def api_me(): return jsonify(g.user)
@app.route('/api/logout', methods=['POST'])
def api_logout():
session.clear(); return jsonify(ok=True)
def _user_check(x, need_pin=False, need_role=False):
if 'username' in x or need_pin:
if not re.fullmatch(r'[A-Za-z0-9_.]{2,30}', str(x.get('username', '')).strip()): return 'Username: 2-30 letters / numbers (spaces nahi)'
if ('name' in x or need_pin) and not str(x.get('name', '')).strip(): return 'Naam bharo'
if (need_pin or x.get('pin')) and len(str(x.get('pin', ''))) < 4: return 'PIN / password kam se kam 4 character ka ho'
if need_role and x.get('role') not in ROLES: return 'Role chuno'
return None
def _other_admin(c, uid):
return c.execute("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1 AND id!=?", (uid,)).fetchone()[0] > 0
@app.route('/users')
def page_users(): return render_template('users.html', config=get_config())
@app.route('/api/users', methods=['GET', 'POST'])
def api_users():
with db() as c:
if request.method == 'GET':
return jsonify([dict(r) for r in c.execute('SELECT id,username,name,role,active,created_at FROM users ORDER BY id').fetchall()])
x = request.get_json(force=True); err = _user_check(x, need_pin=True, need_role=True)
if err: return jsonify(error=err), 400
uname = str(x['username']).strip().lower()
if c.execute('SELECT 1 FROM users WHERE username=?', (uname,)).fetchone(): return jsonify(error='Ye username pehle se hai'), 400
c.execute('INSERT INTO users(username,name,role,pw_hash,created_at) VALUES(?,?,?,?,?)',
(uname, str(x['name']).strip()[:60], x['role'], generate_password_hash(str(x['pin'])), datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
return jsonify(ok=True)
@app.route('/api/users/<int:uid>', methods=['PATCH', 'DELETE'])
def api_user_edit(uid):
with db() as c:
u = c.execute('SELECT * FROM users WHERE id=?', (uid,)).fetchone()
if not u: return jsonify(error='User nahi mila'), 404
me = g.user['id']
if request.method == 'DELETE':
if uid == me: return jsonify(error='Apna account delete nahi ho sakta'), 400
if u['role'] == 'admin' and u['active'] and not _other_admin(c, uid): return jsonify(error='Aakhri admin delete nahi ho sakta'), 400
c.execute('DELETE FROM users WHERE id=?', (uid,)); return jsonify(ok=True)
x = request.get_json(force=True); err = _user_check({k: v for k, v in x.items() if k in ('name', 'pin')})
if err: return jsonify(error=err), 400
role = x.get('role', u['role']); active = 1 if x.get('active', u['active']) else 0
if role not in ROLES: return jsonify(error='Role galat hai'), 400
if u['role'] == 'admin' and u['active'] and (role != 'admin' or not active):
if uid == me: return jsonify(error='Apna admin role / active status nahi badal sakte'), 400
if not _other_admin(c, uid): return jsonify(error='Kam se kam ek admin zaroori hai'), 400
name = str(x.get('name', u['name'])).strip()[:60] or u['name']
pw = generate_password_hash(str(x['pin'])) if x.get('pin') else u['pw_hash']
c.execute('UPDATE users SET name=?,role=?,active=?,pw_hash=? WHERE id=?', (name, role, active, pw, uid))
return jsonify(ok=True)
# ---------------- Bills: PDF + saved bills ----------------
@app.route('/saved-bills')
def page_saved_bills(): return render_template('saved_bills.html', config=get_config())
@app.route('/api/bills')
def api_bills():
day = (request.args.get('date') or '').strip(); q = (request.args.get('q') or '').strip().lower()
sql = """SELECT o.id, o.table_name, o.waiter, o.payment, o.created_at, o.paid_at, o.total,
(SELECT COALESCE(SUM(price*qty),0) FROM order_items WHERE order_id=o.id AND department!='bar') food,
(SELECT COALESCE(SUM(price*qty),0) FROM order_items WHERE order_id=o.id AND department='bar') bar
FROM orders o WHERE o.status='paid'"""
args = []
if day: sql += " AND substr(COALESCE(NULLIF(o.paid_at,''),o.created_at),1,10)=?"; args.append(day)
sql += ' ORDER BY o.id DESC LIMIT 300'
with db() as c: rows = [dict(r) for r in c.execute(sql, args).fetchall()]
if q: rows = [r for r in rows if q in (f"{r['id']} {r['table_name']} {r['waiter'] or ''} {r['payment'] or ''}").lower()]
return jsonify(rows)
@app.route('/api/bills/<int:oid>/pdf')
def api_bill_pdf(oid):
part = request.args.get('part', 'all')
if part not in ('all', 'food', 'bar'): part = 'all'
with db() as c: order, rows = get_order(c, oid)
if not order: return jsonify(error='Bill nahi mila'), 404
data = bill_pdf(order, rows, part)
if not data: return jsonify(error='Is bill me is hisse ke item nahi hain'), 404
disp = 'attachment' if request.args.get('download') else 'inline'
return Response(data, mimetype='application/pdf', headers={'Content-Disposition': f'{disp}; filename="{bill_filename(order, part)}"', 'Cache-Control': 'no-store'})
# ---------------- Drawer pages ----------------
def local_ip():
sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sk.connect(('10.255.255.255', 1)); return sk.getsockname()[0]
except Exception: return '127.0.0.1'
finally: sk.close()
@app.route('/menu-manage')
def page_menu(): return render_template('menu_manage.html', config=get_config())
@app.route('/stock')
def page_stock(): return render_template('stock.html', config=get_config())
@app.route('/sales')
def page_sales(): return render_template('sales.html', config=get_config())
@app.route('/pending')
def page_pending(): return render_template('pending.html', config=get_config())
@app.route('/failed-kot')
def page_failed(): return render_template('failed_kot.html', config=get_config())
@app.route('/settings')
def page_settings(): return render_template('settings.html', config=get_config())
@app.route('/server-ip')
def page_server_ip(): return render_template('server_ip.html', config=get_config())
@app.route('/api/items/<int:item_id>/stock', methods=['POST'])
def item_stock(item_id):
x = request.get_json(silent=True) or {}
with db() as c:
cur = c.execute('UPDATE items SET in_stock=? WHERE id=? AND active=1', (1 if x.get('in_stock', True) else 0, item_id))
if cur.rowcount == 0: return jsonify(error='Item not found'), 404
return jsonify(ok=True)
@app.route('/api/items/stock-reset', methods=['POST'])
def stock_reset():
with db() as c: c.execute('UPDATE items SET in_stock=1')
return jsonify(ok=True)
@app.route('/api/orders/open')
def orders_open():
with db() as c:
rows = c.execute("SELECT * FROM orders WHERE status NOT IN ('paid','cancelled') ORDER BY id DESC LIMIT 200").fetchall()
return jsonify([serialize_order(c, r) for r in rows])
@app.route('/api/kot/failed')
def kot_failed():
with db() as c:
rows = c.execute("""SELECT k.id, k.order_id, k.department, k.printed_at, k.message, o.table_name
FROM kot_prints k LEFT JOIN orders o ON o.id=k.order_id
WHERE k.success=0 AND k.message!='pending' ORDER BY k.id DESC LIMIT 100""").fetchall()
return jsonify([dict(r) for r in rows])
@app.route('/api/reports/daily')
def report_daily():
day = request.args.get('date') or datetime.now().strftime('%Y-%m-%d')
try: datetime.strptime(day, '%Y-%m-%d')
except Exception: return jsonify(error='Bad date'), 400
paid_day = "substr(COALESCE(NULLIF(paid_at,''),created_at),1,10)=?"
with db() as c:
t = c.execute(f"SELECT COUNT(*) n, COALESCE(SUM(total),0) s FROM orders WHERE status='paid' AND {paid_day}", (day,)).fetchone()
pay = [dict(r) for r in c.execute(f"SELECT COALESCE(NULLIF(payment,''),'Cash') mode, COUNT(*) n, SUM(total) amount FROM orders WHERE status='paid' AND {paid_day} GROUP BY 1 ORDER BY amount DESC", (day,)).fetchall()]
ids = f"(SELECT id FROM orders WHERE status='paid' AND {paid_day})"
top = [dict(r) for r in c.execute(f"SELECT name, SUM(qty) qty, SUM(qty*price) amount FROM order_items WHERE order_id IN {ids} GROUP BY name ORDER BY qty DESC, amount DESC LIMIT 10", (day,)).fetchall()]
cats = [dict(r) for r in c.execute(f"SELECT COALESCE(NULLIF(i.category,''),'Other') category, SUM(oi.qty) qty, SUM(oi.qty*oi.price) amount FROM order_items oi LEFT JOIN items i ON i.id=oi.item_id WHERE oi.order_id IN {ids} GROUP BY 1 ORDER BY amount DESC", (day,)).fetchall()]
dept = [dict(r) for r in c.execute(f"SELECT department, SUM(qty*price) amount FROM order_items WHERE order_id IN {ids} GROUP BY department", (day,)).fetchall()]
waiters = [dict(r) for r in c.execute(f"SELECT COALESCE(NULLIF(waiter,''),'-') waiter, COUNT(*) n, SUM(total) amount FROM orders WHERE status='paid' AND {paid_day} GROUP BY 1 ORDER BY amount DESC", (day,)).fetchall()]
canc = c.execute("SELECT COUNT(*) FROM orders WHERE status='cancelled' AND substr(created_at,1,10)=?", (day,)).fetchone()[0]
opn = c.execute("SELECT COUNT(*) n, COALESCE(SUM(total),0) s FROM orders WHERE status NOT IN ('paid','cancelled')").fetchone()
n = t['n']
return jsonify(date=day, bills=n, sales=t['s'], average=(t['s'] / n if n else 0), payments=pay, top_items=top,
categories=cats, departments=dept, waiters=waiters, cancelled=canc, open_bills=opn['n'], open_amount=opn['s'])
@app.route('/api/server-info')
def server_info():
cfg = get_config(); ip = local_ip(); port = int(cfg.get('port', 5000))
return jsonify(ip=ip, port=port, base=f'http://{ip}:{port}', restaurant=cfg.get('restaurant_name', 'The Hoot'))
@app.route('/api/printers', methods=['GET', 'POST'])
def api_printers():
cfg = get_config()
if request.method == 'GET':
return jsonify(printers=cfg.get('printers', {}), paper_width=cfg.get('paper_width', 32), kot_style=cfg.get('kot_style', 'badge'),
auto_print_bill_on_pay=cfg.get('auto_print_bill_on_pay', True))
x = request.get_json(force=True); pr = cfg.setdefault('printers', {})
for name in ('kitchen', 'bar', 'bill'):
v = (x.get('printers') or {}).get(name)
if not v: continue
ip = str(v.get('ip', '')).strip()
if ip and not re.fullmatch(r'[0-9A-Za-z.\-]{3,60}', ip): return jsonify(error=f'Bad IP for {name}'), 400
try: port = int(v.get('port', 9100) or 9100)
except Exception: return jsonify(error=f'Bad port for {name}'), 400
pr[name] = {**pr.get(name, {}), 'ip': ip, 'port': port}
if x.get('kot_style') in KOT_STYLES: cfg['kot_style'] = x['kot_style']
if 'auto_print_bill_on_pay' in x: cfg['auto_print_bill_on_pay'] = bool(x['auto_print_bill_on_pay'])
CONFIG_PATH.write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding='utf-8')
return jsonify(ok=True)
@app.route('/api/printers/<name>/test', methods=['POST'])
def printer_test(name):
if name not in ('kitchen', 'bar', 'bill'): return jsonify(error='Unknown printer'), 404
ip, port, w = printer_cfg(name)
s = Slip(w); s.text(str(get_config().get('restaurant_name', 'THE HOOT')).upper(), 1, True, 0x11 if w >= 32 else 0)
s.text(f'{name.upper()} PRINTER TEST', 1, True); s.line(); s.text(datetime.now().strftime('%d %b %Y %I:%M %p'), 1); s.text('Printer is working OK', 1); s.cut()
ok, msg = send_raw(ip, port, s.data(), 'Printer IP is blank - save the IP first.')
return jsonify(ok=ok, message=msg)
# ---------------- Print agent (cloud -> shop printer bridge) ----------------
def _agent_ok():
try: tok = (BASE / 'agent_token.txt').read_text(encoding='utf-8').strip()
except Exception: tok = str(os.environ.get('AGENT_TOKEN', '')).strip()
got = request.headers.get('X-Agent-Token', '')
return len(tok) >= 16 and hmac.compare_digest(tok.encode(), got.encode())
@app.route('/api/agent/jobs')
def agent_jobs():
if not _agent_ok(): return jsonify(error='Bad agent token'), 401
now = datetime.now(); iso = lambda d: d.isoformat(timespec='seconds')
with db() as c:
c.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('agent_seen',?)", (iso(now),))
c.execute("UPDATE print_jobs SET status='expired' WHERE status IN ('pending','taken') AND created_at<?", (iso(now - timedelta(minutes=15)),))
c.execute("DELETE FROM print_jobs WHERE created_at<?", (iso(now - timedelta(days=2)),))
rows = c.execute("SELECT id,ip,port,data FROM print_jobs WHERE status='pending' OR (status='taken' AND taken_at<?) ORDER BY id LIMIT 10",
(iso(now - timedelta(seconds=45)),)).fetchall()
for r in rows: c.execute("UPDATE print_jobs SET status='taken', taken_at=?, tries=tries+1 WHERE id=?", (iso(now), r['id']))
return jsonify(jobs=[dict(id=r['id'], ip=r['ip'], port=r['port'], data=r['data']) for r in rows])
@app.route('/api/agent/jobs/<int:jid>/done', methods=['POST'])
def agent_job_done(jid):
if not _agent_ok(): return jsonify(error='Bad agent token'), 401
x = request.get_json(silent=True) or {}; msg = str(x.get('message', ''))[:200]
with db() as c:
if x.get('ok'): c.execute("UPDATE print_jobs SET status='done', message=? WHERE id=?", (msg, jid))
else: # failed: stays 'taken' and is offered again after 45 s, up to 5 tries
c.execute("UPDATE print_jobs SET status=CASE WHEN tries>=5 THEN 'failed' ELSE 'taken' END, taken_at=?, message=? WHERE id=?",
(datetime.now().isoformat(timespec='seconds'), msg, jid))
return jsonify(ok=True)
@app.route('/api/agent/status')
def agent_status():
now = datetime.now()
with db() as c:
seen = c.execute("SELECT value FROM meta WHERE key='agent_seen'").fetchone()
pend = c.execute("SELECT COUNT(*) FROM print_jobs WHERE status IN ('pending','taken')").fetchone()[0]
hour = (now - timedelta(hours=1)).isoformat(timespec='seconds')
failed = c.execute("SELECT COUNT(*) FROM print_jobs WHERE status IN ('failed','expired') AND created_at>?", (hour,)).fetchone()[0]
last = c.execute("SELECT message FROM print_jobs WHERE status IN ('failed','taken') AND message!='' ORDER BY id DESC LIMIT 1").fetchone()
online = False
if seen:
try: online = (now - datetime.fromisoformat(seen[0])).total_seconds() < 15
except Exception: pass
return jsonify(mode=str(get_config().get('print_mode', 'direct')), agent_online=online, agent_last_seen=seen[0] if seen else None,
waiting=pend, failed_last_hour=failed, last_error=last[0] if last else '')
if __name__ == '__main__':
init_db()
cfg=get_config(); print('\nRestaurant POS चल रहा है: http://127.0.0.1:5000'); print('Waiter: /waiter | Kitchen: /kitchen | Bar: /bar')
app.run(host=cfg.get('host','0.0.0.0'), port=int(os.environ.get('PORT') or cfg.get('port',5000)), debug=False, threaded=True)