Files
KPK/server_sqlite.py
2026-06-23 15:20:56 +02:00

12493 lines
444 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#-----------------------------------------------
# server_sqlite.py, v.:1.0
# pro SQLite, ma oddelene endpointy a DB operace
#-----------------------------------------------
#dbt ="testIX.db" #jmeno databaze
dbt ="testVIII.db" #jmeno databaze
#v consoli v adresari kde je serverovy pgm
#uvicorn server:app --reload
version = "072_8_Kivy"
import secrets
import data
import logging
import sqlite3
import json
import time
import re
import socket
import os
import threading
import ast
import uuid
from typing import List, Optional, Any
from fastapi import FastAPI, HTTPException, Header, Depends, Query, Body
from fastapi.responses import JSONResponse
from pathlib import Path
from contextlib import contextmanager
from datetime import datetime, timedelta
from urllib.parse import quote
from pydantic import ValidationError
from pydantic import BaseModel, TypeAdapter
from types import SimpleNamespace
import requests
import server_clsrep
import hotel_service
import fidelio_db_service
import postgres_service
from collections import defaultdict
from i18n import available_locales, normalize_lang
# L.L. (22.06.2026) Pridané kvôli spúšťaniu flutter aplikácie v chrome
from fastapi.middleware.cors import CORSMiddleware
from i18n import available_locales, normalize_lang
app = FastAPI()
# L.L. (22.06.2026) Pridané kvôli spúšťaniu flutter aplikácie v chrome
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["Authorization", "Content-Type", "X-Client-ID"],
)
p = data.Krypt() #kodovani
HEART_BEAT:int = 10
HEARTBEAT_INTERVAL = HEART_BEAT# jak často klient posílá heartbeat (s)
HEARTBEAT_TIMEOUT = 30 # po kolika sekundách je klient považován za mrtvého
BLOCK_EXPIRATION = 6 * HEART_BEAT # sekund (heartbeat máš po 10 s)
ACCESS_MINUTES = 15
REFRESH_DAYS = 7
LOCAL_PRINT_WORKER_ENV = "POKLADNA_LOCAL_PRINT_WORKER"
LOCAL_PRINT_AGENT_ID = os.getenv(
"POKLADNA_LOCAL_PRINT_AGENT_ID",
f"server-{socket.gethostname()}",
)
_local_print_worker_stop = threading.Event()
_local_print_worker_thread: threading.Thread | None = None
_local_print_worker_state = {
"started_at": "",
"stopped_at": "",
"last_cycle_at": "",
"last_processed_count": 0,
"last_processed_ids": [],
"last_error": "",
"printers": [],
"prefixes": [],
}
_limit_pg_locks_guard = threading.Lock()
_limit_pg_locks: dict[tuple[str, str, int], dict] = {}
# -----------------------------------------------------
# logovani
# -----------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",)
logger = logging.getLogger(__name__)
LOG_FILE = Path(__file__).with_name("server_sqlite.log")
if not any(
isinstance(handler, logging.FileHandler)
and Path(getattr(handler, "baseFilename", "")) == LOG_FILE
for handler in logging.getLogger().handlers
):
file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
logging.getLogger().addHandler(file_handler)
logging.getLogger().setLevel(logging.INFO)
'''
#do souboru
logging.basicConfig(
filename="server.log",
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",)
'''
# -----------------------------------------------------
# SQLite (JSON storage)
# -----------------------------------------------------
def init_global_schema(cur):
init_tab_zakazky(cur)
init_tab_tokens(cur)
init_tab_heartbeat(cur)
def init_user_schema(cur, prefix: str, zakazka: str=""):
init_tab_uct(prefix, cur)
init_tab_cen(prefix, cur)
init_cenik_texty_schema(prefix, cur)
init_cook_items_schema(prefix, cur)
init_tab_setup(prefix, cur, zakazka)
init_platby_schema(prefix, cur)
init_tab_users(prefix=prefix, cur=cur)
init_tab_closerep(prefix=prefix, cur=cur)
init_closure_cash_state_schema(prefix=prefix, cur=cur)
init_closure_transfer_outbox_schema(prefix=prefix, cur=cur)
backfill_ucty_closure_links(prefix=prefix, cur=cur)
init_mapa_stolu_schema(prefix=prefix, cur=cur)
seed_mapa_stolu_if_empty(prefix=prefix, cur=cur)
init_fstmenu_schema(prefix=prefix, cur=cur)
init_prndef_schema(prefix=prefix, cur=cur)
init_bankterm_schema(prefix=prefix, cur=cur)
init_print_jobs_schema(prefix=prefix, cur=cur)
init_print_bon_counters_schema(prefix=prefix, cur=cur)
init_printer_status_schema(prefix=prefix, cur=cur)
init_kasaucp_schema(prefix=prefix, cur=cur)
init_kasutxt_schema(prefix=prefix, cur=cur)
init_hladiny_schema(prefix=prefix, cur=cur)
init_zlavy_schema(prefix=prefix, cur=cur)
init_setup_parameters_schema(prefix=prefix, cur=cur)
init_postgres_connection_schema(prefix=prefix, cur=cur)
init_limit_locks_schema(prefix=prefix, cur=cur)
init_recepcia_schema(prefix=prefix, cur=cur)
init_hotrastre_schema(prefix=prefix, cur=cur)
init_mewsrastre_schema(prefix=prefix, cur=cur)
init_fidrastre_schema(prefix=prefix, cur=cur)
init_hotplatby_schema(prefix=prefix, cur=cur)
init_mewsdph_schema(prefix=prefix, cur=cur)
init_uvery_schema(prefix=prefix, cur=cur)
init_fooddat_schema(prefix=prefix, cur=cur)
init_clients_schema(prefix=prefix, cur=cur)
init_s_zmena_schema(prefix=prefix, cur=cur) ## L.L. (22.06.2026) Načítavanie cenníka, nastavení a mapy na základe posledného dátumu a času zmeny
@app.on_event("startup")
def startup():
logger.info(f"Server version {version}\nStarting DB initialization")
print("Server version:",version)
with get_db() as conn:
conn.execute("PRAGMA journal_mode=WAL;")
cur = conn.cursor()
init_global_schema(cur) # globální tabulky
cur.execute(""" SELECT id, jmeno_zakazky FROM zakazky WHERE heslo IS NOT NULL AND heslo <> '' """)
rows = cur.fetchall()
for zak_id, name in rows: # existující zakázky
prefix = f"{zak_id:05d}"
logger.info(f"Ensuring schema for {prefix} ({name})")
init_user_schema(cur, prefix)
#upgrade_all_setups(cur, prefix)
logger.info("Embedded local print worker is not started with server. Use local_print_agent.py for local printing.")
@app.on_event("shutdown")
def shutdown():
stop_local_print_worker()
@app.get("/locales/")
def get_locales():
return {"locales": available_locales()}
@app.get("/locales/{lang}")
def get_locale(lang: str):
lang = normalize_lang(lang)
path = Path(__file__).with_name("locales") / f"{lang}.json"
if not path.exists():
raise HTTPException(404, f"Locale {lang} neexistuje")
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def upgrade_all_setups(cur, prefix: str):
table = f"{prefix}_setup"
# pokud tabulka setup ještě neexistuje, není co řešit
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
)
if not cur.fetchone():
return
cur.execute(f'SELECT id_kas, data FROM "{table}"')
rows = cur.fetchall()
for id_kas, raw_json in rows:
try:
setup = data.PosSetup.model_validate_json(raw_json)
except Exception as e:
logger.error(f"Invalid setup JSON {prefix}/{id_kas}: {e}")
continue
normalized = json.dumps(
setup.model_dump(exclude_none=False),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=False,
)
if normalized != raw_json:
logger.info(f"SETUP schema upgraded: {prefix}/{id_kas}")
cur.execute(
f'UPDATE "{table}" SET data=? WHERE id_kas=?',
(normalized, id_kas),
)
def init_tab_zakazky(cur):
cur.execute("""
CREATE TABLE IF NOT EXISTS zakazky ( id INTEGER PRIMARY KEY AUTOINCREMENT, jmeno_zakazky TEXT NOT NULL,
uzivatel TEXT NOT NULL UNIQUE, heslo TEXT NOT NULL ) """)
cur.execute( "SELECT 1 FROM zakazky WHERE uzivatel=?", ("Kobrle",) )
if not cur.fetchone():
cur.execute(
"INSERT INTO zakazky (jmeno_zakazky, uzivatel, heslo) VALUES (?,?,?)",
("Alto", "Kobrle", p.code("heslo")) )
def init_tab_heartbeat(cur):
cur.execute("""
CREATE TABLE IF NOT EXISTS heartbeat_clients (
prefix TEXT NOT NULL,
id_kas TEXT NOT NULL,
client_id TEXT NOT NULL,
user TEXT,
last_seen REAL NOT NULL,
PRIMARY KEY (prefix, id_kas, client_id)
)
""")
def init_tab_tokens(cur):
cur.execute("""
CREATE TABLE IF NOT EXISTS tokens (
user TEXT NOT NULL,
client_id TEXT NOT NULL,
prefix TEXT NOT NULL,
access_token TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
refresh_token TEXT NOT NULL,
refresh_expires_at TIMESTAMP NOT NULL,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user, client_id)
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_tokens_access ON tokens (access_token)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_tokens_refresh ON tokens (refresh_token)")
def init_fstmenu_schema(prefix: str, cur):
table = f"{prefix}_fstmenu"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
c_karty INTEGER,
polozky TEXT NOT NULL
)
""")
index_name = f"{prefix}_fstmenu_kasa_karta"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas, c_karty)
""")
def init_fooddat_schema(prefix: str, cur):
table = f"{prefix}_fooddat"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id TEXT PRIMARY KEY,
c_stredisk INTEGER NOT NULL DEFAULT 0,
id_zkratka TEXT NOT NULL DEFAULT '',
pgm TEXT NOT NULL DEFAULT ''
)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_fooddat_stredisk"
ON "{table}" (c_stredisk)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_fooddat_pgm"
ON "{table}" (pgm)
""")
def init_mapa_stolu_schema(prefix: str, cur):
table = f"{prefix}_mapa_stolu"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL
)
""")
def seed_mapa_stolu_if_empty(prefix: str, cur):
table = f"{prefix}_mapa_stolu"
cur.execute(f'SELECT COUNT(*) FROM "{table}"')
count = cur.fetchone()[0]
if count == 0:
empty_map = {
"rooms": [],
"pokladny": []
}
raw_json = json.dumps(
empty_map,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
cur.execute(
f'INSERT INTO "{table}" (data) VALUES (?)',
(raw_json,),
)
def init_tab_closerep(prefix: str, cur):
table = f"{prefix}_clsrep"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
clsrep_id INTEGER PRIMARY KEY AUTOINCREMENT,
blocked_by TEXT,
ucislo_st TEXT NOT NULL,
ucislo_end TEXT NOT NULL,
dta_from TEXT NOT NULL,
dta_to TEXT NOT NULL,
id_kas TEXT NOT NULL,
clsrep_no TEXT NOT NULL,
data TEXT NOT NULL
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
columns = {str(row[1]) for row in cur.fetchall()}
if "men_sp_man" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN men_sp_man TEXT NOT NULL DEFAULT ""')
if "uzav_odvod" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN uzav_odvod TEXT NOT NULL DEFAULT ""')
if "closure_warnings" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN closure_warnings TEXT NOT NULL DEFAULT "[]"')
# pokladna
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_clsrep_kas
ON "{table}"(id_kas)
""")
def init_closure_cash_state_schema(prefix: str, cur):
table = f"{prefix}_closure_cash_state"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
clsrep_id INTEGER NOT NULL,
id_kas TEXT NOT NULL,
prn_no TEXT NOT NULL DEFAULT '',
payment_code TEXT NOT NULL DEFAULT '',
payment_name TEXT NOT NULL DEFAULT '',
opening_amount REAL NOT NULL DEFAULT 0,
sales_amount REAL NOT NULL DEFAULT 0,
receivable_amount REAL NOT NULL DEFAULT 0,
manual_deposit_amount REAL NOT NULL DEFAULT 0,
manual_withdrawal_amount REAL NOT NULL DEFAULT 0,
auto_deposit_amount REAL NOT NULL DEFAULT 0,
auto_withdrawal_amount REAL NOT NULL DEFAULT 0,
carry_amount REAL NOT NULL DEFAULT 0,
generated_ucislo TEXT NOT NULL DEFAULT '',
fiscal_result TEXT NOT NULL DEFAULT '{{}}',
status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(clsrep_id, id_kas, prn_no, payment_code)
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if "auto_deposit_amount" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN auto_deposit_amount REAL NOT NULL DEFAULT 0')
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_closure_cash_state_status"
ON "{table}"(id_kas, status, prn_no, payment_code)
""")
def init_closure_transfer_outbox_schema(prefix: str, cur):
table = f"{prefix}_closure_transfer_outbox"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
clsrep_id INTEGER NOT NULL,
id_kas TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT 'hotel',
reception_id INTEGER,
reception_name TEXT NOT NULL DEFAULT '',
typ_hotel INTEGER NOT NULL DEFAULT 0,
payload TEXT NOT NULL DEFAULT '{{}}',
response TEXT NOT NULL DEFAULT '{{}}',
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at TEXT
)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_closure_transfer_outbox_status"
ON "{table}"(id_kas, status, clsrep_id)
""")
def init_prndef_schema(prefix: str, cur):
table = f"{prefix}_prn_def"
cur.execute(f"""
DROP TABLE IF EXISTS "{table}"
""")
index_name = f"{prefix}_prndef_prnno"
cur.execute(f"""
DROP INDEX IF EXISTS "{index_name}"
""")
table = f"{prefix}_prndef"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prn_no TEXT NOT NULL,
prn_name TEXT NOT NULL,
data TEXT NOT NULL
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if "id_term" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN id_term TEXT')
index_name = f"{prefix}_prndef_prnno"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (prn_no)
""")
def init_bankterm_schema(prefix: str, cur):
table = f"{prefix}_bankterm"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_term TEXT NOT NULL,
term_name TEXT NOT NULL,
term_data TEXT NOT NULL
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
#columns = {row[1] for row in cur.fetchall()}
index_name = f"{prefix}_bankterm_id_term"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_term)
""")
def init_print_jobs_schema(prefix: str, cur):
table = f"{prefix}_print_jobs"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
printer_no TEXT NOT NULL DEFAULT '',
agent_id TEXT,
job_type TEXT NOT NULL DEFAULT 'other',
document_type TEXT NOT NULL DEFAULT '',
receipt_no TEXT,
required INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued',
priority INTEGER NOT NULL DEFAULT 100,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
payload TEXT NOT NULL DEFAULT '{{}}',
result TEXT NOT NULL DEFAULT '{{}}',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
claimed_at TEXT,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_status"
ON "{table}" (status, priority, id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_kasa_prn"
ON "{table}" (id_kas, printer_no, status)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_status_prn"
ON "{table}" (status, printer_no, priority, id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_receipt"
ON "{table}" (receipt_no)
""")
def init_print_bon_counters_schema(prefix: str, cur):
table = f"{prefix}_print_bon_counters"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
prn_no TEXT NOT NULL,
bon_date TEXT NOT NULL,
last_no INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (prn_no, bon_date)
)
""")
def init_printer_status_schema(prefix: str, cur):
table = f"{prefix}_printer_status"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id_kas TEXT NOT NULL,
prn_no TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT '',
online INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
printer_type TEXT NOT NULL DEFAULT '',
cmd32_on TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
queue_size INTEGER NOT NULL DEFAULT 0,
failed_jobs INTEGER NOT NULL DEFAULT 0,
details TEXT NOT NULL DEFAULT '{{}}',
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id_kas, prn_no)
)
""")
def init_kasaucp_schema(prefix: str, cur):
table = f"{prefix}_kasa_ucp"
cur.execute(f"""
DROP TABLE IF EXISTS "{table}"
""")
index_name = f"{prefix}_kasaucp_idkas"
cur.execute(f"""
DROP INDEX IF EXISTS "{index_name}"
""")
table = f"{prefix}_kasauct"
cur.execute(f"""
DROP TABLE IF EXISTS "{table}"
""")
index_name = f"{prefix}_kasauct_idkas"
cur.execute(f"""
DROP INDEX IF EXISTS "{index_name}"
""")
table = f"{prefix}_kasaucp"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
printers TEXT NOT NULL
)
""")
index_name = f"{prefix}_kasaucp_idkas"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas)
""")
def init_kasutxt_schema(prefix: str, cur):
table = f"{prefix}_kas_utxt"
cur.execute(f"""
DROP TABLE IF EXISTS "{table}"
""")
index_name = f"{prefix}_kasutxt_idkas"
cur.execute(f"""
DROP INDEX IF EXISTS "{index_name}"
""")
table = f"{prefix}_kasutxt"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
riadky TEXT NOT NULL
)
""")
index_name = f"{prefix}_kasutxt_idkas"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas)
""")
def init_hladiny_schema(prefix: str, cur):
table = f"{prefix}_hladiny"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
riadky TEXT NOT NULL
)
""")
index_name = f"{prefix}_hladiny_idkas"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas)
""")
def init_zlavy_schema(prefix: str, cur):
table = f"{prefix}_zlavy"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
id_zlavy_hlav INTEGER NOT NULL,
meno TEXT NOT NULL,
data TEXT NOT NULL
)
""")
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{prefix}_zlavy_idkas_hlav"
ON "{table}" (id_kas, id_zlavy_hlav)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_zlavy_idkas"
ON "{table}" (id_kas)
""")
drop_column_if_exists(cur.connection, table=table, column='aktivna')
drop_column_if_exists(cur.connection, table=table, column='zmazana')
drop_column_if_exists(cur.connection, table=table, column='dat_cas_zm')
def init_uvery_schema(prefix: str, cur):
table = f"{prefix}_uvery"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hjmeno TEXT NOT NULL DEFAULT '',
adresa1 TEXT NOT NULL DEFAULT '',
adresa2 TEXT NOT NULL DEFAULT '',
adresa3 TEXT NOT NULL DEFAULT '',
ico TEXT NOT NULL DEFAULT '',
icdph TEXT NOT NULL DEFAULT '',
dic TEXT NOT NULL DEFAULT ''
)
""")
for column in ("hjmeno", "adresa1", "adresa2", "adresa3", "ico", "icdph", "dic"):
add_column_if_not_exists(cur.connection, f'"{table}"', column, "TEXT NOT NULL DEFAULT ''")
index_name = f"{prefix}_uvery_hjmeno"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (hjmeno COLLATE NOCASE)
""")
def init_clients_schema(prefix: str, cur):
table = f"{prefix}_clients"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
prn_no TEXT NOT NULL DEFAULT '',
room_name TEXT NOT NULL DEFAULT ''
)
""")
for column in ("id_kas", "client_id", "prn_no", "room_name"):
add_column_if_not_exists(cur.connection, f'"{table}"', column, "TEXT NOT NULL DEFAULT ''")
index_name = f"{prefix}_clients_id_kas__client_id"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas COLLATE NOCASE, client_id COLLATE NOCASE)
""")
def init_recepcia_schema(prefix: str, cur):
table = f"{prefix}_recepcia"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hotel TEXT NOT NULL,
hor_ip TEXT NOT NULL,
hor_port TEXT NOT NULL,
hor_meno TEXT NOT NULL,
hor_heslo TEXT NOT NULL,
api_meno TEXT NOT NULL,
api_heslo TEXT NOT NULL,
typ_hotel int NOT NULL,
hor_prefix TEXT NOT NULL
)
""")
index_name = f"{prefix}_recepcia_id"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id)
""")
def init_hotplatby_schema(prefix: str, cur):
table = f"{prefix}_hotplatby"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_hotel int,
druh_pl text not null,
hot_platba_id int,
hot_karta_id int,
hot_platba str,
hot_karta str,
po_uctoch int,
payment text,
id_meny int
)
""")
index_name = f"{prefix}_hotplatby_id"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_hotel, druh_pl)
""")
def init_mewsdph_schema(prefix: str, cur):
table = f"{prefix}_mewsdph"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_hotel int,
mews_taxrate text,
koefdph float
)
""")
index_name = f"{prefix}_mewsdph_id"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_hotel, koefdph)
""")
def init_hotrastre_schema(prefix: str, cur):
table = f"{prefix}_hotrastre"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
id_hotel int,
c_druh int,
raster1 int,
raster2 int,
raster3 int,
raster4 int,
raster5 int,
raster6 int,
raster7 int,
raster8 int,
raster9 int,
dph1 float,
dph2 float,
dph3 float,
dph4 float,
dph5 float,
dph6 float,
dph7 float,
dph8 float,
dph9 float,
tmatr TEXT NOT NULL,
budova TEXT NOT NULL
)
""")
index_name = f"{prefix}_hot_rastre_idkas_druh_hotel_tmatr_budova"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas, c_druh, id_hotel, tmatr, budova)
""")
# pokladna
index_name = f"{prefix}_hot_rastre_idkas"
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{index_name}"
ON "{table}"(id_kas)
""")
def init_mewsrastre_schema(prefix: str, cur):
table = f"{prefix}_mewsrastre"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
id_hotel int,
c_druh int,
raster1 text,
raster2 text,
raster3 text,
raster4 text,
raster5 text,
raster6 text,
raster7 text,
raster8 text,
raster9 text,
dph1 float,
dph2 float,
dph3 float,
dph4 float,
dph5 float,
dph6 float,
dph7 float,
dph8 float,
dph9 float,
tmatr TEXT NOT NULL,
budova TEXT NOT NULL
)
""")
index_name = f"{prefix}_mewsrastre_idkas_druh_hotel_tmatr_budova"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas, c_druh, id_hotel, tmatr, budova)
""")
# pokladna
index_name = f"{prefix}_mewsrastre_idkas"
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{index_name}"
ON "{table}"(id_kas)
""")
def init_fidrastre_schema(prefix: str, cur):
table = f"{prefix}_fidrastre"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
id_hotel int,
c_druh int,
raster text,
raster1 text,
raster2 text,
raster3 text,
raster4 text,
raster5 text,
raster6 text,
raster7 text,
raster8 text,
raster9 text,
dph1 float,
dph2 float,
dph3 float,
dph4 float,
dph5 float,
dph6 float,
dph7 float,
dph8 float,
dph9 float,
tmatr TEXT NOT NULL,
budova TEXT NOT NULL
)
""")
index_name = f"{prefix}_fidrastre_idkas_druh_hotel_tmatr_budova"
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}"
ON "{table}" (id_kas, c_druh, id_hotel, tmatr, budova)
""")
# pokladna
index_name = f"{prefix}_fidrastre_idkas"
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{index_name}"
ON "{table}"(id_kas)
""")
def init_tab_uct(prefix: str, cur):
table = f"{prefix}_ucty"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
ucty_id INTEGER PRIMARY KEY AUTOINCREMENT,
ucislo TEXT,
id_kas TEXT NOT NULL,
stul TEXT,
room_name TEXT,
blocked_by TEXT,
closed_at TEXT,
c_uzaverka INTEGER,
data TEXT NOT NULL
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if "c_uzaverka" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN c_uzaverka INTEGER')
if "room_name" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN room_name TEXT')
# ucislo jen pro uzavřené účty
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_ucty_ucislo
ON "{table}"(ucislo)
WHERE ucislo IS NOT NULL AND TRIM(ucislo) != ''
""")
# rychlé hledání otevřeného účtu
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_ucty_open_stul
ON "{table}"(stul)
WHERE closed_at IS NULL OR TRIM(closed_at) = ''
""")
# pokladna
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_ucty_kas
ON "{table}"(id_kas)
""")
index_name = f"{prefix}_ucty_uzaverka"
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{index_name}"
ON "{table}"(id_kas, c_uzaverka, ucislo)
""")
index_name = f"{prefix}_ucty_room_closed"
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{index_name}"
ON "{table}"(id_kas, room_name, closed_at)
""")
def ensure_ucty_room_name_schema(prefix: str, cur) -> None:
table = f"{prefix}_ucty"
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if "room_name" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN room_name TEXT')
index_name = f"{prefix}_ucty_room_closed"
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{index_name}"
ON "{table}"(id_kas, room_name, closed_at)
""")
def backfill_ucty_closure_links(prefix: str, cur):
table_ucty = f"{prefix}_ucty"
table_clsrep = f"{prefix}_clsrep"
cur.execute(f"""
UPDATE "{table_ucty}" AS u
SET c_uzaverka = (
SELECT c.clsrep_id
FROM "{table_clsrep}" c
WHERE c.id_kas = u.id_kas
AND u.ucislo >= c.ucislo_st
AND u.ucislo <= c.ucislo_end
ORDER BY c.clsrep_id DESC
LIMIT 1
)
WHERE (u.c_uzaverka IS NULL OR u.c_uzaverka = 0)
AND u.closed_at IS NOT NULL
AND TRIM(COALESCE(u.ucislo, '')) != ''
AND EXISTS (
SELECT 1
FROM "{table_clsrep}" c
WHERE c.id_kas = u.id_kas
AND u.ucislo >= c.ucislo_st
AND u.ucislo <= c.ucislo_end
)
""")
#Milan 15.04.26 - doplnene polia
def add_column_if_not_exists(conn, table: str, column: str, col_def: str):
cur = conn.cursor()
cur.execute(f"PRAGMA table_info({table})")
columns = [row[1] for row in cur.fetchall()]
if column not in columns:
cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_def}")
conn.commit()
def drop_column_if_exists(conn, table: str, column: str):
cur = conn.cursor()
cur.execute(f"PRAGMA table_info([{table}])")
columns = [row[1] for row in cur.fetchall()]
if column in columns:
cur.execute(f"ALTER TABLE [{table}] DROP COLUMN {column}")
conn.commit()
def init_tab_users(prefix: str, cur: sqlite3.Cursor):
table = f'"{prefix}_users"'
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL UNIQUE,
jazyk TEXT NOT NULL DEFAULT 'sk',
heslo TEXT NOT NULL,
heslo_karta TEXT NOT NULL,
is_admin BOOL NOT NULL DEFAULT False,
permits TEXT NOT NULL,
payments TEXT NOT NULL,
discounts TEXT NOT NULL,
levels TEXT NOT NULL
)
""")
add_column_if_not_exists(cur.connection, table, "user_id", "TEXT NOT NULL UNIQUE")
add_column_if_not_exists(cur.connection, table, "jazyk", "TEXT NOT NULL DEFAULT 'sk'")
add_column_if_not_exists(cur.connection, table, "heslo_karta", "TEXT NOT NULL DEFAULT 'asdadada'")
add_column_if_not_exists(cur.connection, table, "payments", "TEXT NOT NULL DEFAULT '[]'")
add_column_if_not_exists(cur.connection, table, "discounts", "TEXT NOT NULL DEFAULT '[]'")
add_column_if_not_exists(cur.connection, table, "levels", "TEXT NOT NULL DEFAULT '[]'")
add_column_if_not_exists(cur.connection, table, "is_admin", "BOOL NOT NULL DEFAULT False")
cur.execute(f"SELECT COUNT(*) FROM {table}")
count = cur.fetchone()[0]
if count == 0:
cur.execute(
f"""
INSERT INTO {table} (user_id, name, jazyk, heslo, heslo_karta, is_admin, permits, payments, discounts, levels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"SUP",
"AltoAdmin",
"sk",
"123",
"asdfasfgasf",
True,
json.dumps([]),
json.dumps([]),
json.dumps([]),
json.dumps([])
)
)
def init_tab_cen(prefix: str, cur) -> None:
table = f"{prefix}_cenik"
cur.execute( f''' CREATE TABLE IF NOT EXISTS "{table}" (id INTEGER PRIMARY KEY AUTOINCREMENT,
pokl TEXT NOT NULL, id_card INTEGER NOT NULL DEFAULT 0, c_druh INTEGER NOT NULL DEFAULT 0,
druh TEXT NOT NULL DEFAULT '', spart TEXT NOT NULL DEFAULT '',
prn_no TEXT NOT NULL DEFAULT '', data TEXT NOT NULL) ''' )
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if "id_card" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN id_card INTEGER NOT NULL DEFAULT 0')
if "c_druh" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN c_druh INTEGER NOT NULL DEFAULT 0')
if "druh" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN druh TEXT NOT NULL DEFAULT \'\'')
if "spart" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN spart TEXT NOT NULL DEFAULT \'\'')
if "prn_no" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN prn_no TEXT NOT NULL DEFAULT \'\'')
cur.execute( f'CREATE INDEX IF NOT EXISTS idx_{table}_pokl ON "{table}" (pokl)' )
cur.execute( f'CREATE INDEX IF NOT EXISTS idx_{table}_card ON "{table}" (id_card)' )
cur.execute( f'CREATE INDEX IF NOT EXISTS idx_{table}_prn ON "{table}" (pokl, prn_no)' )
def init_cenik_texty_schema(prefix: str, cur) -> None:
table = f"{prefix}_cenik_texty"
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if columns and ("pokl" in columns or "popis" in columns):
_rebuild_cenik_texty_schema(prefix, cur)
return
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_card INTEGER NOT NULL DEFAULT 0,
jazyk TEXT NOT NULL DEFAULT 'sk',
d_name TEXT NOT NULL DEFAULT '',
ch_name TEXT NOT NULL DEFAULT '',
dat_cas_zm TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{{}}'
)
""")
for column, col_def in (
("id_card", "INTEGER NOT NULL DEFAULT 0"),
("jazyk", "TEXT NOT NULL DEFAULT 'sk'"),
("d_name", "TEXT NOT NULL DEFAULT ''"),
("ch_name", "TEXT NOT NULL DEFAULT ''"),
("dat_cas_zm", "TEXT NOT NULL DEFAULT ''"),
("data", "TEXT NOT NULL DEFAULT '{}'"),
):
add_column_if_not_exists(cur.connection, f'"{table}"', column, col_def)
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang_uq"
ON "{table}" (id_card, jazyk)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang"
ON "{table}" (id_card, jazyk)
""")
def _rebuild_cenik_texty_schema(prefix: str, cur) -> None:
table = f"{prefix}_cenik_texty"
backup = f"{table}_old"
cur.execute(f'DROP TABLE IF EXISTS "{backup}"')
cur.execute(f'ALTER TABLE "{table}" RENAME TO "{backup}"')
cur.execute(f'DROP INDEX IF EXISTS "{prefix}_cenik_texty_pokl_card_lang"')
cur.execute(f'DROP INDEX IF EXISTS "{prefix}_cenik_texty_card_lang"')
cur.execute(f'DROP INDEX IF EXISTS "{prefix}_cenik_texty_card_lang_uq"')
cur.execute(f"""
CREATE TABLE "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_card INTEGER NOT NULL DEFAULT 0,
jazyk TEXT NOT NULL DEFAULT 'sk',
d_name TEXT NOT NULL DEFAULT '',
ch_name TEXT NOT NULL DEFAULT '',
dat_cas_zm TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{{}}'
)
""")
cur.execute(f'PRAGMA table_info("{backup}")')
old_columns = {row[1] for row in cur.fetchall()}
select_columns = [
"id_card",
"jazyk",
"d_name",
"ch_name",
"dat_cas_zm",
]
if all(column in old_columns for column in select_columns):
cur.execute(
f'''
SELECT id_card, jazyk, d_name, ch_name, dat_cas_zm
FROM "{backup}"
ORDER BY id
'''
)
rows = {}
for id_card, jazyk, d_name, ch_name, dat_cas_zm in cur.fetchall():
item = data.CenikText(
id_card=int(id_card or 0),
jazyk=jazyk or "sk",
d_name=d_name or "",
ch_name=ch_name or "",
dat_cas_zm=dat_cas_zm or "",
)
rows[(item.id_card, item.jazyk)] = (
item.id_card,
item.jazyk,
item.d_name,
item.ch_name,
item.dat_cas_zm,
item.model_dump_json(),
)
cur.executemany(
f'''
INSERT OR REPLACE INTO "{table}" (id_card, jazyk, d_name, ch_name, dat_cas_zm, data)
VALUES (?, ?, ?, ?, ?, ?)
''',
list(rows.values()),
)
cur.execute(f'DROP TABLE IF EXISTS "{backup}"')
cur.execute(f"""
CREATE UNIQUE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang_uq"
ON "{table}" (id_card, jazyk)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang"
ON "{table}" (id_card, jazyk)
""")
def init_cook_items_schema(prefix: str, cur) -> None:
table = f"{prefix}_cook_items"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_kas TEXT NOT NULL,
stul TEXT NOT NULL DEFAULT '',
room_name TEXT NOT NULL DEFAULT '',
pos_name TEXT NOT NULL DEFAULT '',
waiter_name TEXT NOT NULL DEFAULT '',
receipt_no TEXT,
bon_no INTEGER NOT NULL DEFAULT 0,
bon_date TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL DEFAULT 'bon',
status TEXT NOT NULL DEFAULT 'new',
id_card INTEGER NOT NULL DEFAULT 0,
c_druh INTEGER NOT NULL DEFAULT 0,
druh TEXT NOT NULL DEFAULT '',
prn_no TEXT NOT NULL DEFAULT '',
line_id TEXT NOT NULL DEFAULT '',
group_id TEXT NOT NULL DEFAULT '',
item_name TEXT NOT NULL DEFAULT '',
quantity REAL NOT NULL DEFAULT 0,
delitel INTEGER NOT NULL DEFAULT 1,
messages TEXT NOT NULL DEFAULT '[]',
ordered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload TEXT NOT NULL DEFAULT '{{}}'
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
columns = {row[1] for row in cur.fetchall()}
if "bon_no" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN bon_no INTEGER NOT NULL DEFAULT 0')
if "bon_date" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN bon_date TEXT NOT NULL DEFAULT \'\'')
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_cook_items_status"
ON "{table}" (status, ordered_at)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_cook_items_kas_prn"
ON "{table}" (id_kas, prn_no, status)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS "{prefix}_cook_items_bon"
ON "{table}" (prn_no, bon_date, bon_no)
""")
def default_payment_types() -> list[data.PaymentType]:
return [
data.PaymentType(
code="CASH",
name="Hotově",
unit="CZK",
rate=1.,allow_partial=True, is_cash=True,
handler=None,color=None, is_default=True,
),
data.PaymentType(
code="CARD",
name="Karta",
unit="CZK",
poradie=20,
rate=1.,allow_partial=True, is_cash=False,
handler=None, #charge_kredit_c.py,
color=None, is_default=False, is_bankterm=True,
),
data.PaymentType(
code="EURO",
name="Euro",
unit="EUR",
poradie=30,
rate=25.,allow_partial=True, is_cash=True,
handler=None,color=None, is_default=False,
),
]
def _payment_to_db_tuple(id_kas: str, payment: data.PaymentType) -> tuple:
p_kopii = getattr(payment, "p_kopii", 1)
return (
id_kas,
payment.code,
payment.name,
payment.unit,
payment.rate,
payment.poradie,
max(int(p_kopii if p_kopii is not None else 1), 0),
int(getattr(payment, "round50", 0) or 0),
int(bool(payment.allow_partial)),
int(bool(payment.is_cash)),
payment.handler,
payment.color,
int(bool(payment.is_default)),
int(bool(payment.fiscal)),
int(bool(getattr(payment, "is_bankterm", False))),
int(getattr(payment, "odvod", 0) or 0),
str(getattr(payment, "odovzdat", "") or ""),
)
def _row_to_payment_type(row) -> data.PaymentType:
(
code,
name,
unit,
rate,
poradie,
p_kopii,
round50,
allow_partial,
is_cash,
handler,
color,
is_default,
fiscal,
is_bankterm,
odvod,
odovzdat,
) = row
return data.PaymentType(
code=code,
name=name,
unit=unit,
rate=rate,
poradie=poradie or 0,
p_kopii=max(int(p_kopii or 0), 0),
round50=int(round50 or 0),
allow_partial=bool(allow_partial),
is_cash=bool(is_cash),
handler=handler or None,
color=color or None,
is_default=bool(is_default),
fiscal=bool(fiscal),
is_bankterm=bool(is_bankterm),
odvod=int(odvod or 0),
odovzdat=str(odovzdat or ""),
)
def _insert_platby_cur(
cur,
table: str,
id_kas: str,
payments: list[data.PaymentType],
replace_existing: bool = True,
) -> None:
if replace_existing:
cur.execute(f'DELETE FROM "{table}" WHERE id_kas=?', (id_kas,))
cur.executemany(
f"""
INSERT OR REPLACE INTO "{table}" (
id_kas, code, name, unit, rate, poradie, p_kopii, round50,
allow_partial, is_cash, handler, color, is_default, fiscal, is_bankterm, odvod, odovzdat
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[_payment_to_db_tuple(id_kas, payment) for payment in payments],
)
def init_platby_schema(prefix: str, cur) -> None:
table = f"{prefix}_platby"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id_kas TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
unit TEXT NOT NULL,
rate REAL NOT NULL DEFAULT 1,
poradie INTEGER NOT NULL DEFAULT 0,
p_kopii INTEGER NOT NULL DEFAULT 1,
round50 INTEGER NOT NULL DEFAULT 0,
allow_partial INTEGER NOT NULL DEFAULT 1,
is_cash INTEGER NOT NULL DEFAULT 0,
handler TEXT,
color TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
fiscal INTEGER NOT NULL DEFAULT 1,
is_bankterm INTEGER NOT NULL DEFAULT 0,
odvod INTEGER NOT NULL DEFAULT 0,
odovzdat TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id_kas, code)
)
""")
cur.execute(f'PRAGMA table_info("{table}")')
columns = {str(row[1]) for row in cur.fetchall()}
if "p_kopii" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN p_kopii INTEGER NOT NULL DEFAULT 1')
if "round50" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN round50 INTEGER NOT NULL DEFAULT 0')
if "is_bankterm" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN is_bankterm INTEGER NOT NULL DEFAULT 0')
if "odvod" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN odvod INTEGER NOT NULL DEFAULT 0')
if "odovzdat" not in columns:
cur.execute(f'ALTER TABLE "{table}" ADD COLUMN odovzdat TEXT NOT NULL DEFAULT ""')
cur.execute(f'UPDATE "{table}" SET p_kopii=1 WHERE p_kopii IS NULL OR p_kopii < 0')
cur.execute(f'UPDATE "{table}" SET round50=0 WHERE round50 IS NULL')
cur.execute(f'UPDATE "{table}" SET odvod=0 WHERE odvod IS NULL')
cur.execute(f'UPDATE "{table}" SET odovzdat="" WHERE odovzdat IS NULL')
seed_platby_from_setup_if_empty(prefix, cur)
def seed_platby_from_setup_if_empty(prefix: str, cur) -> None:
setup_table = f"{prefix}_setup"
platby_table = f"{prefix}_platby"
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(setup_table,),
)
if not cur.fetchone():
return
cur.execute(f'SELECT id_kas, data FROM "{setup_table}"')
for id_kas, raw_json in cur.fetchall():
cur.execute(
f'SELECT COUNT(*) FROM "{platby_table}" WHERE id_kas=?',
(id_kas,),
)
has_payments = cur.fetchone()[0] > 0
try:
setup = data.PosSetup.model_validate_json(raw_json)
except Exception as e:
logger.error(f"Invalid setup JSON while migrating payments {prefix}/{id_kas}: {e}")
setup = data.PosSetup(id_kas=id_kas)
if not has_payments:
payments = setup.platby or default_payment_types()
_insert_platby_cur(
cur,
platby_table,
id_kas,
payments,
replace_existing=True,
)
if setup.platby:
setup.platby = []
normalized_json = json.dumps(
setup.model_dump(exclude_none=False),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
cur.execute(
f'UPDATE "{setup_table}" SET data=? WHERE id_kas=?',
(normalized_json, id_kas),
)
def init_tab_setup(prefix: str, cur, zakazka: str) -> None:
table = f"{prefix}_setup"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id_kas TEXT PRIMARY KEY,
data TEXT NOT NULL
)
""")
setup = data.PosSetup(
pos_name=f"POS {zakazka}",
id_kas="01",
pokladna="Hlavni",
allow_price_edit=True,
offline_allowed=True,
platby=[],
messages=[],
)
normalized_json = json.dumps(
setup.model_dump(exclude_none=False),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=False,
)
cur.execute(
f'INSERT OR IGNORE INTO "{table}" (id_kas, data) VALUES (?, ?)',
(setup.id_kas, normalized_json),
)
def init_setup_parameters_schema(prefix: str, cur) -> None:
table = f"{prefix}_setup_parameters"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id_kas TEXT NOT NULL,
var_name TEXT NOT NULL,
var_value TEXT NOT NULL,
var_type TEXT NOT NULL,
PRIMARY KEY (id_kas, var_name)
)
""")
def init_postgres_connection_schema(prefix: str, cur) -> None:
table = f"{prefix}_postgres_connection"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 5432,
database_name TEXT NOT NULL DEFAULT '',
user_name TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
schema_name TEXT NOT NULL DEFAULT 'food600',
sslmode TEXT NOT NULL DEFAULT 'prefer',
connect_timeout INTEGER NOT NULL DEFAULT 5,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
cur.execute(f"""
INSERT OR IGNORE INTO "{table}" (
id, enabled, host, port, database_name, user_name, password,
schema_name, sslmode, connect_timeout
)
VALUES (1, 0, '', 5432, '', '', '', 'food600', 'prefer', 5)
""")
def table_exists(table_name: str) -> bool:
with get_db() as conn:
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,) )
return cur.fetchone() is not None
# -----------------------------------------------------
# FastApi Authentificacion
# -----------------------------------------------------
def new_access_token() -> str:
return secrets.token_urlsafe(32)
def new_refresh_token() -> str:
return secrets.token_urlsafe(48)
def access_expiry():
return datetime.utcnow() + timedelta(minutes=ACCESS_MINUTES)
def refresh_expiry():
return datetime.utcnow() + timedelta(days=REFRESH_DAYS)
@contextmanager
def get_db():
conn = sqlite3.connect(dbt)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def auth_ctx(
authorization: str = Header(None),
x_client_id: str = Header(None)
) -> tuple[str, str, str]:
return check_auth(authorization, x_client_id)
def check_auth(auth: str, client_id: str) -> tuple[str, str, str]:
if not auth or not auth.startswith("Bearer "):
raise HTTPException(401, "Missing Bearer token")
if not client_id:
raise HTTPException(401, "Missing X-Client-ID")
token = auth[7:]
with get_db() as conn:
cur = conn.cursor()
cur.execute("""
SELECT prefix, user
FROM tokens
WHERE access_token=? AND client_id=?
AND expires_at > CURRENT_TIMESTAMP
""", (token, client_id))
row = cur.fetchone()
if not row:
raise HTTPException(401, "Invalid or expired token")
cur.execute("""
UPDATE tokens SET last_seen=CURRENT_TIMESTAMP
WHERE access_token=? AND client_id=?
""", (token, client_id))
return row[0], row[1], client_id # prefix, user, client_id
@app.post("/login/")
def login(data: data.LoginData,
x_client_id: str = Header(None)):
if not x_client_id:
raise HTTPException(400, "Missing X-Client-ID")
with get_db() as conn:
cur = conn.cursor()
# Ověření zakázky
cur.execute(
"SELECT id, heslo, jmeno_zakazky FROM zakazky WHERE uzivatel=?",
(data.username,)
)
row = cur.fetchone()
if not row or data.password != p.decode(row[1]):
raise HTTPException(401, "Bad credentials")
prefix = f"{row[0]:05d}"
zakazka = row[2]
# Inicializace schématu
init_user_schema(cur, prefix, zakazka)
# Cleanup mrtvých klientů (heartbeat)
cleanup_dead_clients_cur(cur, prefix, data.id_kas)
# Kontrola duplicitního aktivního klienta
cur.execute("""
SELECT 1
FROM heartbeat_clients
WHERE prefix=? AND id_kas=? AND client_id=?
""", (prefix, data.id_kas, x_client_id))
if cur.fetchone():
raise HTTPException(
409,
"Tento terminál je již aktivní"
)
# Registrace klienta do heartbeat
now = time.time()
cur.execute("""
INSERT INTO heartbeat_clients
(prefix, id_kas, client_id, last_seen)
VALUES (?, ?, ?, ?)
""", (prefix, data.id_kas, x_client_id, now))
# Token
acc = new_access_token()
ref = new_refresh_token()
cur.execute("""
INSERT INTO tokens
(user, client_id, prefix,
access_token, refresh_token,
expires_at, refresh_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user, client_id)
DO UPDATE SET
access_token=excluded.access_token,
refresh_token=excluded.refresh_token,
expires_at=excluded.expires_at,
refresh_expires_at=excluded.refresh_expires_at,
last_seen=CURRENT_TIMESTAMP
""", (
data.username,
x_client_id,
prefix,
acc,
ref,
access_expiry(),
refresh_expiry()
))
conn.commit()
return {
"access_token": acc,
"refresh_token": ref,
"token_type": "Bearer",
"version_API": version,
"database_name": dbt,
}
@app.post("/refresh/")
def refresh(
req: data.RefreshRequest,
x_client_id: str = Header(None)):
if not x_client_id:
raise HTTPException(400, "Missing X-Client-ID")
if not req.refresh_token.startswith("Bearer "):
raise HTTPException(401, "Missing Bearer prefix")
raw = req.refresh_token[7:]
with get_db() as conn:
cur = conn.cursor()
cur.execute("""
SELECT user
FROM tokens
WHERE refresh_token=? AND client_id=? AND refresh_expires_at > CURRENT_TIMESTAMP
""", (raw, x_client_id))
row = cur.fetchone()
if not row:
raise HTTPException(401, "Invalid refresh token")
new_acc = new_access_token()
cur.execute("""
UPDATE tokens
SET access_token=?, expires_at=?, last_seen=CURRENT_TIMESTAMP
WHERE client_id=?
""", (new_acc, access_expiry(), x_client_id))
return {"access_token": new_acc}
@app.post("/logout/")
def logout(
id_kas: str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
heartbeat_remove(prefix, id_kas, client_id)
token_remove(prefix, client_id)
return {"ok": True}
def token_remove(prefix: str, client_id: str):
with get_db() as conn:
cur = conn.cursor()
cur.execute("""
DELETE FROM tokens
WHERE prefix=? AND client_id=?
""", (prefix, client_id))
conn.commit()
#-------------------------
# heartbeat
#-------------------------
@app.post("/heartbeat/")
def heartbeat(
id_kas: str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
return heartbeat_upsert(prefix, id_kas, client_id, user)
def heartbeat_upsert(
prefix: str,
id_kas: str,
client_id: str,
user: str | None = None,
):
now = time.time()
with get_db() as conn:
cur = conn.cursor()
cur.execute("""
INSERT INTO heartbeat_clients
(prefix, id_kas, client_id, user, last_seen)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(prefix, id_kas, client_id)
DO UPDATE SET
user=excluded.user,
last_seen=excluded.last_seen
""", (
prefix,
id_kas,
client_id,
user,
now
))
conn.commit()
return {"ok": True}
def heartbeat_remove(prefix: str, id_kas: str, client_id: str):
with get_db() as conn:
cur = conn.cursor()
# uvolni zablokované účty
table = f"{prefix}_ucty"
cur.execute(f"""
UPDATE "{table}"
SET blocked_by = NULL
WHERE id_kas = ?
AND blocked_by LIKE ?
""", (id_kas, f"{client_id}|%"))
# smaž heartbeat
cur.execute("""
DELETE FROM heartbeat_clients
WHERE prefix=? AND id_kas=? AND client_id=?
""", (prefix, id_kas, client_id))
conn.commit()
def find_dead_clients(
prefix: str,
id_kas: str,
*,
now: float | None = None,
) -> list[tuple[str, str]]:
now = now or time.time()
with get_db() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT id_kas, client_id
FROM heartbeat_clients
WHERE prefix=?
AND id_kas=?
AND (? - last_seen) > ?
""",
(prefix, id_kas, now, HEARTBEAT_TIMEOUT),
)
return cur.fetchall()
def unblock_accounts_of_dead_client(prefix: str, id_kas: str, client_id: str):
table = f"{prefix}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
UPDATE "{table}"
SET blocked_by = NULL
WHERE id_kas = ?
AND blocked_by LIKE ?
""",
(id_kas, f"{client_id}|%"),
)
def remove_dead_client(prefix: str, id_kas: str, client_id: str):
with get_db() as conn:
cur = conn.cursor()
cur.execute("""
DELETE FROM heartbeat_clients
WHERE prefix=? AND id_kas=? AND client_id=?
""", (prefix, id_kas, client_id))
def cleanup_dead_clients(prefix: str, id_kas: str):
dead = find_dead_clients(prefix, id_kas=id_kas)
for _, client_id in dead:
logger.warning(
f"Heartbeat timeout → releasing client {client_id} ({id_kas})"
)
unblock_accounts_of_dead_client(prefix, id_kas, client_id)
remove_dead_client(prefix, id_kas, client_id)
def find_dead_clients_cur(
cur,
prefix: str,
id_kas: str,
*,
now: float,
) -> list[tuple[str, str]]:
cur.execute(
"""
SELECT id_kas, client_id
FROM heartbeat_clients
WHERE prefix=?
AND id_kas=?
AND (? - last_seen) > ?
""",
(prefix, id_kas, now, HEARTBEAT_TIMEOUT),
)
return cur.fetchall()
def unblock_accounts_of_dead_client_cur(cur, prefix: str, id_kas: str, client_id: str):
table = f"{prefix}_ucty"
cur.execute(
f"""
UPDATE "{table}"
SET blocked_by = NULL
WHERE id_kas = ?
AND blocked_by LIKE ?
""",
(id_kas, f"{client_id}|%"),
)
def remove_dead_client_cur(cur, prefix: str, id_kas: str, client_id: str):
cur.execute(
"""
DELETE FROM heartbeat_clients
WHERE prefix=? AND id_kas=? AND client_id=?
""",
(prefix, id_kas, client_id),
)
def cleanup_dead_clients_cur(cur, prefix: str, id_kas: str, *, now: float | None = None):
now = now or time.time()
dead = find_dead_clients_cur(cur, prefix, id_kas, now=now)
for _, client_id in dead:
logger.warning(f"Heartbeat timeout → releasing client {client_id} ({id_kas})")
unblock_accounts_of_dead_client_cur(cur, prefix, id_kas, client_id)
remove_dead_client_cur(cur, prefix, id_kas, client_id)
return dead # volitelné (pro log / debug)
# ---------------------------------------------------------------------------------------
# L.L. (22.06.2026 Aktualizácia cenníka, nastavení pokladne a mapy stolov
# na zaklade zmeny datumu a času poslednej zmeny v s_zmeny
# ---------------------------------------------------------------------------------------
@app.get("/poslednaZmenaDatFoodMan", response_model=data.FoodManDataChange)
def posledna_zmena_dat_foodman(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET poslednaZmenaDatFoodMan: prefix={prefix} pokladna={id_kas}")
with get_db() as conn:
cur = conn.cursor()
return ensure_foodman_data_change_cur(cur, prefix, id_kas)
# -------------------------------------
# --- users
# -------------------------------------
@app.get("/users/raw/")
def load_users_api(
id_kas: str = "",
auth: tuple[str, str, str] = Depends(auth_ctx),
):
try:
prefix, _, _ = auth
with get_db() as conn:
users = load_users_table(conn, prefix)
return users
except Exception as e:
return {"ok": False, "error": str(e)}
#Milan 15.04.26
def load_users_table(conn, prefix: str) -> list[dict]:
table = f'"{prefix}_users"'
cur = conn.cursor()
cur.execute(f"""
SELECT id, name, permits, user_id, payments, discounts, is_admin, levels, jazyk
FROM {table}
ORDER BY id
""")
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"id": r[0],
"name": r[1],
"permits": json.loads(r[2] or "[]"),
"user_id": r[3],
"payments": json.loads(r[4] or "[]"),
"discounts": json.loads(r[5] or "[]"),
"is_admin": r[6],
"levels": json.loads(r[7] or "[]"),
"jazyk": r[8] or "sk",
})
return out
@app.post("/users/reset/")
def reset_users_api(users: list[data.UserIn] = Body(...), id_kas: str = "",
auth: tuple[str, str, str] = Depends(auth_ctx),):
try:
prefix, _, _ = auth
if not users:
raise ValueError("Seznam uživatelů je prázdný")
with get_db() as conn:
#try:
# reset_users_table(conn, prefix, users)
#finally:
# conn.close()
reset_users_table(conn, prefix, users)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
#Milan 15.04.26
def reset_users_table(conn, prefix: str, users: list[data.UserIn]):
table = f'"{prefix}_users"'
cur = conn.cursor()
try:
# DROP
cur.execute(f"DROP TABLE IF EXISTS {table}")
except Exception as e:
print("INSERT ERROR:", e)
raise
try:
# CREATE
cur.execute(f"""
CREATE TABLE {table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL UNIQUE,
jazyk TEXT NOT NULL DEFAULT 'sk',
heslo TEXT NOT NULL,
heslo_karta TEXT NOT NULL,
is_admin BOOL NOT NULL DEFAULT False,
permits TEXT NOT NULL,
payments TEXT NOT NULL,
discounts TEXT NOT NULL,
levels TEXT NOT NULL
)
""")
except Exception as e:
print("INSERT ERROR:", e)
raise
# INSERT
for u in users:
try:
cur.execute(f"""
INSERT INTO {table} (name, user_id, jazyk, heslo, heslo_karta, is_admin, permits, payments, discounts, levels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (
u.name,
u.user_id,
u.jazyk,
u.heslo,
u.heslo_karta,
u.is_admin,
json.dumps([p.model_dump() for p in (u.permits or [])]),
json.dumps([p.model_dump() for p in (u.payments or [])]),
json.dumps([p.model_dump() for p in (u.discounts or [])]),
json.dumps([p.model_dump() for p in (u.levels or [])]),
))
except Exception as e:
print("INSERT ERROR:", e)
raise
cur.execute(f"SELECT COUNT(*) FROM {table}")
print("POCET VLOZENYCH USERU:", cur.fetchone())
conn.commit()
def init_limit_locks_schema(prefix: str, cur) -> None:
table = f"{prefix}_limit_locks"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
lock_key TEXT PRIMARY KEY,
id_kas TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
user TEXT NOT NULL DEFAULT '',
id_limit INTEGER NOT NULL DEFAULT 0,
id_den INTEGER NOT NULL DEFAULT 0,
locked_at REAL NOT NULL DEFAULT 0
)
""")
#Milan 15.04.26 - doplneny kas ako parameter a doplnene nacitanie permitions, payments, discounts a levels pre aktualnu kasu
def login_user_db(cur: sqlite3.Cursor, prefix: str, heslo: str, kas: str, pl1:data.PaymentType) -> data.UserLoginOut | None:
heslo = heslo.strip()
table = f'"{prefix}_users"'
#print(f"heslo {heslo}\ntable {table}\n")
cur.execute(
f"""
SELECT id, name, user_id, jazyk, is_admin, permits, payments, discounts, levels FROM {table} WHERE heslo = ? or heslo_karta = ?
""",
(heslo,heslo))
row = cur.fetchone()
if not row:
return None
id_, name_, user_id_, jazyk_, is_admin_, permits_, payments_, discounts_, levels_ = row
permits_data = json.loads(permits_ or "[]")
payments_data = json.loads(payments_ or "[]")
discounts_data = json.loads(discounts_ or "[]")
levels_data = json.loads(levels_ or "[]")
permits=[
p
for block in permits_data
if block["id_kas"] == kas
for p in block["permits"]
]
payments=[
p
for block in payments_data
if block["id_kas"] == kas
for p in block["payments"]
]
platby= [
p for p in pl1
if p.code in payments
]
if not platby:
platby = [p for p in pl1 if p.is_default]
discounts=[
d
for block in discounts_data
if block["id_kas"] == kas
for d in block["discounts"]
]
levels=[
l
for block in levels_data
if block["id_kas"] == kas
for l in block["levels"]
]
logger.info(f"User login: prefix={prefix} name={name_} permits={permits_}")
return data.UserLoginOut(
id=id_, name=name_, user_id=user_id_, jazyk=jazyk_ or "sk", is_admin=is_admin_, permits=permits, payments=platby, discounts=discounts, levels=levels)
def all_permits() -> list[str]:
return [p.code for p in data.Perm]
def all_payments(prefix, id_kas: str) -> list[data.PaymentType]:
data=get_setup_platby_from_db(prefix, id_kas)
return data
def all_discounts(prefix, id_kas: str) -> list[str]:
zlavy=get_setup_discounts_from_db(prefix, id_kas)
return zlavy
#Milan 15.04.26 - nacitanie permitions aj s description podla jayzkovej mutacie ziadatela
def get_permits(lang: str = "sk") -> list[dict[str, str]]:
lang = normalize_lang(lang)
vysledok = []
for p in data.Perm:
match lang:
case "sk":
text = p.descriptionsk
case "cs":
text = p.descriptioncz
case "it":
text = p.descriptionit
case "en":
text = p.descriptionen
case "pl":
text = p.descriptionpl
vysledok.append({
"code": p.code,
"text": text
})
return vysledok
#Milan 15.04.26 - doplneny parameter, ktorym sa definuje pozadovana jazykova mutacia
@app.get("/permits/", response_model=list[data.PermitOut])
def get_permits_endpoint(lang: str = Query("sk", enum=["sk", "cs", "cz", "it", "en", "pl"])):
return JSONResponse(content=get_permits(lang),media_type="application/json; charset=utf-8")
#return get_permits(lang)
#Milan 15.04.26 - test slovenskeho pocitaneho hesla a doplnenie zoznamu povolenych platieb a zliav pre aktualnu kasu
@app.post("/users/login/", response_model=data.UserLoginOut)
def user_login(
login_data: data.UserLoginIn,
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, _, _ = auth
# ADMIN LOGIN (mimo DB)
platby=all_payments(prefix,login_data.kas)
discounts=all_discounts(prefix,login_data.kas)
levels=[]
if login_data.heslo == data.admheslo():
logger.info(f"Alto administrator LogIn {all_permits()}")
return data.UserLoginOut(
id=0,
name="AltoAdmin",
user_id="SUP",
jazyk="sk",
is_admin=True,
permits=all_permits(),
payments=platby,
discounts=discounts,
levels=levels
)
if login_data.heslo == data.admskheslo():
logger.info(f"Alto administrator LogIn {all_permits()}")
return data.UserLoginOut(
id=0,
name="AltoAdmin",
user_id="SUP",
jazyk="sk",
is_admin=True,
permits=all_permits(),
payments=platby,
discounts=discounts,
levels=levels
)
# NORMAL USER LOGIN
with get_db() as conn:
cur = conn.cursor()
cisnik = login_user_db(cur, prefix, login_data.heslo, login_data.kas, platby)
if not cisnik:
raise HTTPException(401, "Neplatné heslo")
return cisnik
#Milan 15.04.26
def get_users_db(cur: sqlite3.Cursor, prefix: str) -> list[data.UserOut]:
cur.execute(f"""
SELECT id, name, user_id, jazyk, is_admin, permits, payments, discounts, levels
FROM {prefix}_users
ORDER BY id
""")
rows = cur.fetchall()
return [
data.UserOut(
id=id_,
name=name,
user_id=user_id,
jazyk=jazyk or "sk",
is_admin=is_admin,
permits=json.loads(permits),
payments=json.loads(payments),
discounts=json.loads(discounts),
levels=json.loads(levels)
)
for id_, name, user_id, jazyk, is_admin, permits, payments, discounts, levels in rows
]
#Milan 15.04.26
def create_user_db(cur: sqlite3.Cursor, prefix: str, user: data.UserIn) -> int:
cur.execute(
f"""
INSERT INTO {prefix}_users (name, user_id, jazyk, heslo, heslo_karta, permits, payments, discounts, is_admin, levels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user.name,
user.user_id,
user.jazyk,
user.heslo,
user.heslo_karta,
json.dumps([p.model_dump() for p in (user.permits or [])]),
json.dumps([p.model_dump() for p in (user.payments or [])]),
json.dumps([p.model_dump() for p in (user.discounts or [])]),
user.is_admin,
json.dumps([p.model_dump() for p in (user.levels or [])])
)
)
return cur.lastrowid
def delete_user_db(cur: sqlite3.Cursor, prefix: str, user_id: int) -> bool:
cur.execute(
f"DELETE FROM {prefix}_users WHERE id = ?",
(user_id,)
)
return cur.rowcount > 0
@app.get("/users/", response_model=list[data.UserOut])
def get_users(
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, _, _ = auth
with get_db() as conn:
cur = conn.cursor()
return get_users_db(cur, prefix)
#Milan 15.04.26
@app.post("/users/", response_model=data.UserOut)
def create_user(
user: data.UserIn,
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, _, _ = auth
with get_db() as conn:
cur = conn.cursor()
try:
userid = create_user_db(cur, prefix, user)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, "Uživatel s tímto jménem již existuje")
return data.UserOut(
id=userid,
name=user.name,
user_id=user.user_id,
jazyk=user.jazyk,
is_admin=user.is_admin,
permits=user.permits,
payments=user.payments,
discounts=user.discounts,
levels=user.levels)
@app.delete("/users/{user_id}")
def delete_user(
user_id: int,
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, _, _ = auth
with get_db() as conn:
cur = conn.cursor()
ok = delete_user_db(cur, prefix, user_id)
conn.commit()
if not ok:
raise HTTPException(404, "Uživatel nenalezen")
return {"status": "deleted", "id": user_id}
# ------------------------
# setup
# -----------------------
PROTECTED_SETUP_PARAMETER_NAMES = {
"platby",
}
SETUP_PARAMETER_ALIASES = {
"def_cenhla": "default_price_level",
}
def ensure_min_payment_types(setup: data.PosSetup) -> bool:
changed = False
existing = {p.code for p in setup.platby}
for p in default_payment_types():
if p.code not in existing:
setup.platby.append(p)
changed = True
# zajistit jen jeden default
defaults = [p for p in setup.platby if p.is_default]
if len(defaults) > 1:
for p in defaults[1:]:
p.is_default = False
changed = True
return changed
def _import_parameters_path() -> Path:
return Path(__file__).with_name("import_parameters.json")
def _clean_import_parameter(item: dict) -> dict:
cleaned = dict(item)
var_type = cleaned.get("var_type") or cleaned.get("var_typ") or "C"
raw_value = (
cleaned["parsed_value"]
if "parsed_value" in cleaned
else cleaned.get("value")
)
normalized = data.normalize_setup_parameter_value(raw_value, var_type)
cleaned["value"] = normalized
cleaned["parsed_value"] = normalized
return cleaned
def load_import_parameter_definitions() -> list[dict]:
path = _import_parameters_path()
if not path.exists():
logger.warning(f"Import parameter file missing: {path}")
return []
with path.open("r", encoding="utf-8-sig") as f:
raw = json.load(f)
return [
_clean_import_parameter(item)
for item in raw
if isinstance(item, dict) and (item.get("key") or item.get("var_name"))
]
def get_default_setup_parameters() -> dict[str, data.SetupParameterValue]:
params = {}
for item in load_import_parameter_definitions():
param = data.SetupParameterValue.model_validate(item)
params[param.var_name] = param
return params
def _encode_setup_parameter_value(value, var_type: str) -> str:
normalized = data.normalize_setup_parameter_value(value, var_type)
typ = (var_type or "").strip().upper()
if typ == "L":
return "1" if normalized else "0"
return "" if normalized is None else str(normalized)
def _decode_setup_parameter_value(value, var_type: str):
return data.normalize_setup_parameter_value(value, var_type)
def get_setup_parameters_from_db(prefix: str, id_kas: str) -> dict[str, data.SetupParameterValue]:
table = f"{prefix}_setup_parameters"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT var_name, var_value, var_type FROM "{table}" WHERE id_kas=?',
(id_kas,),
)
rows = cur.fetchall()
return {
var_name: data.SetupParameterValue(
var_name=var_name,
var_value=_decode_setup_parameter_value(var_value, var_type),
var_type=var_type,
)
for var_name, var_value, var_type in rows
}
def get_effective_setup_parameters(prefix: str, id_kas: str) -> list[data.SetupParameterValue]:
params = get_default_setup_parameters()
for name in PROTECTED_SETUP_PARAMETER_NAMES:
params.pop(name, None)
for var_name, param in get_setup_parameters_from_db(prefix, id_kas).items():
if var_name in params:
params[var_name] = param
return list(params.values())
def apply_setup_parameters(prefix: str, id_kas: str, setup: data.PosSetup) -> data.PosSetup:
for param in get_effective_setup_parameters(prefix, id_kas):
setattr(setup, param.var_name, param.var_value)
alias = SETUP_PARAMETER_ALIASES.get(param.var_name)
if alias:
setattr(setup, alias, param.var_value)
return setup
def save_setup_parameters_db(
prefix: str,
id_kas: str,
parameters: list[data.SetupParameterValue],
) -> dict:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_setup_parameters"
allowed_names = set(get_default_setup_parameters())
normalized = []
for raw in parameters:
param = data.SetupParameterValue.model_validate(raw)
if param.var_name in PROTECTED_SETUP_PARAMETER_NAMES:
continue
if param.var_name and (not allowed_names or param.var_name in allowed_names):
normalized.append(param)
with get_db() as conn:
cur = conn.cursor()
cur.executemany(
f"""
INSERT OR REPLACE INTO "{table}" (id_kas, var_name, var_value, var_type)
VALUES (?, ?, ?, ?)
""",
[
(
id_kas,
p.var_name,
_encode_setup_parameter_value(p.var_value, p.var_type),
p.var_type,
)
for p in normalized
],
)
conn.commit()
return {"ok": True, "count": len(normalized)}
@app.get("/setup/", response_model=data.PosSetup)
def get_setup(id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
logger.info(f"GET setup: prefix={prefix} pokladna={id_kas}")
result = get_setup_from_db(prefix, id_kas)
return result
#return get_setup_from_db(prefix, id_kas)
@app.get("/import_parameters/")
@app.get("/setup/parameters/import/")
def get_import_parameters(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET import parameters: prefix={prefix} user={user}")
return JSONResponse(content=load_import_parameter_definitions(),media_type="application/json; charset=utf-8")
#return load_import_parameter_definitions()
@app.get("/setup/parameters/", response_model=list[data.SetupParameterValue])
def get_setup_parameters(
id_kas: str,
include_defaults: bool = True,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET setup parameters: prefix={prefix} pokladna={id_kas}")
if include_defaults:
return get_effective_setup_parameters(prefix, id_kas)
return list(get_setup_parameters_from_db(prefix, id_kas).values())
@app.post("/setup/parameters/")
def update_setup_parameters(
id_kas: str,
parameters: list[data.SetupParameterValue],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
f"POST setup parameters: prefix={prefix} pokladna={id_kas} "
f"count={len(parameters)}"
)
return save_setup_parameters_db(prefix, id_kas, parameters)
def _encode_secret(value: str) -> str:
value = str(value or "")
return p.code(value) if value else ""
def _decode_secret(value: str) -> str:
value = str(value or "")
if not value:
return ""
try:
return p.decode(value)
except Exception:
return ""
def _bool_setup_value(value) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "t", "yes", "y", "ano", "a"}
def get_postgres_connection_db(prefix: str, include_password: bool = True) -> data.PostgresConnection:
table = f"{prefix}_postgres_connection"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT enabled, host, port, database_name, user_name, password,
schema_name, sslmode, connect_timeout
FROM "{table}"
WHERE id=1
"""
)
row = cur.fetchone()
if not row:
return data.PostgresConnection()
return data.PostgresConnection(
enabled=bool(row[0]),
host=row[1],
port=row[2],
database=row[3],
user=row[4],
password=row[5],
schema=row[6],
sslmode=row[7],
connect_timeout=row[8],
)
def get_postgres_connection_public(prefix: str) -> data.PostgresConnectionOut:
conn = get_postgres_connection_db(prefix, include_password=False)
table = f"{prefix}_postgres_connection"
with get_db() as db:
cur = db.cursor()
cur.execute(f'SELECT password FROM "{table}" WHERE id=1')
row = cur.fetchone()
payload = conn.model_dump(by_alias=True)
payload["password"] = ""
payload["password_set"] = bool(row and row[0])
return data.PostgresConnectionOut(**payload)
def save_postgres_connection_db(prefix: str, incoming: data.PostgresConnection) -> data.PostgresConnectionOut:
table = f"{prefix}_postgres_connection"
incoming = data.PostgresConnection.model_validate(incoming)
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'SELECT password FROM "{table}" WHERE id=1')
row = cur.fetchone()
stored_password = row[0] if row else ""
cur.execute(
f"""
INSERT INTO "{table}" (
id, enabled, host, port, database_name, user_name, password,
schema_name, sslmode, connect_timeout, updated_at
)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
enabled=excluded.enabled,
host=excluded.host,
port=excluded.port,
database_name=excluded.database_name,
user_name=excluded.user_name,
password=excluded.password,
schema_name=excluded.schema_name,
sslmode=excluded.sslmode,
connect_timeout=excluded.connect_timeout,
updated_at=CURRENT_TIMESTAMP
""",
(
1 if incoming.enabled else 0,
incoming.host,
incoming.port,
incoming.database,
incoming.user,
incoming.password,
incoming.schema_,
incoming.sslmode,
incoming.connect_timeout
),
)
conn.commit()
return get_postgres_connection_public(prefix)
def postgres_cashier_enabled(prefix: str, id_kas: str) -> bool:
for param in get_effective_setup_parameters(prefix, id_kas):
if param.var_name == "postgres_enabled":
return _bool_setup_value(param.var_value)
return False
def get_postgres_status_db(prefix: str, id_kas: str, test_connection: bool = True) -> data.PostgresStatus:
cashier_enabled = postgres_cashier_enabled(prefix, id_kas)
conn = get_postgres_connection_db(prefix, include_password=True)
installation_enabled = bool(conn.enabled)
configured = postgres_service.is_configured(conn)
status = data.PostgresStatus(
cashier_enabled=cashier_enabled,
installation_enabled=installation_enabled,
connection_configured=configured,
)
if not cashier_enabled:
status.message = "PostgreSQL nie je povoleny pre tuto kasu."
return status
if not installation_enabled:
status.message = "PostgreSQL pripojenie nie je povolene pre instalaciu."
return status
if not configured:
status.message = "PostgreSQL pripojenie nie je vyplnene."
return status
if not test_connection:
status.available = True
status.message = "PostgreSQL pripojenie je nakonfigurovane."
return status
try:
postgres_service.test_connection(conn)
except postgres_service.PostgresServiceError as e:
status.message = str(e)
return status
status.connection_ok = True
status.available = True
status.message = "PostgreSQL pripojenie je dostupne."
return status
@app.get("/postgres/connection/", response_model=data.PostgresConnectionOut)
def get_postgres_connection(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET postgres connection: prefix={prefix} user={user}")
return get_postgres_connection_public(prefix)
@app.post("/postgres/connection/", response_model=data.PostgresConnectionOut)
def update_postgres_connection(
connection: data.PostgresConnection,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"POST postgres connection: prefix={prefix} user={user}")
return save_postgres_connection_db(prefix, connection)
@app.get("/postgres/status/", response_model=data.PostgresStatus)
def get_postgres_status(
id_kas: str,
test_connection: bool = True,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET postgres status: prefix={prefix} pokladna={id_kas}")
return get_postgres_status_db(prefix, id_kas, test_connection=test_connection)
@app.get("/limity/", response_model=list[data.LimitTable])
def get_limity(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET limity: prefix={prefix} pokladna={id_kas} user={user}")
return load_limit_tables_from_postgres(prefix, id_kas)
@app.get("/limity/ucet/", response_model=data.Ucet)
def get_limit_ucet(
id_kas: str,
id_limit: int,
id_den: int,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
f"GET limit ucet: prefix={prefix} pokladna={id_kas} "
f"limit={id_limit} den={id_den} client={client_id}"
)
lock = acquire_limit_semafor(prefix, id_kas, id_limit, id_den, client_id, user)
if not lock.ok:
raise HTTPException(409, lock.message or "Limit je zamknuty.")
try:
return build_limit_ucet_from_postgres(prefix, id_kas, id_limit, id_den, user)
except Exception:
release_limit_semafor(prefix, id_kas, id_limit, client_id)
raise
@app.post("/limity/release/", response_model=data.LimitLockResult)
def release_limit(
id_kas: str,
id_limit: int,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
f"POST limit release: prefix={prefix} pokladna={id_kas} "
f"limit={id_limit} client={client_id}"
)
return release_limit_semafor(prefix, id_kas, id_limit, client_id)
@app.post("/limity/ucet/save/", response_model=data.Ucet)
def save_limit_ucet(
ucet: data.Ucet,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
"POST limit ucet save: prefix=%s pokladna=%s limit=%s den=%s client=%s",
prefix,
ucet.id_kas,
getattr(ucet, "limit_id", None),
getattr(ucet, "limit_den_id", None),
client_id,
)
save_limit_items_to_postgres(prefix, ucet.id_kas, ucet, client_id)
return ucet
@app.post("/limity/ucet/finish/", response_model=data.Ucet)
def finish_limit_ucet(
ucet: data.Ucet,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
"POST limit ucet finish: prefix=%s pokladna=%s limit=%s den=%s client=%s",
prefix,
ucet.id_kas,
getattr(ucet, "limit_id", None),
getattr(ucet, "limit_den_id", None),
client_id,
)
return insert_limit_closed_receipt_db(prefix, ucet, client_id)
@app.post("/limity/ucet/clear/", response_model=data.LimitLockResult)
def clear_limit_ucet(
ucet: data.Ucet,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
"POST limit ucet clear: prefix=%s pokladna=%s ucislo=%s limit=%s client=%s",
prefix,
ucet.id_kas,
getattr(ucet, "ucislo", ""),
getattr(ucet, "limit_id", None),
client_id,
)
clear_limit_payment_in_postgres(prefix, ucet.id_kas, ucet)
return data.LimitLockResult(
ok=True,
id_limit=int(getattr(ucet, "limit_id", 0) or 0),
id_den=int(getattr(ucet, "limit_den_id", 0) or 0),
table_id=limit_table_id(getattr(ucet, "limit_id", 0), getattr(ucet, "limit_den_id", 0)),
)
def get_setup_from_db(cur_pref: str, id_kas: str) -> data.PosSetup:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_setup"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT data FROM "{table}" WHERE id_kas=?',
(id_kas,), )
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen")
raw_json = row[0]
# validace + doplnění nových polí
setup = data.PosSetup.model_validate_json(raw_json)
setup.platby = []
# NORMALIZACE (dict → json)
normalized_json = json.dumps(
setup.model_dump(exclude_none=False),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True, )
# pokud se struktura změnila → UPDATE
if normalized_json != raw_json:
logger.info(f"SETUP schema updated for {id_kas}")
cur.execute(
f'UPDATE "{table}" SET data=? WHERE id_kas=?',
(normalized_json, id_kas), )
apply_setup_parameters(cur_pref, id_kas, setup)
setup.platby = get_setup_platby_from_db(cur_pref, id_kas)
return setup
@app.get("/platby/", response_model=List[data.PaymentType])
def get_platby(id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
logger.info(f"GET platby: prefix={prefix} pokladna={id_kas}")
result = get_setup_platby_from_db(prefix, id_kas)
return result
def get_setup_platby_from_db(cur_pref: str, id_kas: str) -> List[data.PaymentType]:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_platby"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT code, name, unit, rate, poradie, p_kopii, round50, allow_partial,
is_cash, handler, color, is_default, fiscal, is_bankterm, odvod, odovzdat
FROM "{table}"
WHERE id_kas=?
ORDER BY poradie, name, code
""",
(id_kas,),
)
rows = cur.fetchall()
if rows:
return [_row_to_payment_type(row) for row in rows]
payments = default_payment_types()
_insert_platby_cur(cur, table, id_kas, payments, replace_existing=True)
return sorted(
payments,
key=lambda p: (getattr(p, "poradie", 0) or 0, str(getattr(p, "name", ""))),
)
def get_setup_discounts_from_db(cur_pref: str, id_kas: str) -> List[data.PaymentType]:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_zlavy"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT id_zlavy_hlav
FROM "{table}"
WHERE id_kas=?
ORDER BY id_zlavy_hlav
""",
(id_kas,),
)
rows = cur.fetchall()
if rows:
return [str(row[0]) for row in rows]
else:
return []
@app.post("/platby/")
@app.post("/platby/setup/")
def update_platby(
id_kas: str,
platby: list[data.PaymentType],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_platby_db(prefix, id_kas, platby)
def update_platby_db(prefix: str, id_kas: str, new_platby: list[data.PaymentType]):
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_platby"
payments = list(new_platby or [])
with get_db() as conn:
cur = conn.cursor()
# načítaj aktuálny setup
_insert_platby_cur(
cur,
table,
id_kas,
payments,
replace_existing=True,
)
# pôvodné platby ako dict podľa code
# nové platby ako dict podľa code
# 🔥 merge:
# - update existujúcich
# - pridanie nových
# update existujúceho (prepíše všetko)
# nová platba
# výsledný zoznam (len to čo prišlo → staré sa zahodia)
# uloženie späť
conn.commit()
return {"ok": True, "count": len(payments)}
@app.get("/spravy/", response_model=List)
def get_spravy(id_kas: str,
auth: tuple[str] = Depends(auth_ctx)):
prefix, user, client_id = auth
logger.info(f"GET setup: prefix={prefix} pokladna={id_kas}")
result = get_setup_spravy_from_db(prefix, id_kas)
return result
def get_setup_spravy_from_db(cur_pref: str, id_kas: str) -> List[str]:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_setup"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT data FROM "{table}" WHERE id_kas=?',
(id_kas,), )
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen")
raw_json = row[0]
setup = data.PosSetup.model_validate_json(raw_json)
return setup.messages
@app.post("/spravy/setup/")
def update_spravy(
id_kas: str,
spravy: list[str],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_spravy_db(prefix, id_kas, spravy)
def update_spravy_db(prefix: str, id_kas: str, new_spravy: list[str]):
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_setup"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT data FROM "{table}" WHERE id_kas=?',
(id_kas,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen")
setup = json.loads(row[0])
# 🔥 normalizácia (odstráni None, duplicitné, trimne)
cleaned = []
seen = set()
for m in new_spravy:
if not m:
continue
m = m.strip()
if not m:
continue
if m in seen:
continue
seen.add(m)
cleaned.append(m)
# 🔥 uloženie
setup["messages"] = cleaned
normalized_json = json.dumps(
setup,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
cur.execute(
f'UPDATE "{table}" SET data=? WHERE id_kas=?',
(normalized_json, id_kas),
)
conn.commit()
return {"ok": True, "count": len(cleaned)}
@app.post("/fstmenu/")
def update_fstmenu(
data: list[data.FstMenu],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_fstmenu_db(prefix, data)
def update_fstmenu_db(prefix: str, fst: list[data.FstMenu]):
table = f"{prefix}_fstmenu"
with get_db() as conn:
cur = conn.cursor()
# 🔹 1. načítaj existujúce z DB
cur.execute(f'SELECT id_kas, c_karty FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 2. incoming IDs
incoming_ids = {(item.id_kas, item.c_karty) for item in fst}
# 🔹 3. DELETE (čo už nie je v requeste)
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE id_kas=? AND c_karty=?',
list(to_delete)
) # 🔹 4. INSERT / UPDATE
for item in fst:
polozky_json = json.dumps(
[p.model_dump() for p in item.polozky],
ensure_ascii=False
)
cur.execute(f"""
INSERT INTO "{table}" (id_kas, c_karty, polozky)
VALUES (?, ?, ?)
ON CONFLICT(id_kas, c_karty) DO UPDATE SET
polozky = excluded.polozky
""", (item.id_kas, item.c_karty, polozky_json))
conn.commit()
return {"ok": True}
@app.get("/fooddat/", response_model=list[data.FoodDat])
def get_fooddat(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return get_fooddat_db(prefix)
def get_fooddat_db(prefix: str) -> list[data.FoodDat]:
table = f"{prefix}_fooddat"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT id, c_stredisk, id_zkratka, pgm
FROM "{table}"
ORDER BY id
'''
)
return [
data.FoodDat(
id=row[0] or "",
c_stredisk=int(row[1] or 0),
id_zkratka=row[2] or "",
pgm=row[3] or "",
)
for row in cur.fetchall()
]
def _fooddat_id_candidates(value) -> list[str]:
text = _strip_value(value)
candidates = []
if text:
candidates.append(text)
if text.isdigit():
candidates.append(str(int(text)))
candidates.append(f"{int(text):02d}")
return list(dict.fromkeys(candidates))
def get_fooddat_stredisk_map(prefix: str) -> dict[str, int]:
table = f"{prefix}_fooddat"
try:
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT id, c_stredisk
FROM "{table}"
'''
)
result: dict[str, int] = {}
for row in cur.fetchall():
stredisk = int(row[1] or 0)
for candidate in _fooddat_id_candidates(row[0]):
result.setdefault(candidate, stredisk)
return result
except Exception as exc:
logger.warning(f"Fooddat strediska sa nepodarilo nacitat: {exc}")
return {}
def fooddat_stredisk_for_sklad(fooddat_map: dict[str, int], sklad) -> int:
for candidate in _fooddat_id_candidates(sklad):
if candidate in fooddat_map:
return int(fooddat_map[candidate] or 0)
return 0
@app.post("/fooddat/")
def update_fooddat(
items: list[data.FoodDat],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_fooddat_db(prefix, items)
def update_fooddat_db(prefix: str, items: list[data.FoodDat]) -> dict:
table = f"{prefix}_fooddat"
rows = [data.FoodDat.model_validate(item) for item in (items or [])]
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'DELETE FROM "{table}"')
cur.executemany(
f'''
INSERT INTO "{table}" (id, c_stredisk, id_zkratka, pgm)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
c_stredisk = excluded.c_stredisk,
id_zkratka = excluded.id_zkratka,
pgm = excluded.pgm
''',
[
(
item.id,
int(item.c_stredisk or 0),
item.id_zkratka,
item.pgm,
)
for item in rows
if item.id
],
)
conn.commit()
return {"ok": True, "count": len([item for item in rows if item.id])}
@app.get("/prndefkasa/")
def get_prndef(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return get_prndef_for_kasa(prefix, id_kas)
@app.get("/prndef/", response_model=list[data.PrnDefShort])
def get_all_prndef(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return get_all_prndef_short(prefix)
def get_all_prndef_short(prefix: str) -> list[data.PrnDefShort]:
table_prndef = f'"{prefix}_prndef"'
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT prn_no, prn_name, id_term, data
FROM {table_prndef}
ORDER BY prn_no
"""
)
rows = cur.fetchall()
out: list[data.PrnDefShort] = []
for index, row in enumerate(rows, start=1):
try:
prn_data = json.loads(row[3] or "{}")
except Exception:
prn_data = {}
out.append(
data.PrnDefShort(
prn_no=row[0],
prn_name=row[1],
poradie=index,
id_term=row[2] or "",
cmd32_on=str((prn_data or {}).get("cmd32_on") or ""),
)
)
return out
def get_prndef_for_kasa(
prefix: str,
id_kas: str
) -> list[data.PrnDefShort]:
table_prndef = f'"{prefix}_prndef"'
table_kasaucp = f'"{prefix}_kasaucp"'
with get_db() as conn:
cur = conn.cursor()
# načítanie povolených tlačiarní pre kasu
cur.execute(
f'''
SELECT printers
FROM {table_kasaucp}
WHERE id_kas=?
''',
(id_kas,)
)
row = cur.fetchone()
if not row:
return []
raw_json = row[0]
allowed = TypeAdapter(
list[data.KasaUcpPrinters]
).validate_json(raw_json)
if not allowed:
return []
prn_map = {
x.prn_no: x.poradie
for x in allowed
}
placeholders = ",".join("?" for _ in prn_map)
cur.execute(
f"""
SELECT prn_no, prn_name, id_term, data
FROM {table_prndef}
WHERE prn_no IN ({placeholders})
ORDER BY prn_no
""",
tuple(prn_map.keys())
)
rows = cur.fetchall()
out = []
for r in rows:
try:
prn_data = json.loads(r[3] or "{}")
except Exception:
prn_data = {}
out.append(
data.PrnDefShort(
prn_no=r[0],
prn_name=r[1],
poradie=prn_map.get(r[0], 0),
id_term=r[2] or "",
cmd32_on=str((prn_data or {}).get("cmd32_on") or ""),
)
)
out.sort(key=lambda x: (x.poradie, x.prn_no))
return out
@app.get(
"/pricelevels/", response_model=list[data.HladinyRiadky]
)
def get_pricelevels(
id_kas: str,
auth: tuple[str] = Depends(auth_ctx)
):
prefix, user, client_id = auth
logger.info(
f"GET pricelevels: prefix={prefix} pokladna={id_kas}"
)
return get_pricelevels_from_db(prefix, id_kas)
def get_pricelevels_from_db(cur_pref: str, id_kas: str) -> List[str]:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_hladiny"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT riadky FROM "{table}" WHERE id_kas=?',
(id_kas,), )
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen")
raw_json = row[0]
return TypeAdapter(list[data.HladinyRiadky]).validate_json(raw_json)
@app.get(
"/clientsettings/", response_model=data.ClientSettings
)
def get_clientsettings(
id_kas: str,
auth: tuple[str] = Depends(auth_ctx)
):
prefix, user, client_id = auth
logger.info(
f"GET clientsettings: prefix={prefix} pokladna={id_kas}"
)
return get_clientsettings_from_db(prefix, id_kas, client_id)
def get_clientsettings_from_db(
cur_pref: str,
id_kas: str,
client_id: str
) -> data.ClientSettings:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_clients"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT prn_no, room_name FROM "{table}" WHERE id_kas=? AND client_id=?',
(id_kas, client_id),
)
row = cur.fetchone()
if not row:
cur.execute(
f'''
INSERT INTO "{table}"
(id_kas, client_id, prn_no, room_name)
VALUES (?, ?, '', '')
ON CONFLICT(id_kas, client_id) DO UPDATE SET
prn_no = excluded.prn_no,
room_name = excluded.room_name
''',
(id_kas, client_id),
)
raw_data = {
"prn_no": "",
"room_name": "",
}
else:
raw_data = {
"prn_no": row[0],
"room_name": row[1],
}
return TypeAdapter(data.ClientSettings).validate_python(raw_data)
@app.post(
"/clientsettings/",
response_model=data.ClientSettings,
)
def set_clientsettings(
prn_no: str,
room_name: str,
id_kas: str,
auth: tuple[str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
f"SET clientsettings: prefix={prefix} pokladna={id_kas} client_id={client_id} prn_no={prn_no} room_name={room_name}"
)
return save_clientsettings_to_db(
prefix,
id_kas,
client_id,
prn_no,
room_name,
)
def save_clientsettings_to_db(
cur_pref: str,
id_kas: str,
client_id: str,
prn_no: str,
room_name: str,
) -> data.ClientSettings:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_clients"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
INSERT INTO "{table}"
(id_kas, client_id, prn_no, room_name)
VALUES (?, ?, ?, ?)
ON CONFLICT(id_kas, client_id) DO UPDATE SET
prn_no = excluded.prn_no,
room_name = excluded.room_name
""",
(id_kas, client_id, prn_no, room_name),
)
conn.commit()
return data.ClientSettings(
prn_no=prn_no,
room_name=room_name,
)
@app.get(
"/kasutxt/{id_kas}", response_model=data.KasUtxtRiadky
)
def get_kasutxt(
id_kas: str,
auth: tuple[str] = Depends(auth_ctx)
):
prefix, user, client_id = auth
logger.info(
f"GET kasutxt: prefix={prefix} pokladna={id_kas}"
)
return get_kasutxt_from_db(prefix, id_kas)
def get_kasutxt_from_db(cur_pref: str, id_kas: str) -> data.KasUtxtRiadky:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{cur_pref}_kasutxt"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT riadky FROM "{table}" WHERE id_kas=?',
(id_kas,), )
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Hlavicky uctov pro pokladnu {id_kas} nenalezen")
raw_json = row[0]
logger.info(f"GET kasutxt: raw_json={raw_json}")
return TypeAdapter(data.KasUtxtRiadky).validate_json(raw_json)
@app.post("/prndef/")
def update_prndef(
prn: list[data.PrnDef],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_prndef_db(prefix, prn)
def update_prndef_db(prefix: str, prn: list[data.PrnDef]):
table = f"{prefix}_prndef"
with get_db() as conn:
cur = conn.cursor()
# 🔹 1. načítaj existujúce z DB
cur.execute(f'SELECT prn_no FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 2. incoming IDs
incoming_ids = {(item.prn_no) for item in prn}
# 🔹 3. DELETE (čo už nie je v requeste)
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE prn_no=?',
[(x,) for x in to_delete]
)
for item in prn:
prn_json = json.dumps(item.data.model_dump())
cur.execute(f"""
INSERT INTO "{table}" (prn_no, prn_name, data, id_term)
VALUES (?, ?, ?, ?)
ON CONFLICT(prn_no) DO UPDATE SET
data = excluded.data,
prn_name=excluded.prn_name,
id_term=excluded.id_term
""", (item.prn_no, item.prn_name, prn_json, item.id_term))
conn.commit()
return {"ok": True}
@app.get("/bankterm/", response_model=list[data.BankTerm])
def get_bankterm(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return get_bankterm_db(prefix)
def get_bankterm_db(prefix: str) -> list[data.BankTerm]:
table = f"{prefix}_bankterm"
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'SELECT id_term, term_name, term_data FROM "{table}" ORDER BY id_term')
result: list[data.BankTerm] = []
for id_term, term_name, raw_data in cur.fetchall():
try:
raw = json.loads(raw_data or "{}")
term_data = data.BankTermData.model_validate(raw)
except Exception:
logger.exception("Bank terminal data is invalid id_term=%s", id_term)
continue
result.append(
data.BankTerm(
id_term=id_term or "",
term_name=term_name or "",
term_data=term_data,
)
)
return result
@app.post("/bankterm/")
def update_bankterm(
bt: list[data.BankTerm],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_bankterm_db(prefix, bt)
def update_bankterm_db(prefix: str, bt: list[data.BankTerm]):
table = f"{prefix}_bankterm"
with get_db() as conn:
cur = conn.cursor()
# 🔹 1. načítaj existujúce z DB
cur.execute(f'SELECT id_term FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 2. incoming IDs
incoming_ids = {(item.id_term) for item in bt}
# 🔹 3. DELETE (čo už nie je v requeste)
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE id_term=?',
[(x,) for x in to_delete]
)
for item in bt:
prn_json = json.dumps(item.term_data.model_dump())
cur.execute(f"""
INSERT INTO "{table}" (id_term, term_name, term_data)
VALUES (?, ?, ?)
ON CONFLICT(id_term) DO UPDATE SET
term_data = excluded.term_data, term_name=excluded.term_name
""", (item.id_term, item.term_name, prn_json))
conn.commit()
return {"ok": True}
PRINT_JOB_ACTIVE_STATUSES = ("queued", "retry_pending")
PRINT_JOB_VISIBLE_STATUSES = {
"queued",
"claimed",
"printing",
"printed",
"failed",
"retry_pending",
"failed_final",
"cancelled",
}
def _json_obj(raw: str | None) -> dict:
if not raw:
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _json_dump_obj(value: dict | None) -> str:
return json.dumps(value or {}, ensure_ascii=False, separators=(",", ":"))
def _print_job_select_columns() -> str:
return """
id, id_kas, printer_no, agent_id, job_type, document_type,
receipt_no, required, status, priority, attempts, max_attempts,
payload, result, error, created_at, claimed_at, started_at,
finished_at, updated_at
"""
def _print_job_from_row(row) -> data.PrintJob:
return data.PrintJob(
id=int(row[0]),
id_kas=row[1],
printer_no=row[2] or "",
agent_id=row[3],
job_type=row[4] or "other",
document_type=row[5] or "",
receipt_no=row[6],
required=bool(row[7]),
status=row[8] or "queued",
priority=int(row[9] or 100),
attempts=int(row[10] or 0),
max_attempts=int(row[11] or 3),
payload=_json_obj(row[12]),
result=_json_obj(row[13]),
error=row[14] or "",
created_at=row[15] or "",
claimed_at=row[16],
started_at=row[17],
finished_at=row[18],
updated_at=row[19] or "",
)
def _load_print_job_cur(cur, table: str, job_id: int) -> data.PrintJob:
cur.execute(
f'SELECT {_print_job_select_columns()} FROM "{table}" WHERE id=?',
(job_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Print job {job_id} not found")
return _print_job_from_row(row)
@app.post("/print/jobs/", response_model=data.PrintJob)
def create_print_job(
job: data.PrintJobCreate,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return create_print_job_db(prefix, job)
def create_print_job_db(prefix: str, job: data.PrintJobCreate) -> data.PrintJob:
if len(job.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_print_jobs"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
INSERT INTO "{table}" (
id_kas, printer_no, job_type, document_type, receipt_no,
required, status, priority, max_attempts, payload, result, error
)
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, '{{}}', '')
""",
(
job.id_kas,
job.printer_no or "",
job.job_type or "other",
job.document_type or "",
job.receipt_no,
int(bool(job.required)),
int(job.priority or 100),
max(int(job.max_attempts or 1), 1),
_json_dump_obj(job.payload),
),
)
conn.commit()
created = _load_print_job_cur(cur, table, int(cur.lastrowid))
logger.info(
"Print job created: prefix=%s id=%s id_kas=%s printer=%s type=%s document=%s receipt=%s",
prefix,
created.id,
created.id_kas,
created.printer_no,
created.job_type,
created.document_type,
created.receipt_no,
)
return created
def _truthy_flag(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "ano", "áno"}
def _load_prndef_map_cur(cur, prefix: str) -> dict[str, dict]:
table_prndef = f"{prefix}_prndef"
cur.execute(f'SELECT prn_no, prn_name, id_term, data FROM "{table_prndef}" ORDER BY prn_no')
result: dict[str, dict] = {}
for prn_no, prn_name, id_term, raw_data in cur.fetchall():
prn_key = str(prn_no)
result[prn_key] = {
"prn_no": prn_key,
"prn_name": prn_name or "",
"id_term": id_term or "",
"data": _json_obj(raw_data),
}
return result
def _printer_target_numbers(prn_no: str, prndef_map: dict[str, dict]) -> list[str]:
printer = prndef_map.get(str(prn_no))
if not printer:
return []
cmd32_on = str((printer.get("data") or {}).get("cmd32_on") or "").strip()
if cmd32_on.startswith("***"):
return [target.strip() for target in re.findall(r"\{([^{}]+)\}", cmd32_on) if target.strip()]
return [str(prn_no)]
def _printer_route(printer: dict | None) -> dict:
if not printer:
return {}
pdata = printer.get("data") or {}
cmd32_on = str(pdata.get("cmd32_on") or "").strip()
cmd_upper = cmd32_on.upper()
if cmd_upper == "FISKAL":
route_type = "fiskal"
elif cmd_upper == "CUPS":
route_type = "cups"
else:
route_type = "raw"
return {
"prn_no": printer.get("prn_no") or "",
"prn_name": printer.get("prn_name") or "",
"id_term": printer.get("id_term") or "",
"route_type": route_type,
"cmd32_on": cmd32_on,
"ip": str(pdata.get("ip") or ""),
"port": str(pdata.get("port") or ""),
"cupsname": str(pdata.get("cupsname") or ""),
"printer_type": str(pdata.get("printer_type") or ""),
"convert_charset": str(pdata.get("convert_charset") or ""),
"p_width": str(pdata.get("p_width") or ""),
"p_reset": str(pdata.get("p_reset") or ""),
"p_wideon": str(pdata.get("p_wideon") or ""),
"p_wideoff": str(pdata.get("p_wideoff") or ""),
"p_crlf": str(pdata.get("p_crlf") or ""),
"p_fullcut": str(pdata.get("p_fullcut") or ""),
"template_bon": str(pdata.get("template_bon") or ""),
"template_ucet": str(pdata.get("template_ucet") or ""),
}
def load_current_printer_route(prefix: str, printer_no: str) -> dict:
printer_key = str(printer_no or "").strip()
if not printer_key:
return {}
with get_db() as conn:
cur = conn.cursor()
return _printer_route(_load_prndef_map_cur(cur, prefix).get(printer_key))
def next_print_bon_number_db(prefix: str, prn_no: str, bon_date: str | None = None) -> tuple[int, str]:
prn_key = str(prn_no or "").strip()
if not prn_key:
raise HTTPException(422, "Invalid bon printer number")
bon_day = bon_date or datetime.now().date().isoformat()
try:
ref_day = datetime.fromisoformat(bon_day).date()
except Exception:
ref_day = datetime.now().date()
retention_days = max(1, _env_int("POKLADNA_BON_COUNTER_RETENTION_DAYS", 35))
cleanup_before = (ref_day - timedelta(days=retention_days)).isoformat()
table = f"{prefix}_print_bon_counters"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}" WHERE bon_date < ?', (cleanup_before,))
cur.execute(
f'SELECT last_no FROM "{table}" WHERE prn_no=? AND bon_date=?',
(prn_key, bon_day),
)
row = cur.fetchone()
next_no = int(row[0] or 0) + 1 if row else 1
cur.execute(
f"""
INSERT INTO "{table}" (prn_no, bon_date, last_no, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(prn_no, bon_date) DO UPDATE SET
last_no=excluded.last_no,
updated_at=CURRENT_TIMESTAMP
""",
(prn_key, bon_day, next_no),
)
return next_no, bon_day
def resolve_table_info_from_map(prefix: str, id_kas: str, stul: str | None) -> tuple[str, str]:
table_id = str(stul or "").strip()
if not table_id:
return "", ""
try:
mapa = get_mapa_stolu_from_db(prefix, id_kas)
except Exception:
return table_id, ""
for room in getattr(mapa, "rooms", []) or []:
room_name = str(getattr(room, "room_name", "") or "").strip()
for table in getattr(room, "stoly", []) or []:
current_id = str(getattr(table, "id", "") or "").strip()
if current_id == table_id or current_id.split("|")[-1] == table_id:
table_name = str(getattr(table, "name", "") or current_id or table_id)
return table_name, room_name
return table_id, ""
def resolve_table_name_from_map(prefix: str, id_kas: str, stul: str | None) -> str:
return resolve_table_info_from_map(prefix, id_kas, stul)[0]
def ensure_ucet_room_name(prefix: str, ucet: data.Ucet) -> data.Ucet:
_, room_name = resolve_table_info_from_map(prefix, getattr(ucet, "id_kas", ""), getattr(ucet, "stul", ""))
if room_name:
ucet.room_name = room_name
elif getattr(ucet, "room_name", None) is None:
ucet.room_name = ""
return ucet
def _clone_ucet_for_print(ucet: data.Ucet, poloz: list) -> data.Ucet:
clone = ucet.model_copy(deep=True)
clone.poloz = [pol.model_copy(deep=True) for pol in poloz]
return clone
def _apply_cenik_print_defaults(pol, cenik_map: dict[int, dict]) -> None:
defaults = cenik_map.get(_int_value(getattr(pol, "id_card", 0), 0), {})
if not getattr(pol, "c_druh", 0):
pol.c_druh = _int_value(defaults.get("c_druh"), 0)
if not getattr(pol, "druh", ""):
pol.druh = _strip_value(defaults.get("druh"))
if not getattr(pol, "spart", ""):
pol.spart = _strip_value(defaults.get("spart"))
if not getattr(pol, "prn_no", ""):
pol.prn_no = _strip_value(defaults.get("prn_no"))
def _insert_cook_items_db(
prefix: str,
req: data.KitchenPrintRequest,
poloz: list,
*,
bon_no: int = 0,
bon_date: str = "",
) -> None:
if not poloz:
return
table = f"{prefix}_cook_items"
now_text = datetime.now().isoformat(sep=" ", timespec="seconds")
rows = []
for pol in poloz:
rows.append((
req.id_kas,
getattr(req.ucet, "stul", "") or "",
req.room_name or "",
req.pos_name or "",
getattr(req.ucet, "autor", "") or "",
getattr(req.ucet, "ucislo", None),
int(bon_no or 0),
bon_date or "",
req.kind or "bon",
"new",
_int_value(getattr(pol, "id_card", 0), 0),
_int_value(getattr(pol, "c_druh", 0), 0),
getattr(pol, "druh", "") or "",
getattr(pol, "prn_no", "") or "",
getattr(pol, "line_id", "") or "",
getattr(pol, "group_id", "") or "",
getattr(pol, "nazev", "") or "",
_float_value(getattr(pol, "pocet", 0), 0.0),
_int_value(getattr(pol, "delitel", 1), 1),
json.dumps(getattr(pol, "zpravy", []) or [], ensure_ascii=False),
now_text,
now_text,
pol.model_dump_json(),
))
with get_db() as conn:
cur = conn.cursor()
cur.executemany(
f"""
INSERT INTO "{table}" (
id_kas, stul, room_name, pos_name, waiter_name, receipt_no,
bon_no, bon_date, event_type, status, id_card, c_druh, druh, prn_no, line_id,
group_id, item_name, quantity, delitel, messages,
ordered_at, updated_at, payload
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
@app.post("/print/kitchen/", response_model=list[data.PrintJob])
def create_kitchen_print_jobs(
req: data.KitchenPrintRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return create_kitchen_print_jobs_db(prefix, req)
def create_kitchen_print_jobs_db(
prefix: str,
req: data.KitchenPrintRequest,
) -> list[data.PrintJob]:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
kind = (req.kind or "bon").strip().lower()
if not kind:
kind = "bon"
source_count = len(list(getattr(req.ucet, "poloz", []) or []))
logger.info(
"Kitchen print request: prefix=%s id_kas=%s kind=%s items=%s receipt=%s table=%s",
prefix,
req.id_kas,
kind,
source_count,
getattr(req.ucet, "ucislo", None),
getattr(req.ucet, "stul", ""),
)
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
logger.info(
"Kitchen print prndef loaded: prefix=%s printers=%s",
prefix,
",".join(sorted(prndef_map.keys())) or "-",
)
cenik_map = load_cenik_print_map(prefix, req.id_kas)
source_lines = list(getattr(req.ucet, "poloz", []) or [])
for pol in source_lines:
_apply_cenik_print_defaults(pol, cenik_map)
menu_headers = {
_strip_value(getattr(pol, "group_id", "")): pol
for pol in source_lines
if _int_value(getattr(pol, "typ_menu", 0), 0) == 1
and _strip_value(getattr(pol, "group_id", ""))
}
lines_by_printer: dict[str, list] = defaultdict(list)
added_menu_headers: set[tuple[str, str]] = set()
cook_lines_by_printer: dict[str, list] = defaultdict(list)
def add_line_to_printer(target_prn: str, source_pol, target_printer: dict, *, for_cook: bool = True) -> None:
target_line = source_pol.model_copy(deep=True)
target_line.prn_no = target_prn
lines_by_printer[target_prn].append(target_line)
if for_cook and _truthy_flag((target_printer.get("data") or {}).get("is_cook")):
cook_lines_by_printer[target_prn].append(target_line)
for pol in source_lines:
typ_menu = _int_value(getattr(pol, "typ_menu", 0), 0)
if typ_menu == 1:
continue
source_prn = _strip_value(getattr(pol, "prn_no", ""))
if not source_prn:
logger.warning(
"Kitchen print item skipped without prn_no: prefix=%s id_kas=%s item=%s id_card=%s",
prefix,
req.id_kas,
getattr(pol, "nazev", ""),
getattr(pol, "id_card", ""),
)
continue
target_numbers = _printer_target_numbers(source_prn, prndef_map)
if not target_numbers:
logger.warning(f"Print requested for unknown printer {source_prn}: prefix={prefix} id_kas={req.id_kas}")
continue
for target_prn in target_numbers:
target_printer = prndef_map.get(target_prn)
if not target_printer:
logger.warning(f"Print group {source_prn} points to unknown printer {target_prn}: prefix={prefix}")
continue
if typ_menu == 2:
group_id = _strip_value(getattr(pol, "group_id", ""))
header = menu_headers.get(group_id)
header_key = (target_prn, group_id)
if header and group_id and header_key not in added_menu_headers:
add_line_to_printer(target_prn, header, target_printer, for_cook=True)
added_menu_headers.add(header_key)
add_line_to_printer(target_prn, pol, target_printer, for_cook=True)
if not lines_by_printer:
logger.warning(f"Kitchen print requested but no item has usable prn_no: prefix={prefix} id_kas={req.id_kas}")
return []
document_type = "kitchen_storno" if "storno" in kind else "kitchen_bon"
jobs: list[data.PrintJob] = []
table_name = resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", ""))
for prn_no, poloz in lines_by_printer.items():
printer = prndef_map.get(prn_no)
bon_no, bon_date = next_print_bon_number_db(prefix, prn_no)
if cook_lines_by_printer.get(prn_no):
try:
_insert_cook_items_db(
prefix,
req,
cook_lines_by_printer[prn_no],
bon_no=bon_no,
bon_date=bon_date,
)
except Exception:
logger.exception(
"Cook item insert failed, continuing with print job: prefix=%s id_kas=%s printer=%s bon=%s",
prefix,
req.id_kas,
prn_no,
bon_no,
)
ucet_for_printer = _clone_ucet_for_print(req.ucet, poloz)
payload = {
"kind": kind,
"bon_no": bon_no,
"bon_date": bon_date,
"pager_no": "",
"table_name": table_name,
"room_name": req.room_name or "",
"pos_name": req.pos_name or "",
"route": _printer_route(printer),
"ucet": ucet_for_printer.model_dump(mode="json"),
}
jobs.append(
create_print_job_db(
prefix,
data.PrintJobCreate(
id_kas=req.id_kas,
printer_no=prn_no,
job_type=kind,
document_type=document_type,
receipt_no=req.ucet.ucislo,
required=req.required,
priority=req.priority,
payload=payload,
),
)
)
return jobs
def _ucet_has_fiscal_payment(ucet: data.Ucet) -> bool:
return any(bool(getattr(payment, "fiscal", False)) for payment in (getattr(ucet, "platby", []) or []))
def _receipt_fiscal_text_allowed(kind: str) -> bool:
kind_l = str(kind or "").strip().lower()
return kind_l in {"copy", "kopia", "reprint"}
@app.post("/print/receipt/", response_model=list[data.PrintJob])
def create_receipt_print_jobs(
req: data.ReceiptPrintRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return create_receipt_print_jobs_db(prefix, req)
def create_receipt_print_jobs_db(
prefix: str,
req: data.ReceiptPrintRequest,
) -> list[data.PrintJob]:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
kind = str(req.kind or "receipt").strip().lower() or "receipt"
if _ucet_has_fiscal_payment(req.ucet) and not _receipt_fiscal_text_allowed(kind):
raise HTTPException(
409,
"Fiskalny ucet sa netlaci cez textovu frontu. Pouzi fiskalny paragon/send.",
)
printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip()
if not printer_no:
raise HTTPException(422, "Receipt printer is not set")
copies = max(1, min(int(req.copies or 1), 20))
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
target_numbers = _printer_target_numbers(printer_no, prndef_map)
if not target_numbers:
raise HTTPException(404, f"Receipt printer {printer_no} not found")
table_name = resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", ""))
jobs: list[data.PrintJob] = []
for target_prn in target_numbers:
printer = prndef_map.get(target_prn)
if not printer:
logger.warning("Receipt printer group %s points to unknown printer %s", printer_no, target_prn)
continue
payload = {
"kind": kind,
"title": req.title or "",
"table_name": table_name,
"pos_name": req.pos_name or "",
"headers": [str(line) for line in (req.headers or []) if str(line).strip()],
"footers": [str(line) for line in (req.footers or []) if str(line).strip()],
"route": _printer_route(printer),
"ucet": req.ucet.model_dump(mode="json"),
}
for _ in range(copies):
jobs.append(
create_print_job_db(
prefix,
data.PrintJobCreate(
id_kas=req.id_kas,
printer_no=target_prn,
job_type="receipt",
document_type=f"receipt_{kind}",
receipt_no=req.ucet.ucislo,
required=req.required,
priority=req.priority,
payload=payload,
),
)
)
return jobs
@app.post("/print/closure/", response_model=list[data.PrintJob])
def create_closure_print_jobs(
req: data.ClosurePrintRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return create_closure_print_jobs_db(prefix, req)
def create_closure_print_jobs_db(
prefix: str,
req: data.ClosurePrintRequest,
) -> list[data.PrintJob]:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
printer_no = str(req.printer_no or "").strip()
if not printer_no:
raise HTTPException(422, "Closure printer is not set")
text = str(req.text or "")
if not text.strip():
raise HTTPException(422, "Closure text is empty")
copies = max(1, min(int(req.copies or 1), 20))
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
target_numbers = _printer_target_numbers(printer_no, prndef_map)
if not target_numbers:
raise HTTPException(404, f"Closure printer {printer_no} not found")
kind = str(req.kind or "closure").strip().lower() or "closure"
jobs: list[data.PrintJob] = []
for target_prn in target_numbers:
printer = prndef_map.get(target_prn)
if not printer:
logger.warning("Closure printer group %s points to unknown printer %s", printer_no, target_prn)
continue
payload = {
"kind": kind,
"title": req.title or "Uzavierka",
"clsrep_no": req.clsrep_no or "",
"route": _printer_route(printer),
"text": text if text.endswith("\n") else text + "\n",
}
for _ in range(copies):
jobs.append(
create_print_job_db(
prefix,
data.PrintJobCreate(
id_kas=req.id_kas,
printer_no=target_prn,
job_type="closure",
document_type=f"closure_{kind}",
receipt_no=req.clsrep_no,
required=req.required,
priority=req.priority,
payload=payload,
),
)
)
return jobs
@app.post("/print/receipt/preview/", response_model=data.ReceiptPrintPreviewOut)
def render_receipt_print_preview(
req: data.ReceiptPrintRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return render_receipt_print_preview_db(prefix, req)
def _receipt_print_payload(prefix: str, req: data.ReceiptPrintRequest, printer_no: str, printer: dict | None) -> dict:
return {
"kind": str(req.kind or "receipt").strip().lower() or "receipt",
"title": req.title or "",
"table_name": resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", "")),
"pos_name": req.pos_name or "",
"headers": [str(line) for line in (req.headers or []) if str(line).strip()],
"footers": [str(line) for line in (req.footers or []) if str(line).strip()],
"route": _printer_route(printer),
"ucet": req.ucet.model_dump(mode="json"),
}
def render_receipt_print_preview_db(
prefix: str,
req: data.ReceiptPrintRequest,
) -> data.ReceiptPrintPreviewOut:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip()
if not printer_no:
raise HTTPException(422, "Receipt printer is not set")
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
printer = prndef_map.get(printer_no)
if not printer:
raise HTTPException(404, f"Receipt printer {printer_no} not found")
route = _printer_route(printer)
job = data.PrintJob(
id=0,
id_kas=req.id_kas,
printer_no=printer_no,
job_type="receipt",
document_type=f"receipt_{str(req.kind or 'receipt').strip().lower() or 'receipt'}",
receipt_no=req.ucet.ucislo,
payload=_receipt_print_payload(prefix, req, printer_no, printer),
)
text, meta = render_print_job_text_with_meta(job, route=route)
meta["route"] = route
meta["charset"] = _print_charset(route)
return data.ReceiptPrintPreviewOut(text=text, meta=meta)
def _fiscal_dph_value(rate) -> int | None:
raw = str(rate or "").strip()
if not raw:
return None
try:
value = float(raw)
except Exception:
return None
if value == -1:
return None
if value >= 1 and value <= 3:
pct = (value - 1) * 100
elif value > 3:
pct = value
else:
pct = value * 100
return int(round(pct))
FISCAL_ITEM_TYPE_SALE = "F"
FISCAL_ITEM_TYPE_RECEIVABLE = "P"
def _ucet_is_pohladavka(ucet: data.Ucet) -> bool:
return bool(_int_value(getattr(ucet, "pohladavka", 0), 0))
def _fiscal_receipt_document_type(ucet: data.Ucet) -> str:
# AFS/eKasa accepts a regular fiscal document type here. Receivables are
# carried by item metadata and by the saved account flag.
return FISCAL_ITEM_TYPE_SALE
def _fiscal_receipt_item_type(ucet: data.Ucet) -> str:
return FISCAL_ITEM_TYPE_RECEIVABLE if _ucet_is_pohladavka(ucet) else FISCAL_ITEM_TYPE_SALE
def _fiscal_receipt_type(ucet: data.Ucet) -> str:
return _fiscal_receipt_item_type(ucet)
def _dict_with_1_based_keys(rows: list[dict]) -> dict:
return {str(idx): row for idx, row in enumerate(rows, start=1)}
def _fiscal_receipt_items(ucet: data.Ucet) -> dict:
rows: list[dict] = []
fiscal_type = _fiscal_receipt_item_type(ucet)
for idx, pol in enumerate(getattr(ucet, "poloz", []) or [], start=1):
delitel = max(_int_value(getattr(pol, "delitel", 1), 1), 1)
qty = _float_value(getattr(pol, "pocet", 0), 0.0) / delitel
price = _float_value(getattr(pol, "cena", 0), 0.0)
dph = _fiscal_dph_value(getattr(pol, "dph", ""))
identifier = None
if fiscal_type == FISCAL_ITEM_TYPE_RECEIVABLE:
for msg in getattr(pol, "zpravy", []) or []:
identifier = _strip_value(msg)
if identifier:
break
rows.append({
"nazov": _strip_value(getattr(pol, "nazev", "")),
"cena": abs(price),
"mnozstvo": abs(qty),
"dil_porce": delitel,
"mj": "por",
"dph": dph,
"typ_fiskal": fiscal_type,
"okp": "",
"uid": "",
"identifikator": identifier,
})
return _dict_with_1_based_keys(rows)
def _payment_fiscal_index(payment) -> int:
for attr in ("fiskal_pro_payment_index", "typ", "payment_type", "fiscal_type"):
value = getattr(payment, attr, None)
if value not in (None, ""):
return _int_value(value, 16)
code = _strip_value(getattr(payment, "code", "")).upper()
if "CARD" in code or "KARTA" in code:
return 17
return 16
def _ucet_has_bankterm_payment(ucet: data.Ucet) -> bool:
return any(
bool(getattr(payment, "is_bankterm", False))
and abs(_float_value(getattr(payment, "suma", 0), 0.0)) >= 0.005
for payment in (getattr(ucet, "platby", []) or [])
)
def _route_has_bankterm(route: dict) -> bool:
return bool(_strip_value((route or {}).get("id_term", "")))
def _use_afs_bankterm(route: dict, ucet: data.Ucet) -> bool:
return _route_has_bankterm(route) and _ucet_has_bankterm_payment(ucet)
def _fiscal_receipt_payments(ucet: data.Ucet, use_bankterm: bool = False) -> dict:
rows: list[dict] = []
for idx, payment in enumerate(getattr(ucet, "platby", []) or [], start=1):
amount = abs(_float_value(getattr(payment, "suma", 0), 0.0))
amount_base = abs(_float_value(getattr(payment, "suma_czk", amount), amount))
fiscal_index = _payment_fiscal_index(payment)
payment_code = _strip_value(getattr(payment, "code", "")).upper()
payment_uses_bankterm = use_bankterm and bool(getattr(payment, "is_bankterm", False))
rows.append({
"cislo": idx,
"kompez": 0,
"typpl": "",
"text": _strip_value(getattr(payment, "nazev", "") or getattr(payment, "code", "")),
"mena": _strip_value(getattr(payment, "unit", "")),
"kurz": f"{_float_value(getattr(payment, 'rate', 1), 1.0):.3f}",
"suma": amount_base,
"suma_mena": amount,
"typ": fiscal_index,
"nazev_platby": payment_code,
"prg_dotaz": "",
"obsluzne": abs(_float_value(getattr(payment, "tip", 0), 0.0)),
"overpay": 0.0,
"karta": payment_uses_bankterm,
"loyalman_zlava": 0.0,
"fiskal_pro_payment_index": fiscal_index,
})
return _dict_with_1_based_keys(rows)
def _fiscal_receipt_text_dict(lines: list[str]) -> dict:
return _dict_with_1_based_keys([_strip_value(line) for line in lines if _strip_value(line)])
def _parse_receipt_datetime(value) -> datetime | None:
raw = str(value or "").strip()
if not raw:
return None
for fmt in ("%y%m%d %H:%M:%S", "%Y%m%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(raw[:19], fmt)
except Exception:
pass
return None
def _receipt_time_text(value) -> str:
raw = str(value or "").strip()
if not raw:
return ""
dt = _parse_receipt_datetime(raw)
if dt:
return dt.strftime("%H:%M")
return raw[:5]
def _receipt_date_text(value) -> str:
dt = _parse_receipt_datetime(value)
if dt:
return dt.strftime("%d.%m.%Y")
return datetime.now().strftime("%d.%m.%Y")
def _fiscal_receipt_text_top(ucet: data.Ucet) -> dict[str, str]:
open_time = _receipt_time_text(getattr(ucet, "open_at", ""))
closed_value = getattr(ucet, "closed_at", "") or getattr(ucet, "datetime", "")
closed_time = _receipt_time_text(closed_value)
date_text = _receipt_date_text(closed_value)
external_parts = []
if open_time or closed_time:
external_parts.append(f"{open_time}-{closed_time}".strip("-"))
external_parts.append(date_text)
if getattr(ucet, "ucislo", None):
external_parts.append(str(ucet.ucislo))
return {
"1": " ".join(part for part in external_parts if part),
}
def _has_tip_on_receipt(ucet: data.Ucet) -> bool:
return any(abs(_float_value(getattr(payment, "tip", 0), 0.0)) >= 0.005 for payment in (getattr(ucet, "platby", []) or []))
def _fiscal_receipt_text_bottom(ucet: data.Ucet, table_name: str = "") -> list[str]:
lines: list[str] = []
table_name = _strip_value(table_name or getattr(ucet, "stul", ""))
receipt_no = _strip_value(getattr(ucet, "ucislo", ""))
if receipt_no and table_name:
lines.append(f"{receipt_no}/ stôl: {table_name}")
elif receipt_no:
lines.append(receipt_no)
elif table_name:
lines.append(f"stôl: {table_name}")
author = _strip_value(getattr(ucet, "autor", ""))
if author:
lines.append(f"Obsluha: {author}")
if not _has_tip_on_receipt(ucet):
lines.append("TIP: ..............")
return lines
def _fiscal_receipt_vat(ucet: data.Ucet) -> list[dict]:
if not getattr(ucet, "dane", None):
try:
ucet = ucet.model_copy(deep=True)
ucet.sumdph()
except Exception:
pass
rows = []
for vat in getattr(ucet, "dane", []) or []:
hladina = _fiscal_dph_value(getattr(vat, "rate", ""))
zaklad = round(_float_value(getattr(vat, "zaklad", 0), 0.0), 2)
dan = round(zaklad * (hladina or 0) / 100, 2)
rows.append({
"hladina": hladina,
"zaklad": zaklad,
"dan": dan,
})
return rows
def _fiscal_receipt_bill_id(ucet: data.Ucet) -> str:
fiscal_result = getattr(ucet, "fiscal_result", {}) or {}
if not isinstance(fiscal_result, dict):
return ""
candidates = [
fiscal_result.get("bill_id"),
fiscal_result.get("BILL_ID"),
]
ret = fiscal_result.get("return")
if isinstance(ret, dict):
candidates.extend([ret.get("bill_id"), ret.get("BILL_ID")])
response = fiscal_result.get("response")
if isinstance(response, dict):
response_ret = response.get("return")
if isinstance(response_ret, dict):
candidates.extend([response_ret.get("bill_id"), response_ret.get("BILL_ID")])
else:
candidates.append(response_ret)
for candidate in candidates:
value = _strip_value(candidate)
if value:
return value
return ""
def _build_fiscal_receipt_payload(
req: data.FiscalReceiptPrintRequest,
route: dict,
bill_id: str = "",
table_name: str = "",
) -> dict:
ucet = req.ucet
is_storno = bool(getattr(ucet, "is_storno", None))
resolved_table_name = _strip_value(table_name or getattr(ucet, "stul", ""))
use_bankterm = _use_afs_bankterm(route, ucet)
fiscal_type = _fiscal_receipt_document_type(ucet)
return {
"ucet": _strip_value(getattr(ucet, "ucislo", "")),
"typ_poloziek": fiscal_type,
"storno": is_storno,
"preducet": False,
"email": _strip_value(getattr(ucet, "receipt_email", "")) if getattr(ucet, "send_receipt_email", False) else "",
"polozky": _fiscal_receipt_items(ucet),
"platby": _fiscal_receipt_payments(ucet, use_bankterm=use_bankterm),
"text_top": _fiscal_receipt_text_top(ucet),
"text_bottom": _fiscal_receipt_text_dict(_fiscal_receipt_text_bottom(ucet, resolved_table_name)),
"printer_name": _strip_value(route.get("prn_name", "")),
"printer_no": _strip_value(route.get("prn_no", "")),
"transaction_result": {},
"client_id": str(uuid.uuid4()),
"bill_id": bill_id or "",
"zaokruhlenie": round(_float_value(getattr(ucet, "round50", 0), 0.0), 2),
"cancel_payment_on_fail": False,
"discount_text": None,
"employee_name": _strip_value(getattr(ucet, "autor", "")),
"strih": _decode_printer_command(route.get("p_fullcut")),
"dph": _fiscal_receipt_vat(ucet),
"table_name": resolved_table_name,
}
def _fiskal_next_bill_id(route: dict) -> str:
url = f"{_fiskal_base_url(route)}/next_bill_id"
logger.info("Fiscal next_bill_id request: url=%s", url)
try:
response = requests.get(url, timeout=30, verify=False)
response.raise_for_status()
data_obj = response.json()
except requests.RequestException as exc:
raise RuntimeError(f"Nepodarilo sa ziskat next_bill_id z fiskalneho servera ({url}): {exc}") from exc
except Exception as exc:
raise RuntimeError(f"Fiskalny server vratil necitatelne next_bill_id ({url}): {exc}") from exc
logger.info("Fiscal next_bill_id response: %s", json.dumps(data_obj, ensure_ascii=False)[:1000])
if isinstance(data_obj, dict) and data_obj.get("code", 0) not in (0, "0", None):
raise RuntimeError(f"{data_obj.get('code')}: {data_obj.get('code_text') or data_obj}")
bill_id = _strip_value((data_obj or {}).get("return") if isinstance(data_obj, dict) else "")
if not bill_id:
raise RuntimeError("Fiskalny server nevratil next_bill_id")
return bill_id
def _fiskal_receipt_result(route: dict, bill_id: str, timeout: float = 30.0) -> dict:
bill_id = _strip_value(bill_id)
if not bill_id:
raise RuntimeError("Chyba bill_id pre overenie fiskalneho dokladu")
url = f"{_fiskal_base_url(route)}/paragon/{bill_id}/result"
logger.info("Fiscal receipt result request: url=%s", url)
try:
response = requests.get(url, timeout=timeout, verify=False)
response.raise_for_status()
except requests.RequestException as exc:
body = ""
resp = getattr(exc, "response", None)
if resp is not None:
body = str(getattr(resp, "text", "") or "")[:1000]
raise RuntimeError(f"Nepodarilo sa overit fiskalny doklad ({url}): {exc}; {body}") from exc
try:
response_data = response.json()
except Exception as exc:
logger.error("Fiscal receipt result invalid JSON response: %s", str(response.text or "")[:1000])
raise RuntimeError(f"Fiskalny server vratil necitatelny vysledok dokladu: {str(response.text or '')[:500]}") from exc
logger.info("Fiscal receipt result response: %s", json.dumps(response_data, ensure_ascii=False)[:2000])
if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None):
raise RuntimeError(
f"{response_data.get('code')}: "
f"{response_data.get('code_text') or response_data.get('message') or response_data}"
)
return response_data
def _send_fiscal_receipt_payload(route: dict, payload: dict, timeout: float = 300.0) -> dict:
url = f"{_fiskal_base_url(route)}/paragon/send"
logger.info(
"Fiscal receipt request: url=%s ucet=%s items=%s payments=%s",
url,
payload.get("ucet") or payload.get("receipt_no") or "",
len(payload.get("polozky") or {}),
len(payload.get("platby") or {}),
)
logger.info("Fiscal receipt payload: %s", json.dumps(payload, ensure_ascii=False)[:4000])
try:
response = requests.post(
url,
json=payload,
timeout=timeout,
verify=False,
)
response.raise_for_status()
except requests.exceptions.ReadTimeout as exc:
bill_id = _strip_value(payload.get("bill_id"))
if bill_id:
logger.warning(
"Fiscal receipt send timed out; checking already assigned bill_id=%s",
bill_id,
)
try:
return _fiskal_receipt_result(route, bill_id, timeout=30.0)
except Exception as result_exc:
logger.exception(
"Fiscal receipt result check failed after send timeout: bill_id=%s",
bill_id,
)
raise RuntimeError(
f"Fiskalny server neodpovedal na tlac a nepodarilo sa overit doklad {bill_id}: {result_exc}"
) from exc
raise RuntimeError(f"Fiskalny server neodpovedal na tlac ({url}): {exc}") from exc
except requests.RequestException as exc:
body = ""
resp = getattr(exc, "response", None)
if resp is not None:
body = str(getattr(resp, "text", "") or "")[:1000]
raise RuntimeError(f"Komunikacia s fiskalnym serverom zlyhala ({url}): {exc}; {body}") from exc
try:
response_data = response.json()
except Exception:
response_data = {"raw": response.text}
logger.error("Fiscal receipt invalid JSON response: %s", str(response.text or "")[:1000])
raise RuntimeError(f"Fiskalny server vratil necitatelnu odpoved: {str(response.text or '')[:500]}")
logger.info("Fiscal receipt response: %s", json.dumps(response_data, ensure_ascii=False)[:2000])
if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None):
raise RuntimeError(
f"{response_data.get('code')}: "
f"{response_data.get('code_text') or response_data.get('message') or response_data}"
)
return response_data
def _send_fiscal_receipt_copy(route: dict, bill_id: str, timeout: float = 300.0) -> dict:
bill_id = _strip_value(bill_id)
if not bill_id:
raise RuntimeError("Chyba bill_id pre tlac fiskalnej kopie")
url = f"{_fiskal_base_url(route)}/paragon/copy/{quote(bill_id, safe='')}"
logger.info("Fiscal receipt copy request: url=%s bill_id=%s", url, bill_id)
try:
response = requests.get(url, timeout=timeout, verify=False)
response.raise_for_status()
except requests.RequestException as exc:
body = ""
resp = getattr(exc, "response", None)
if resp is not None:
body = str(getattr(resp, "text", "") or "")[:1000]
raise RuntimeError(f"Komunikacia s fiskalnym serverom pri tlaci kopie zlyhala ({url}): {exc}; {body}") from exc
try:
response_data = response.json()
except Exception:
logger.error("Fiscal receipt copy invalid JSON response: %s", str(response.text or "")[:1000])
raise RuntimeError(f"Fiskalny server vratil necitatelnu odpoved pri tlaci kopie: {str(response.text or '')[:500]}")
logger.info("Fiscal receipt copy response: %s", json.dumps(response_data, ensure_ascii=False)[:2000])
if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None):
raise RuntimeError(
f"{response_data.get('code')}: "
f"{response_data.get('code_text') or response_data.get('message') or response_data}"
)
return response_data
def _send_fiscal_cash_operation(
route: dict,
payment_id: str,
payment_name: str,
amount: float,
timeout: float = 120.0,
) -> dict:
payment_id = quote(_strip_value(payment_id), safe="")
payment_name = quote(_strip_value(payment_name), safe="")
amount_text = quote(f"{float(amount):.2f}", safe="")
url = f"{_fiskal_base_url(route)}/depozite/{payment_id}/{payment_name}/{amount_text}"
logger.info("Fiscal cash operation request: url=%s", url)
try:
response = requests.get(url, timeout=timeout, verify=False)
response.raise_for_status()
except requests.RequestException as exc:
body = ""
resp = getattr(exc, "response", None)
if resp is not None:
body = str(getattr(resp, "text", "") or "")[:1000]
raise RuntimeError(f"Fiskalny vklad/vyber sa nepodarilo vykonat ({url}): {exc}; {body}") from exc
try:
response_data = response.json()
except Exception:
logger.error("Fiscal cash operation invalid JSON response: %s", str(response.text or "")[:1000])
raise RuntimeError(f"Fiskalny server vratil necitatelnu odpoved pri vklade/vybere: {str(response.text or '')[:500]}")
logger.info("Fiscal cash operation response: %s", json.dumps(response_data, ensure_ascii=False)[:2000])
if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None):
raise RuntimeError(
f"{response_data.get('code')}: "
f"{response_data.get('code_text') or response_data.get('message') or response_data}"
)
return response_data
def _fiscal_result_from_response(route: dict, response: dict) -> dict:
ret = response.get("return") if isinstance(response, dict) else None
result = {
"printer_no": _strip_value(route.get("prn_no", "")),
"printer_name": _strip_value(route.get("prn_name", "")),
"id_term": _strip_value(route.get("id_term", "")),
"printed_at": datetime.now().isoformat(sep=" ", timespec="seconds"),
}
if isinstance(response, dict):
result["code"] = response.get("code", 0)
if response.get("code_text") or response.get("message"):
result["message"] = response.get("code_text") or response.get("message")
if isinstance(ret, dict):
result["okp"] = ret.get("OKP") or ret.get("okp") or ""
result["uid"] = ret.get("UID") or ret.get("uid") or ""
result["bill_id"] = ret.get("BILL_ID") or ret.get("bill_id") or ""
if ret.get("transaction_result"):
result["transaction_result"] = ret.get("transaction_result")
elif ret not in (None, 0, False):
result["bill_id"] = str(ret)
return result
def _store_ucet_fiscal_result(prefix: str, ucet: data.Ucet) -> data.Ucet:
if not getattr(ucet, "ucislo", None):
return ucet
table = f"{prefix}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT data FROM "{table}" WHERE ucislo=? AND id_kas=?',
(ucet.ucislo, ucet.id_kas),
)
row = cur.fetchone()
if row:
try:
stored = data.Ucet.model_validate_json(row[0])
except Exception:
stored = ucet
stored.fiscal_result = dict(getattr(ucet, "fiscal_result", {}) or {})
payload = stored.model_dump_json()
cur.execute(
f'UPDATE "{table}" SET data=? WHERE ucislo=? AND id_kas=?',
(payload, ucet.ucislo, ucet.id_kas),
)
conn.commit()
return stored
return ucet
@app.post("/print/fiscal/receipt/", response_model=data.FiscalReceiptPrintOut)
def print_fiscal_receipt(
req: data.FiscalReceiptPrintRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return print_fiscal_receipt_db(prefix, req)
def print_fiscal_receipt_db(
prefix: str,
req: data.FiscalReceiptPrintRequest,
) -> data.FiscalReceiptPrintOut:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
if not getattr(req.ucet, "ucislo", None):
raise HTTPException(422, "Fiscal receipt must have ucislo")
if not _ucet_has_fiscal_payment(req.ucet):
raise HTTPException(409, "Ucet nema fiskalnu platbu")
printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip()
if not printer_no:
raise HTTPException(422, "Receipt printer is not set")
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
target_numbers = _printer_target_numbers(printer_no, prndef_map)
if len(target_numbers) != 1:
raise HTTPException(409, "Fiskalny original musi mat prave jednu cielovu tlaciaren")
target_no = target_numbers[0]
route = _printer_route(prndef_map.get(target_no))
if route.get("route_type") != "fiskal":
raise HTTPException(409, f"Tlaciaren {target_no} nie je nastavena ako FISKAL")
try:
bill_id = _fiskal_next_bill_id(route)
except Exception as exc:
logger.exception(
"Fiscal next_bill_id failed: prefix=%s id_kas=%s ucet=%s printer=%s",
prefix,
req.id_kas,
getattr(req.ucet, "ucislo", ""),
target_no,
)
raise HTTPException(502, f"Fiskalny doklad nebol vytlaceny: {exc}") from exc
table_name = resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", ""))
payload = _build_fiscal_receipt_payload(req, route, bill_id=bill_id, table_name=table_name)
logger.info(
"Fiscal receipt send: prefix=%s id_kas=%s ucet=%s printer=%s payments=%s",
prefix,
req.id_kas,
getattr(req.ucet, "ucislo", ""),
target_no,
len(getattr(req.ucet, "platby", []) or []),
)
try:
response = _send_fiscal_receipt_payload(route, payload)
except HTTPException:
raise
except Exception as exc:
logger.exception(
"Fiscal receipt failed: prefix=%s id_kas=%s ucet=%s printer=%s",
prefix,
req.id_kas,
getattr(req.ucet, "ucislo", ""),
target_no,
)
raise HTTPException(502, f"Fiskalny doklad nebol vytlaceny: {exc}") from exc
fiscal_result = _fiscal_result_from_response(route, response)
result_ucet = req.ucet.model_copy(deep=True)
result_ucet.fiscal_result = fiscal_result
result_ucet = _store_ucet_fiscal_result(prefix, result_ucet)
return data.FiscalReceiptPrintOut(
ok=True,
ucet=result_ucet,
response=response if isinstance(response, dict) else {"raw": response},
fiscal_result=fiscal_result,
)
@app.post("/print/fiscal/receipt/copy/", response_model=data.FiscalReceiptPrintOut)
def print_fiscal_receipt_copy(
req: data.FiscalReceiptCopyRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return print_fiscal_receipt_copy_db(prefix, req)
def print_fiscal_receipt_copy_db(
prefix: str,
req: data.FiscalReceiptCopyRequest,
) -> data.FiscalReceiptPrintOut:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
if not getattr(req.ucet, "ucislo", None):
raise HTTPException(422, "Fiscal receipt copy must have ucislo")
if not _ucet_has_fiscal_payment(req.ucet):
raise HTTPException(409, "Ucet nema fiskalnu platbu")
bill_id = _strip_value(req.bill_id) or _fiscal_receipt_bill_id(req.ucet)
if not bill_id:
raise HTTPException(422, "Ucet nema ulozeny bill_id pre tlac fiskalnej kopie")
printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip()
if not printer_no:
raise HTTPException(422, "Receipt printer is not set")
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
target_numbers = _printer_target_numbers(printer_no, prndef_map)
if len(target_numbers) != 1:
raise HTTPException(409, "Fiskalna kopia musi mat prave jednu cielovu tlaciaren")
target_no = target_numbers[0]
route = _printer_route(prndef_map.get(target_no))
if route.get("route_type") != "fiskal":
raise HTTPException(409, f"Tlaciaren {target_no} nie je nastavena ako FISKAL")
try:
response = _send_fiscal_receipt_copy(route, bill_id)
except Exception as exc:
logger.exception(
"Fiscal receipt copy failed: prefix=%s id_kas=%s ucet=%s printer=%s bill_id=%s",
prefix,
req.id_kas,
getattr(req.ucet, "ucislo", ""),
target_no,
bill_id,
)
raise HTTPException(502, f"Fiskalna kopia dokladu nebola vytlacena: {exc}") from exc
fiscal_result = _fiscal_result_from_response(route, response)
fiscal_result.update({
"copy": True,
"bill_id": bill_id,
})
return data.FiscalReceiptPrintOut(
ok=True,
ucet=req.ucet,
response=response if isinstance(response, dict) else {"raw": response},
fiscal_result=fiscal_result,
)
@app.post("/print/fiscal/cash-operation/", response_model=data.FiscalCashOperationOut)
def print_fiscal_cash_operation(
req: data.FiscalCashOperationRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, client_id = auth
return print_fiscal_cash_operation_db(prefix, req, client_id)
def _cash_operation_label(operation: str) -> str:
op = str(operation or "").strip().lower()
if op in {"manual_deposit", "deposit", "vklad"}:
return "Vklad"
if op in {"manual_withdrawal", "withdrawal", "withdraw", "vyber", "výber"}:
return "Vyber"
raise HTTPException(422, "Invalid cash operation")
def _cash_operation_kind(operation: str) -> tuple[str, str]:
op = str(operation or "").strip().lower()
if op in {"manual_deposit", "deposit", "vklad"}:
return "manual_deposit", "Vklad"
if op == "auto_deposit":
return "auto_deposit", "Vklad"
if op in {"manual_withdrawal", "withdrawal", "withdraw", "vyber", "vyber"}:
return "manual_withdrawal", "Vyber"
if op == "auto_withdrawal":
return "auto_withdrawal", "Vyber"
raise HTTPException(422, "Invalid cash operation")
def _cash_operation_origin(operation_code: str, operation_label: str) -> str:
if str(operation_code or "").startswith("auto_"):
return f"Auto {operation_label.lower()}"
return operation_label
def insert_fiscal_cash_operation_ucet(
cur_pref: str,
uct: data.Ucet,
) -> dict:
table = f"{cur_pref}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
if not uct.ucislo:
uct.ucislo = generate_ucislo(cur, table, uct.id_kas)
if not uct.closed_at:
uct.closed_at = data.stime_str()
if not uct.datetime:
uct.datetime = uct.closed_at
if not uct.open_at:
uct.open_at = uct.closed_at
uct.stul = _strip_value(getattr(uct, "stul", ""))
uct.table_name = _strip_value(getattr(uct, "table_name", ""))
uct.room_name = _strip_value(getattr(uct, "room_name", ""))
uct.blocked_by = ""
uct.checksum_val = uct.checksum()
payload = uct.model_dump_json()
cur.execute(f"""
INSERT INTO "{table}"
(ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
uct.ucislo,
uct.id_kas,
uct.stul,
uct.room_name,
"",
uct.closed_at,
uct.c_uzaverka,
payload,
))
return ucet_save_response({
"operation": "insert-cash-operation",
"ucislo": uct.ucislo,
}, uct)
def print_fiscal_cash_operation_db(
prefix: str,
req: data.FiscalCashOperationRequest,
client_id: str,
c_uzaverka: int | None = None,
) -> data.FiscalCashOperationOut:
if len(req.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
amount = round(abs(_float_value(req.amount, 0.0)), 2)
if amount <= 0:
raise HTTPException(422, "Suma musi byt vacsia ako nula")
operation, operation_label = _cash_operation_kind(req.operation)
signed_amount = amount if operation.endswith("deposit") else -amount
payment = req.payment
if not bool(getattr(payment, "fiscal", False)):
raise HTTPException(409, "Pre vklad/vyber je mozne pouzit iba fiskalnu platbu")
printer_no = _strip_value(req.printer_no)
if not printer_no:
raise HTTPException(422, "Nie je nastavena fiskalna tlaciaren")
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
target_numbers = _printer_target_numbers(printer_no, prndef_map)
if len(target_numbers) != 1:
raise HTTPException(409, "Vklad/vyber musi mat prave jednu cielovu fiskalnu tlaciaren")
target_no = target_numbers[0]
route = _printer_route(prndef_map.get(target_no))
if route.get("route_type") != "fiskal":
raise HTTPException(409, f"Tlaciaren {target_no} nie je nastavena ako FISKAL")
payment_id = str(_payment_fiscal_index(payment))
payment_name = _strip_value(getattr(payment, "name", "") or getattr(payment, "code", ""))
try:
response = _send_fiscal_cash_operation(route, payment_id, payment_name, signed_amount)
except HTTPException:
raise
except Exception as exc:
logger.exception(
"Fiscal cash operation failed: prefix=%s id_kas=%s operation=%s printer=%s",
prefix,
req.id_kas,
operation,
target_no,
)
raise HTTPException(502, f"Fiskalny {operation_label.lower()} nebol vykonany: {exc}") from exc
fiscal_result = _fiscal_result_from_response(route, response)
fiscal_result.update({
"cash_operation": operation,
"amount": signed_amount,
"payment_code": _strip_value(getattr(payment, "code", "")),
"payment_name": payment_name,
})
now_text = data.stime_str()
ucet = data.Ucet(
id_kas=req.id_kas,
ucislo=None,
stul="",
table_name="",
room_name="",
autor=_strip_value(req.author),
open_at=now_text,
closed_at=now_text,
datetime=now_text,
origin=_cash_operation_origin(operation, operation_label),
cash_operation=operation,
c_uzaverka=c_uzaverka,
bill_printer=target_no,
total_base_currency=signed_amount,
fiscal_result=fiscal_result,
platby=[
data.Platba(
code=_strip_value(getattr(payment, "code", "")),
nazev=payment_name,
suma=signed_amount,
unit=_strip_value(getattr(payment, "unit", "")),
rate=_float_value(getattr(payment, "rate", 1.0), 1.0),
suma_czk=signed_amount,
fiscal=True,
is_bankterm=False,
p_kopii=int(getattr(payment, "p_kopii", 1) or 1),
)
],
poloz=[],
dane=[],
)
try:
insert_fiscal_cash_operation_ucet(prefix, ucet)
except Exception as exc:
logger.exception(
"Fiscal cash operation was printed but account save failed: prefix=%s id_kas=%s operation=%s",
prefix,
req.id_kas,
operation,
)
raise HTTPException(500, f"Fiskalny {operation_label.lower()} bol vytlaceny, ale doklad sa nepodarilo ulozit: {exc}") from exc
return data.FiscalCashOperationOut(
ok=True,
ucet=ucet,
response=response if isinstance(response, dict) else {"raw": response},
fiscal_result=fiscal_result,
)
def _print_template_out(path: Path, kind: str = "bon") -> data.PrintTemplateOut:
stem = path.stem
scope = "custom"
printer_no = ""
template_kind = str(kind or "bon").strip().lower()
if template_kind in {"closure", "clsrep", "uzavierka"}:
prefix_name = "TP-closure"
output_kind = "closure"
elif template_kind in {"ucet", "receipt", "bill"}:
prefix_name = "TP-ucet"
output_kind = "ucet"
else:
prefix_name = "TP-bon"
output_kind = "bon"
if stem == f"{prefix_name}_default":
scope = "default"
else:
match = re.fullmatch(rf"{re.escape(prefix_name)}_(.+)", stem)
if match:
suffix = match.group(1)
if suffix.isdigit():
scope = "printer"
printer_no = suffix
else:
scope = "named"
try:
stat = path.stat()
size = int(stat.st_size)
modified_at = datetime.fromtimestamp(stat.st_mtime).isoformat(sep=" ", timespec="seconds")
except Exception:
size = 0
modified_at = ""
return data.PrintTemplateOut(
name=path.name,
kind=output_kind,
template_bon=path.name if output_kind == "bon" else "",
template_ucet=path.name if output_kind == "ucet" else "",
template_closure=path.name if output_kind == "closure" else "",
scope=scope,
printer_no=printer_no,
size=size,
modified_at=modified_at,
)
def list_print_templates_db(kind: str = "bon") -> list[data.PrintTemplateOut]:
template_kind = str(kind or "bon").strip().lower()
if template_kind not in {"bon", "kitchen_bon", "ucet", "receipt", "bill", "closure", "clsrep", "uzavierka"}:
raise HTTPException(422, f"Unsupported print template kind {kind}")
if template_kind in {"closure", "clsrep", "uzavierka"}:
template_prefix = "TP-closure"
elif template_kind in {"ucet", "receipt", "bill"}:
template_prefix = "TP-ucet"
else:
template_prefix = "TP-bon"
templates_dir = _print_templates_dir()
base_dir = Path(__file__).resolve().parent
if not templates_dir.exists() and not base_dir.exists():
return []
files: list[Path] = []
for suffix in PRINT_TEMPLATE_EXTENSIONS:
if templates_dir.exists():
files.extend(templates_dir.glob(f"{template_prefix}*{suffix}"))
if template_prefix == "TP-closure":
files.extend(base_dir.glob(f"{template_prefix}*{suffix}"))
unique = {path.name: path for path in files if path.is_file()}
result = [_print_template_out(path, kind=template_kind) for path in unique.values()]
scope_order = {"default": 0, "printer": 1, "named": 2, "custom": 3}
return sorted(result, key=lambda item: (scope_order.get(item.scope, 9), item.printer_no or "", item.name.lower()))
@app.get("/print/templates/", response_model=list[data.PrintTemplateOut])
def list_print_templates(
kind: str = Query(default="bon"),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
return list_print_templates_db(kind)
@app.get("/print/jobs/", response_model=list[data.PrintJob])
def list_print_jobs(
id_kas: str,
status: str | None = Query(default=None),
printer_no: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_print_jobs"
sql = f'SELECT {_print_job_select_columns()} FROM "{table}" WHERE id_kas=?'
params: list = [id_kas]
if status:
sql += " AND status=?"
params.append(status)
if printer_no:
sql += " AND printer_no=?"
params.append(printer_no)
sql += " ORDER BY id DESC LIMIT ?"
params.append(int(limit))
with get_db() as conn:
cur = conn.cursor()
cur.execute(sql, tuple(params))
return [_print_job_from_row(row) for row in cur.fetchall()]
@app.post("/print/jobs/claim/", response_model=list[data.PrintJob])
def claim_print_jobs(
req: data.PrintJobClaimRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return claim_print_jobs_db(prefix, req)
def claim_print_jobs_db(prefix: str, req: data.PrintJobClaimRequest) -> list[data.PrintJob]:
if not req.agent_id.strip():
raise HTTPException(422, "Invalid agent_id")
table = f"{prefix}_print_jobs"
limit = max(1, min(int(req.limit or 10), 100))
printers = [p for p in (req.printers or []) if str(p).strip()]
placeholders = ",".join("?" for _ in PRINT_JOB_ACTIVE_STATUSES)
params: list = [*PRINT_JOB_ACTIVE_STATUSES]
sql = f"""
SELECT id
FROM "{table}"
WHERE status IN ({placeholders})
"""
if printers:
prn_placeholders = ",".join("?" for _ in printers)
sql += f" AND (printer_no='' OR printer_no IN ({prn_placeholders}))"
params.extend(printers)
sql += " ORDER BY priority, id LIMIT ?"
params.append(limit)
with get_db() as conn:
cur = conn.cursor()
prndef_map = _load_prndef_map_cur(cur, prefix)
cur.execute(sql, tuple(params))
ids = [int(row[0]) for row in cur.fetchall()]
claimed: list[data.PrintJob] = []
for job_id in ids:
cur.execute(
f"""
UPDATE "{table}"
SET status='claimed',
agent_id=?,
attempts=attempts+1,
claimed_at=CURRENT_TIMESTAMP,
updated_at=CURRENT_TIMESTAMP
WHERE id=?
AND status IN ({placeholders})
""",
(req.agent_id, job_id, *PRINT_JOB_ACTIVE_STATUSES),
)
if cur.rowcount:
job = _load_print_job_cur(cur, table, job_id)
route = _printer_route(prndef_map.get(str(job.printer_no or "")))
if route:
payload = dict(job.payload or {})
payload["route"] = route
cur.execute(
f'UPDATE "{table}" SET payload=?, updated_at=CURRENT_TIMESTAMP WHERE id=?',
(_json_dump_obj(payload), job_id),
)
job = _load_print_job_cur(cur, table, job_id)
claimed.append(job)
conn.commit()
return claimed
@app.post("/print/jobs/{job_id}/status", response_model=data.PrintJob)
def update_print_job_status(
job_id: int,
update: data.PrintJobStatusUpdate,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_print_job_status_db(prefix, job_id, update)
def update_print_job_status_db(
prefix: str,
job_id: int,
update: data.PrintJobStatusUpdate,
) -> data.PrintJob:
status = (update.status or "").strip()
if status not in PRINT_JOB_VISIBLE_STATUSES:
raise HTTPException(422, f"Invalid print job status {status}")
table = f"{prefix}_print_jobs"
set_parts = [
"status=?",
"result=?",
"error=?",
"updated_at=CURRENT_TIMESTAMP",
]
params: list = [
status,
_json_dump_obj(update.result),
update.error or "",
]
if status == "printing":
set_parts.append("started_at=COALESCE(started_at, CURRENT_TIMESTAMP)")
if status in {"printed", "failed", "failed_final", "cancelled"}:
set_parts.append("finished_at=CURRENT_TIMESTAMP")
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'UPDATE "{table}" SET {", ".join(set_parts)} WHERE id=?',
(*params, job_id),
)
if cur.rowcount == 0:
raise HTTPException(404, f"Print job {job_id} not found")
conn.commit()
return _load_print_job_cur(cur, table, job_id)
@app.post("/print/jobs/{job_id}/retry", response_model=data.PrintJob)
def retry_print_job(
job_id: int,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return retry_print_job_db(prefix, job_id)
def retry_print_job_db(prefix: str, job_id: int) -> data.PrintJob:
table = f"{prefix}_print_jobs"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
UPDATE "{table}"
SET status='queued',
agent_id=NULL,
attempts=0,
result='{{}}',
error='',
claimed_at=NULL,
started_at=NULL,
finished_at=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=?
AND status != 'printed'
""",
(job_id,),
)
if cur.rowcount == 0:
raise HTTPException(404, f"Print job {job_id} not found or already printed")
conn.commit()
return _load_print_job_cur(cur, table, job_id)
def update_print_job_payload_db(prefix: str, job_id: int, payload: dict) -> data.PrintJob:
table = f"{prefix}_print_jobs"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'UPDATE "{table}" SET payload=?, updated_at=CURRENT_TIMESTAMP WHERE id=?',
(_json_dump_obj(payload), job_id),
)
if cur.rowcount == 0:
raise HTTPException(404, f"Print job {job_id} not found")
conn.commit()
return _load_print_job_cur(cur, table, job_id)
def _route_int(route: dict, key: str, default: int) -> int:
try:
value = int(str(route.get(key) or "").strip())
return value if value > 0 else default
except Exception:
return default
def _require_route_value(route: dict, key: str) -> str:
value = str(route.get(key) or "").strip()
if not value:
raise RuntimeError(f"Printer route missing {key}")
return value
DEFAULT_KITCHEN_BON_TEMPLATE = """{{ printer.reset }}
{{ printer.bigfont_on }}Stol: {{ table_name|truncate(printer.max_characters - 6) }}{{ printer.bigfont_off }}
{{ cashier_name|truncate(printer.max_characters) }}
{{ created_at.strftime('%d.%m.%Y,%H:%M') }} => {{ printer_name|truncate(printer.max_characters - 21) }}
{{ '-'|repeat(printer.max_characters) }}
{% if is_storno %}
{% if is_bill_cancel %}
{{ "STORNO STAREHO UCTU"|box('*', printer.max_characters) }}
{% else %}
{{ "S T O R N O"|box('*', printer.max_characters) }}
{% endif %}
{% endif %}
{% for item in items %}
{% if item.print_course_header %}
{% set chod = '******** Chod: ' + item.course_name|string|trim + ' ********' %}
{{ chod|center(printer.max_characters) }}
{% endif %}
{% if item.print_guest_header %}
{% set host = '---- Host: ' + item.guest_name|string|trim + ' ----' %}
{{ host|center(printer.max_characters) }}
{% endif %}
{{ item.description }}
{% for line in item.order_lines %}
{{ line }}
{% endfor %}
{% endfor %}
{{ printer.bigfont_on }}
Stol: {{ table_name|truncate(printer.max_characters - 6) }}{{ printer.bigfont_off }}
Casnik: {{ user|truncate(printer.max_characters - 8) }}
{% if locator_number %}
{{ '*'|repeat(printer.max_characters) }}
Cislo objednavky: {{ locator_number }}
{{ '*'|repeat(printer.max_characters) }}
{% endif %}
Bon: {{ bon_count }}
{{ cashier_name|truncate(printer.max_characters) }}
{{ printer.crlf }}
{{ printer.fullcut }}
"""
DEFAULT_RECEIPT_TEMPLATE = """{{ printer.reset }}{{ separator }}
{{ header_lines|join('\n') }}
{{ separator }}
{{ item_lines|join('\n') }}
{{ separator }}
{{ summary_lines|join('\n') }}{% if payment_lines %}{{ '\n' }}{{ separator }}
{{ payment_lines|join('\n') }}{% endif %}{% if tax_lines %}{{ '\n' }}{{ separator }}
{{ tax_lines|join('\n') }}{% endif %}{% if footer_lines %}{{ '\n' }}{{ separator }}
{{ footer_lines|join('\n') }}{% endif %}{{ '\n' }}{{ separator }}
{{ printer.crlf }}
{{ printer.fullcut }}
"""
def _format_print_quantity(pol, *, absolute: bool = False) -> str:
pocet = _float_value(getattr(pol, "pocet", 0), 0.0)
if absolute:
pocet = abs(pocet)
delitel = _int_value(getattr(pol, "delitel", 1), 1) or 1
if delitel != 1:
left = int(pocet) if float(pocet).is_integer() else pocet
return f"{left}/{delitel}"
return str(int(pocet)) if float(pocet).is_integer() else str(pocet)
def _printer_width(route: dict | None, default: int = 40) -> int:
route = route or {}
try:
width = int(str(route.get("p_width") or "").strip())
except Exception:
width = default
return max(32, min(width or default, 80))
def _decode_printer_command(value) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("latin-1", errors="ignore")
text = str(value)
if not text:
return ""
stripped = text.strip()
if (stripped.startswith("b'") or stripped.startswith('b"')) and stripped[-1:] in {"'", '"'}:
try:
parsed = ast.literal_eval(stripped)
if isinstance(parsed, bytes):
return parsed.decode("latin-1", errors="ignore")
except Exception:
pass
def chr_repl(match):
try:
return chr(int(match.group(1)))
except Exception:
return match.group(0)
had_chr_syntax = bool(re.search(r"(?i)chr\(\s*\d{1,3}\s*\)", text))
text = re.sub(r"(?i)chr\(\s*(\d{1,3})\s*\)", chr_repl, text)
if had_chr_syntax:
text = re.sub(r"\s*\+\s*", "", text)
def brace_repl(match):
try:
return chr(int(match.group(1)))
except Exception:
return match.group(0)
text = re.sub(r"\{(\d{1,3})\}", brace_repl, text)
def hex_repl(match):
try:
return chr(int(match.group(1), 16))
except Exception:
return match.group(0)
text = re.sub(r"\\x([0-9a-fA-F]{2})", hex_repl, text)
if "\\" in text:
text = (
text
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
)
return text
PRINT_TEMPLATE_EXTENSIONS = (".jinja2", ".jinja")
def _print_templates_dir() -> Path:
return Path(__file__).with_name("templates")
def _template_name_candidates(name: str) -> list[Path]:
raw_name = str(name or "").strip()
if not raw_name:
return []
path = Path(raw_name)
if path.is_absolute():
base = path
else:
base = _print_templates_dir() / path.name
candidates = [base]
if base.suffix.lower() not in PRINT_TEMPLATE_EXTENSIONS:
candidates.extend(Path(f"{base}{suffix}") for suffix in PRINT_TEMPLATE_EXTENSIONS)
unique: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key not in seen:
unique.append(candidate)
seen.add(key)
return unique
def _kitchen_bon_template_candidates(route: dict | None = None) -> list[Path]:
route = route or {}
candidates: list[Path] = []
candidates.extend(_template_name_candidates(route.get("template_bon") or ""))
prn_no = str(route.get("prn_no") or "").strip()
if prn_no:
candidates.extend(_template_name_candidates(f"TP-bon_{prn_no}"))
candidates.extend(_template_name_candidates("TP-bon_default"))
unique: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key not in seen:
unique.append(candidate)
seen.add(key)
return unique
def _receipt_template_candidates(route: dict | None = None) -> list[Path]:
route = route or {}
candidates: list[Path] = []
candidates.extend(_template_name_candidates(route.get("template_ucet") or ""))
prn_no = str(route.get("prn_no") or "").strip()
if prn_no:
candidates.extend(_template_name_candidates(f"TP-ucet_{prn_no}"))
candidates.extend(_template_name_candidates("TP-ucet_default"))
unique: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key not in seen:
unique.append(candidate)
seen.add(key)
return unique
def _resolve_kitchen_bon_template(route: dict | None = None) -> tuple[str, str]:
candidates = _kitchen_bon_template_candidates(route)
for path in candidates:
try:
if path.exists():
return path.read_text(encoding="utf-8"), path.name
except Exception as exc:
logger.warning(f"Cannot load kitchen bon template {path}: {exc}")
return DEFAULT_KITCHEN_BON_TEMPLATE, "internal_fallback"
def _resolve_receipt_template(route: dict | None = None) -> tuple[str, str]:
candidates = _receipt_template_candidates(route)
for path in candidates:
try:
if path.exists():
return path.read_text(encoding="utf-8"), path.name
except Exception as exc:
logger.warning(f"Cannot load receipt template {path}: {exc}")
return DEFAULT_RECEIPT_TEMPLATE, "internal_fallback"
def _load_kitchen_bon_template(route: dict | None = None) -> str:
return _resolve_kitchen_bon_template(route)[0]
def _kitchen_bon_template_source(route: dict | None = None) -> str:
return _resolve_kitchen_bon_template(route)[1]
def _box_filter(value, char="*", width=40, label="") -> str:
try:
width = int(width)
except Exception:
width = 40
width = max(10, width)
text = str(value or "")
if label:
text = f"{label} {text}".strip()
return "\n".join([
str(char or "*")[:1] * width,
text.center(width),
str(char or "*")[:1] * width,
])
def _repeat_filter(value, count=1) -> str:
try:
count = int(count)
except Exception:
count = 1
return str(value or "") * max(0, count)
def _guest_course_map(items: list, default_start: int) -> dict[str, int]:
result: dict[str, int] = {}
for idx, item in enumerate(items or [], start=default_start):
if isinstance(item, dict):
item_id = item.get("id")
else:
item_id = getattr(item, "id", None)
if item_id:
result[str(item_id)] = idx
return result
def _guest_course_name_map(items: list, default_start: int) -> dict[str, str]:
result: dict[str, str] = {}
for idx, item in enumerate(items or [], start=default_start):
if isinstance(item, dict):
item_id = item.get("id")
item_name = item.get("name")
else:
item_id = getattr(item, "id", None)
item_name = getattr(item, "name", None)
if item_id:
result[str(item_id)] = str(item_name or idx)
return result
def _created_at_for_print(job: data.PrintJob):
raw = str(job.created_at or "").strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(raw[:19], fmt)
except Exception:
pass
return datetime.now()
def _fit_print_text(value, width: int) -> str:
text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
return text[:max(0, int(width or 0))]
def _money_text(value, currency: str = "") -> str:
try:
amount = float(value or 0)
except Exception:
amount = 0.0
suffix = str(currency or "").strip()
text = f"{amount:.2f}"
return f"{text} {suffix}" if suffix else text
def _two_col_text(left, right, width: int) -> str:
width = max(10, int(width or 40))
left_s = str(left or "").replace("\r", " ").replace("\n", " ").strip()
right_s = str(right or "").replace("\r", " ").replace("\n", " ").strip()
if len(right_s) >= width:
return right_s[-width:]
left_width = max(1, width - len(right_s) - 1)
return f"{left_s[:left_width]:<{left_width}} {right_s}"
def _fixed_col_text(value, width: int, *, align: str = "left", precision: int | None = None) -> str:
if precision is not None:
try:
text = f"{float(value or 0):.{precision}f}"
except Exception:
text = str(value or "")
else:
text = str(value or "")
text = text.replace("\r", " ").replace("\n", " ").strip()
if len(text) > width:
text = text[:width] if align == "left" else text[-width:]
if align == "right":
return text.rjust(width)
if align == "center":
return text.center(width)
return text.ljust(width)
def _receipt_table_widths(width: int) -> tuple[int, int, int, int]:
dph_w = 5 if width >= 42 else 4
qty_w = 9
total_w = 11 if width >= 42 else 10
name_w = max(10, width - qty_w - total_w - dph_w)
return name_w, qty_w, total_w, dph_w
def _receipt_dph_percent(rate) -> str:
raw = str(rate or "").strip()
try:
value = float(raw)
if value == -1:
return "bez"
pct = (value - 1) * 100 if value > 1 else value * 100
return f"{pct:g}"
except Exception:
return raw.replace("%", "")
def _receipt_item_table_lines(ucet: data.Ucet, width: int, currency: str) -> list[str]:
name_w, qty_w, total_w, dph_w = _receipt_table_widths(width)
lines = [
(
_fixed_col_text("Nazov tovaru", name_w)
+ _fixed_col_text("Mnozstvo", qty_w, align="right")
+ _fixed_col_text("Celkom", total_w, align="right")
+ _fixed_col_text("%DPH", dph_w, align="right")
),
"-" * width,
]
for pol in getattr(ucet, "poloz", []) or []:
delitel = _int_value(getattr(pol, "delitel", 1), 1) or 1
qty_value = _float_value(getattr(pol, "pocet", 0), 0.0) / delitel
price = _float_value(getattr(pol, "cena", 0), 0.0)
line_total = round(qty_value * price, 2)
name = str(getattr(pol, "nazev", "") or "")
dph_text = _receipt_dph_percent(getattr(pol, "dph", ""))
if len(name) <= name_w:
lines.append(
_fixed_col_text(name, name_w)
+ _fixed_col_text(qty_value, qty_w, align="right", precision=3)
+ _fixed_col_text(line_total, total_w, align="right", precision=2)
+ _fixed_col_text(dph_text, dph_w, align="right")
)
else:
lines.append(_fit_print_text(name, width))
lines.append(
_fixed_col_text(_money_text(price, currency), name_w)
+ _fixed_col_text(qty_value, qty_w, align="right", precision=3)
+ _fixed_col_text(line_total, total_w, align="right", precision=2)
+ _fixed_col_text(dph_text, dph_w, align="right")
)
if getattr(pol, "cenhlad", "") and str(getattr(pol, "cenhlad", "")).strip() != "0":
lines.append(_fit_print_text(f" Hladina: {getattr(pol, 'cenhlad', '')}", width))
for msg in getattr(pol, "zpravy", []) or []:
lines.append(_fit_print_text(f" {msg}", width))
return lines
def _receipt_tax_table_lines(ucet: data.Ucet, width: int, currency: str) -> list[str]:
rate_w = 6
base_w = 12
tax_w = 12
turnover_w = max(8, width - rate_w - base_w - tax_w)
lines = [
_fixed_col_text("Sadzba", rate_w)
+ _fixed_col_text("Zaklad", base_w, align="right")
+ _fixed_col_text("Dan", tax_w, align="right")
+ _fixed_col_text("Obrat", turnover_w, align="right")
]
total_turnover = 0.0
for dph in getattr(ucet, "dane", []) or []:
rate = _float_value(getattr(dph, "rate", 0), 0.0)
base = _float_value(getattr(dph, "zaklad", 0), 0.0)
tax = 0.0 if rate == -1 else (base * (rate - 1) if rate > 1 else base * rate)
turnover = base + tax
total_turnover += turnover
rate_text = _receipt_dph_percent(getattr(dph, "rate", ""))
rate_label = rate_text if rate_text == "bez" else f"{rate_text}%"
lines.append(
_fixed_col_text(rate_label, rate_w)
+ _fixed_col_text(base, base_w, align="right", precision=2)
+ _fixed_col_text(tax, tax_w, align="right", precision=2)
+ _fixed_col_text(turnover, turnover_w, align="right", precision=2)
)
lines.append(_two_col_text("SPOLU", _money_text(total_turnover, currency), width))
return lines
def _dph_label(rate) -> str:
raw = str(rate or "").strip()
try:
value = float(raw)
if value == -1:
return "bez DPH"
if value > 1:
return f"{round((value - 1) * 100, 2):g}%"
return f"{round(value * 100, 2):g}%"
except Exception:
return raw
def _center_print_lines(lines: list[str], width: int) -> list[str]:
return [
_fit_print_text(line, width).center(width)
for line in (lines or [])
if str(line or "").strip()
]
def _receipt_note_fields(note: str, marker: str, fields: list[str]) -> list[tuple[str, str]]:
if not note or marker not in note:
return []
text = str(note).split(marker, 1)[1].strip()
result = []
for idx, field in enumerate(fields):
start = text.find(field)
if start == -1:
continue
value_start = start + len(field)
next_positions = [
text.find(next_field, value_start)
for next_field in fields[idx + 1:]
if text.find(next_field, value_start) != -1
]
value_end = min(next_positions) if next_positions else len(text)
value = text[value_start:value_end].strip(" ,")
if value:
result.append((field, value))
return result
def _receipt_object_lines(title: str, values: list[tuple[str, object]], width: int) -> list[str]:
lines = []
clean_values = [
(label, str(value or "").strip())
for label, value in values
if str(value or "").strip()
]
if not clean_values:
return lines
lines.append(_fit_print_text(title, width))
for label, value in clean_values:
lines.append(_two_col_text(f" {label}", value, width))
return lines
def _receipt_uver_lines(uver, width: int) -> list[str]:
if not uver:
return []
return _receipt_object_lines(
"Uverovy zaznam",
[
("Firma", getattr(uver, "hjmeno", "")),
("Akcia", getattr(uver, "akcia", "")),
("Adresa", getattr(uver, "adresa1", "")),
("", getattr(uver, "adresa2", "")),
("", getattr(uver, "adresa3", "")),
("ICO", getattr(uver, "ico", "")),
("DIC", getattr(uver, "dic", "")),
("IC DPH", getattr(uver, "icdph", "")),
("Schvalil", getattr(uver, "schvalil", "")),
],
width,
)
def _receipt_hotel_lines(target, width: int) -> list[str]:
if not target:
return []
if str(getattr(target, "target_type", "") or "") == "group":
values = [
("Recepcia", getattr(target, "reception_name", "")),
("Skupina", getattr(target, "group_name", "")),
("Ucet", getattr(target, "account_id", "")),
]
else:
values = [
("Recepcia", getattr(target, "reception_name", "")),
("Izba", getattr(target, "room_code", "") or getattr(target, "room_id", "")),
("Host", getattr(target, "guest_name", "")),
("Ucet", getattr(target, "account_id", "")),
]
return _receipt_object_lines("Hotelovy ucet", values, width)
def _receipt_note_extra_lines(note: str, width: int) -> list[str]:
lines = []
st_fields = _receipt_note_fields(
note,
"dotaz_st:",
["akcia", "hjmeno", "adresa1", "adresa2", "adresa3", "ico", "dic", "icdph", "schvalil"],
)
if st_fields:
labels = {
"akcia": "Akcia",
"hjmeno": "Firma",
"adresa1": "Adresa",
"adresa2": "",
"adresa3": "",
"ico": "ICO",
"dic": "DIC",
"icdph": "IC DPH",
"schvalil": "Schvalil",
}
lines.extend(_receipt_object_lines("Uverovy zaznam", [(labels.get(k, k), v) for k, v in st_fields], width))
ho_fields = _receipt_note_fields(
note,
"dotaz_ho:",
["izba", "host", "skupina", "recepcia"],
)
if ho_fields:
labels = {
"izba": "Izba",
"host": "Host",
"skupina": "Skupina",
"recepcia": "Recepcia",
}
lines.extend(_receipt_object_lines("Hotelovy ucet", [(labels.get(k, k), v) for k, v in ho_fields], width))
return lines
def _receipt_title(kind: str, title: str = "") -> str:
if str(title or "").strip():
return str(title).strip()
kind_l = str(kind or "").strip().lower()
if "storno" in kind_l:
return "STORNO UCTU"
if "copy" in kind_l or "kop" in kind_l:
return "KOPIA UCTU"
if "prebill" in kind_l or "preducet" in kind_l:
return "PREDUCET"
if "payment_change" in kind_l or "zmena" in kind_l:
return "ZMENA PLATBY"
return "UCET"
def _build_receipt_template_context(job: data.PrintJob, route: dict | None = None) -> dict:
payload = job.payload or {}
route = route or payload.get("route") or {}
ucet = data.Ucet.model_validate(payload.get("ucet") or {})
kind = str(payload.get("kind") or job.job_type or "").lower()
width = _printer_width(route)
separator = "-" * width
currency = ""
for pol in getattr(ucet, "poloz", []) or []:
if getattr(pol, "mena", ""):
currency = str(pol.mena)
break
if not currency:
for payment in getattr(ucet, "platby", []) or []:
if getattr(payment, "unit", ""):
currency = str(payment.unit)
break
total = float(getattr(ucet, "total_base_currency", 0) or 0)
if not total:
total = float(ucet.total_czk())
discount = float(ucet.total_slev() or 0)
title = _receipt_title(kind, str(payload.get("title") or ""))
header_lines = _center_print_lines([str(line) for line in (payload.get("headers") or [])], width)
header_lines.extend([
_fit_print_text(title, width).center(width),
])
if getattr(ucet, "ucislo", None):
header_lines.append(_two_col_text("Ucet", ucet.ucislo, width))
if getattr(ucet, "stul", None):
table_text = str(payload.get("table_name") or getattr(ucet, "stul", "") or "")
header_lines.append(_two_col_text("Stol", table_text, width))
if payload.get("pos_name"):
header_lines.append(_two_col_text("Kasa", payload.get("pos_name"), width))
if getattr(ucet, "autor", ""):
header_lines.append(_two_col_text("Obsluha", getattr(ucet, "autor", ""), width))
closed_at = str(getattr(ucet, "closed_at", "") or getattr(ucet, "datetime", "") or job.created_at or "")
if closed_at:
header_lines.append(_two_col_text("Datum", closed_at, width))
item_lines = _receipt_item_table_lines(ucet, width, currency)
summary_lines = []
if discount:
subtotal = total + discount
summary_lines.append(_two_col_text("Medzisucet", _money_text(subtotal, currency), width))
if discount > 0:
summary_lines.append(_two_col_text("Zlava", _money_text(-discount, currency), width))
else:
summary_lines.append(_two_col_text("Prirazka", _money_text(abs(discount), currency), width))
summary_lines.append(_two_col_text("Spolu", _money_text(total, currency), width))
payment_lines = []
tip_total = 0.0
payments = list(getattr(ucet, "platby", []) or [])
uver_printed = False
for idx, payment in enumerate(payments):
unit = str(getattr(payment, "unit", "") or currency)
pay_name = getattr(payment, "nazev", "") or getattr(payment, "code", "")
if unit != currency and _float_value(getattr(payment, "rate", 1), 1.0) != 1:
left = f"{pay_name} {_money_text(getattr(payment, 'suma', 0), unit)}"
right = _money_text(getattr(payment, "suma_czk", 0), currency)
else:
left = pay_name
right = _money_text(getattr(payment, "suma", 0), unit)
payment_lines.append(_two_col_text(left, right, width))
tip = _float_value(getattr(payment, "tip", 0), 0.0)
if tip:
tip_total += tip
payment_lines.append(_two_col_text(" TIP", _money_text(tip, unit), width))
note = str(getattr(payment, "poznamka", "") or "").strip()
target = getattr(payment, "hotel_charge", None)
if not target and len(payments) == 1:
target = getattr(ucet, "hotel_charge", None)
if target:
payment_lines.extend(_receipt_hotel_lines(target, width))
elif note and "dotaz_ho:" in note:
payment_lines.extend(_receipt_note_extra_lines(note, width))
if not uver_printed and getattr(ucet, "uver", None):
payment_lines.extend(_receipt_uver_lines(getattr(ucet, "uver", None), width))
uver_printed = True
elif not uver_printed and note and "dotaz_st:" in note:
payment_lines.extend(_receipt_note_extra_lines(note, width))
uver_printed = True
if note and "dotaz_st:" not in note and "dotaz_ho:" not in note:
payment_lines.append(_fit_print_text(f" {note}", width))
rounding_total = round(_float_value(getattr(ucet, "round50", 0), 0.0), 2)
if rounding_total:
payment_lines.append(_two_col_text("Zaokruhlenie", _money_text(rounding_total, currency), width))
if tip_total and not payment_lines:
payment_lines.append(_two_col_text("TIP", _money_text(tip_total, currency), width))
tax_lines = _receipt_tax_table_lines(ucet, width, currency) if getattr(ucet, "dane", None) else []
return {
"printer": SimpleNamespace(
max_characters=width,
reset=_decode_printer_command(route.get("p_reset")),
crlf=_decode_printer_command(route.get("p_crlf")) or "\n",
fullcut=_decode_printer_command(route.get("p_fullcut")),
),
"kind": kind,
"title": title,
"separator": separator,
"header_lines": header_lines,
"item_lines": item_lines,
"summary_lines": summary_lines,
"payment_lines": payment_lines,
"tax_lines": tax_lines,
"footer_lines": _center_print_lines([str(line) for line in (payload.get("footers") or [])], width),
"ucet": ucet,
"route": route,
"currency": currency,
}
def _kitchen_storno_flags(kind: str, ucet: data.Ucet) -> tuple[bool, bool]:
kind_l = str(kind or "").strip().lower()
is_storno = "storno" in kind_l or bool(getattr(ucet, "is_storno", None))
is_bill_cancel = (
"ucet" in kind_l
or "bill" in kind_l
or str(getattr(ucet, "origin", "") or "").lower() in {"storno", "storno_ucet"}
)
return is_storno, is_bill_cancel
def _build_kitchen_template_context(job: data.PrintJob, route: dict | None = None) -> dict:
payload = job.payload or {}
route = route or payload.get("route") or {}
ucet = data.Ucet.model_validate(payload.get("ucet") or {})
kind = str(payload.get("kind") or job.job_type or "").lower()
is_storno, is_bill_cancel = _kitchen_storno_flags(kind, ucet)
width = _printer_width(route)
guest_map = _guest_course_map(getattr(ucet, "guests", []) or [], 1)
course_map = _guest_course_map(getattr(ucet, "courses", []) or [], 1)
guest_name_map = _guest_course_name_map(getattr(ucet, "guests", []) or [], 1)
course_name_map = _guest_course_name_map(getattr(ucet, "courses", []) or [], 1)
courses_count = max(_int_value(getattr(ucet, "course_count", 1), 1), len(course_map) or 1)
guests_count = max(_int_value(getattr(ucet, "guest_count", 1), 1), len(guest_map) or 1)
items = []
last_course_key = None
last_guest_key = None
for pol in ucet.poloz:
qty = _format_print_quantity(pol, absolute=is_storno)
name = str(getattr(pol, "nazev", "") or "")
order_lines = [str(line) for line in (getattr(pol, "zpravy", []) or []) if str(line).strip()]
course_id = str(getattr(pol, "course_id", "") or "")
guest_id = str(getattr(pol, "guest_id", "") or "")
course_no = course_map.get(course_id, 0)
guest_no = guest_map.get(guest_id, 1)
course_label = course_name_map.get(course_id, str(course_no if course_no else ""))
guest_label = guest_name_map.get(guest_id, str(guest_no if guest_no else ""))
show_course = bool(course_label) and (courses_count > 1 or course_no > 0)
show_guest = bool(guest_label) and (guests_count > 1 or guest_no > 1)
print_course_header = bool(show_course and course_id != last_course_key)
if print_course_header:
last_course_key = course_id
last_guest_key = None
print_guest_header = bool(show_guest and guest_id != last_guest_key)
if print_guest_header:
last_guest_key = guest_id
items.append(SimpleNamespace(
description=f"{qty:>5} {name}",
order_text="\n".join(order_lines),
order_lines=order_lines,
course=course_no,
course_name=course_label,
guest=guest_no,
guest_name=guest_label,
print_course_header=print_course_header,
print_guest_header=print_guest_header,
))
return {
"printer": SimpleNamespace(
max_characters=width,
reset=_decode_printer_command(route.get("p_reset")),
bigfont_on=_decode_printer_command(route.get("p_wideon")),
bigfont_off=_decode_printer_command(route.get("p_wideoff")),
crlf=_decode_printer_command(route.get("p_crlf")) or "\n",
fullcut=_decode_printer_command(route.get("p_fullcut")),
),
"pager": str(payload.get("pager_no") or ""),
"order_note": "",
"table_name": str(payload.get("table_name") or getattr(ucet, "stul", "") or ""),
"cashier_name": str(payload.get("pos_name") or getattr(ucet, "id_kas", "") or ""),
"created_at": _created_at_for_print(job),
"printer_name": str(route.get("prn_name") or ""),
"is_storno": is_storno,
"is_bill_cancel": is_bill_cancel,
"items": items,
"courses_count": courses_count,
"guests_count": guests_count,
"user": str(getattr(ucet, "autor", "") or ""),
"locator_number": "",
"bon_count": _int_value(payload.get("bon_no"), 0),
}
def _render_kitchen_print_jinja(job: data.PrintJob, route: dict | None = None) -> tuple[str, str] | None:
try:
from jinja2 import Environment
except Exception as exc:
logger.warning(f"Jinja2 is not available, kitchen print will use internal fallback: {exc}")
return None
env = Environment(autoescape=False, trim_blocks=True, lstrip_blocks=True)
env.filters["box"] = _box_filter
env.filters["repeat"] = _repeat_filter
template_text, template_source = _resolve_kitchen_bon_template(route)
template = env.from_string(template_text)
rendered = template.render(**_build_kitchen_template_context(job, route=route)).strip("\n") + "\n"
return rendered, template_source
def _render_kitchen_print_text(job: data.PrintJob, width: int = 40, route: dict | None = None) -> str:
text, _ = _render_kitchen_print_text_with_meta(job, width=width, route=route)
return text
def _render_kitchen_print_text_with_meta(
job: data.PrintJob,
width: int = 40,
route: dict | None = None,
) -> tuple[str, dict]:
payload = job.payload or {}
rendered = _render_kitchen_print_jinja(job, route=route)
if rendered is not None:
rendered_text, template_source = rendered
return rendered_text, {
"renderer": "jinja2",
"template_bon": template_source,
}
ucet = data.Ucet.model_validate(payload.get("ucet") or {})
kind = str(payload.get("kind") or job.job_type or "").lower()
is_storno, is_bill_cancel = _kitchen_storno_flags(kind, ucet)
width = _printer_width(route or payload.get("route") or {}, width)
title = "STORNO STAREHO UCTU" if is_bill_cancel else ("STORNO - KUCHYNA" if is_storno else "KUCHYNA")
lines = [
"=" * width,
title.center(width),
]
bon_no = _int_value(payload.get("bon_no"), 0)
bon_date = str(payload.get("bon_date") or "")
if bon_no:
bon_label = f"BON: {bon_no}"
if bon_date:
bon_label = f"{bon_label} / {bon_date}"
lines.append(bon_label[:width])
lines.append("-" * width)
if getattr(ucet, "stul", None):
lines.append(f"STOL: {ucet.stul}"[:width])
if payload.get("room_name"):
lines.append(f"MIESTNOST: {payload.get('room_name')}"[:width])
if payload.get("pos_name"):
lines.append(f"KASA: {payload.get('pos_name')}"[:width])
if getattr(ucet, "autor", ""):
lines.append(f"CISNIK: {ucet.autor}"[:width])
if getattr(ucet, "ucislo", None):
lines.append(f"UCET: {ucet.ucislo}"[:width])
lines.append("-" * width)
for pol in ucet.poloz:
qty = _format_print_quantity(pol, absolute=is_storno)
name = getattr(pol, "nazev", "") or ""
line = f"{qty:>5} {name}"
lines.append(line[:width])
if getattr(pol, "chod", ""):
lines.append(f" [CHOD {pol.chod}]"[:width])
for msg in getattr(pol, "zpravy", []) or []:
lines.append(f" - {msg}"[:width])
lines.append("-" * width)
lines.append(datetime.now().strftime("%d.%m.%Y %H:%M:%S").center(width))
lines.append("=" * width)
return "\n".join(lines) + "\n", {
"renderer": "internal_fallback",
"template_bon": "internal_fallback",
}
def _render_receipt_print_jinja(job: data.PrintJob, route: dict | None = None) -> tuple[str, str] | None:
try:
from jinja2 import Environment
except Exception as exc:
logger.warning(f"Jinja2 is not available, receipt print will use internal fallback: {exc}")
return None
env = Environment(autoescape=False, trim_blocks=True, lstrip_blocks=True)
env.filters["repeat"] = _repeat_filter
env.filters["money"] = _money_text
template_text, template_source = _resolve_receipt_template(route)
template = env.from_string(template_text)
rendered = template.render(**_build_receipt_template_context(job, route=route)).strip("\n") + "\n"
return rendered, template_source
def _render_receipt_print_text_with_meta(
job: data.PrintJob,
width: int = 40,
route: dict | None = None,
) -> tuple[str, dict]:
payload = job.payload or {}
rendered = _render_receipt_print_jinja(job, route=route)
if rendered is not None:
rendered_text, template_source = rendered
return rendered_text, {
"renderer": "jinja2",
"template_ucet": template_source,
}
context = _build_receipt_template_context(job, route=route or payload.get("route") or {})
lines = []
lines.extend(context["header_lines"])
lines.append(context["separator"])
lines.extend(context["item_lines"])
lines.append(context["separator"])
lines.extend(context["summary_lines"])
if context["payment_lines"]:
lines.append(context["separator"])
lines.extend(context["payment_lines"])
if context["tax_lines"]:
lines.append(context["separator"])
lines.extend(context["tax_lines"])
if context["footer_lines"]:
lines.append(context["separator"])
lines.extend(context["footer_lines"])
return "\n".join(lines) + "\n", {
"renderer": "internal_fallback",
"template_ucet": "internal_fallback",
}
def render_print_job_text(job: data.PrintJob, width: int = 40, route: dict | None = None) -> str:
text, _ = render_print_job_text_with_meta(job, width=width, route=route)
return text
def render_print_job_text_with_meta(
job: data.PrintJob,
width: int = 40,
route: dict | None = None,
) -> tuple[str, dict]:
payload = job.payload or {}
if isinstance(payload.get("text"), str):
text = payload.get("text") or ""
return (text if text.endswith("\n") else text + "\n"), {
"renderer": "payload_text",
}
if str(job.document_type or "").startswith("receipt_"):
return _render_receipt_print_text_with_meta(job, width=width, route=route)
if str(job.document_type or "").startswith("kitchen_") or "ucet" in payload:
return _render_kitchen_print_text_with_meta(job, width=width, route=route)
raise RuntimeError(f"Print job {job.id} has no printable payload")
def _print_charset(route: dict) -> str:
charset = str(route.get("convert_charset") or "").strip()
if not charset or charset.lower() == "auto":
return "utf-8"
return charset
def _render_escpos_bytes(text: str, route: dict) -> bytes:
body = text.encode(_print_charset(route), errors="replace")
has_control = any(ch in text for ch in ("\x1b", "\x1d", "\x1c", "\x10", "\x14"))
if has_control:
return body
return b"\x1b@" + body + b"\n\x1dV\x00"
def _fiskal_base_url(route: dict) -> str:
host = _require_route_value(route, "ip")
if host.startswith("http://") or host.startswith("https://"):
base = host.rstrip("/")
if route.get("port") and ":" not in base.rsplit("/", 1)[-1]:
base = f"{base}:{_route_int(route, 'port', 80)}"
return base
return f"http://{host}:{_route_int(route, 'port', 80)}"
def _fiscal_probe_get(base_url: str, endpoint: str, timeout: float) -> dict:
url = f"{base_url}{endpoint}"
response = requests.get(url, timeout=timeout, verify=False)
response.raise_for_status()
try:
loaded = response.json()
except Exception:
loaded = {"raw": response.text}
return loaded if isinstance(loaded, dict) else {"return": loaded}
def _fiscal_probe_message(details: dict, error: str = "") -> tuple[str, str, bool]:
if error:
return "error", error, False
fiskal_type = details.get("fiskal_type") or {}
failed_bills = details.get("failed_bills") or {}
warnings = []
if details.get("failed_bills_error"):
warnings.append(f"failed_bills: {details.get('failed_bills_error')}")
for name, payload in (("fiskal_type", fiskal_type), ("failed_bills", failed_bills)):
code = payload.get("code") if isinstance(payload, dict) else None
if code not in (None, 0, "0"):
warnings.append(f"{name}: {payload.get('code_text') or payload.get('message') or code}")
failed_return = failed_bills.get("return") if isinstance(failed_bills, dict) else None
if failed_return:
warnings.append(f"Neodoslané doklady: {failed_return}")
if warnings:
return "warning", "; ".join(str(item) for item in warnings), True
version = ""
ret = fiskal_type.get("return") if isinstance(fiskal_type, dict) else None
if isinstance(ret, dict):
version = str(ret.get("version") or ret.get("verzia") or ret.get("fiskal_type") or "")
elif ret not in (None, ""):
version = str(ret)
message = f"Fiskálny server odpovedal{(': ' + version) if version else ''}."
return "online", message, True
@app.get("/print/fiscal/status/", response_model=data.PrinterStatusOut)
def get_fiscal_printer_status(
id_kas: str,
printer_no: str,
timeout: float = Query(default=10.0, ge=1.0, le=60.0),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return get_fiscal_printer_status_db(prefix, id_kas, printer_no, timeout=timeout)
def get_fiscal_printer_status_db(
prefix: str,
id_kas: str,
printer_no: str,
timeout: float = 10.0,
) -> data.PrinterStatusOut:
route = load_current_printer_route(prefix, printer_no)
if not route:
raise HTTPException(404, f"Tlačiareň {printer_no} nebola nájdená.")
if str(route.get("route_type") or "").lower() != "fiskal":
raise HTTPException(422, f"Tlačiareň {printer_no} nie je fiskálna.")
details: dict = {
"route": {
"ip": route.get("ip", ""),
"port": route.get("port", ""),
"prn_no": route.get("prn_no", ""),
"prn_name": route.get("prn_name", ""),
}
}
error = ""
try:
base_url = _fiskal_base_url(route)
details["fiskal_type"] = _fiscal_probe_get(base_url, "/params/fiskal_type", timeout)
try:
details["failed_bills"] = _fiscal_probe_get(base_url, "/params/failed_bills", timeout)
except Exception as exc:
details["failed_bills_error"] = str(exc)
except Exception as exc:
error = str(exc)
logger.exception("Fiscal printer status failed: prefix=%s id_kas=%s printer=%s", prefix, id_kas, printer_no)
status_text, message, online = _fiscal_probe_message(details, error=error)
return upsert_printer_status_db(
prefix,
data.PrinterStatusIn(
id_kas=id_kas,
prn_no=str(printer_no or "").strip(),
agent_id="server",
online=online,
status=status_text,
printer_type=str(route.get("printer_type") or "fiskal"),
cmd32_on=str(route.get("cmd32_on") or "FISKAL"),
message=message,
details=details,
),
)
def _text_to_fiskal_paragon(text: str) -> dict:
return {100 + idx + 1: line for idx, line in enumerate(text.splitlines())}
def _send_fiskal_print_job(job: data.PrintJob, route: dict, text: str, timeout: float) -> dict:
payload = {
"client_id": "",
"platby": None,
"text": _text_to_fiskal_paragon(text),
"typ_poloziek": "TEXT",
"ucet": None,
}
response = requests.post(
f"{_fiskal_base_url(route)}/paragon/send",
json=payload,
timeout=timeout,
verify=False,
)
response.raise_for_status()
try:
response_data = response.json()
except Exception:
response_data = {"raw": response.text}
if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None):
raise RuntimeError(str(response_data.get("code_text") or response_data))
return {
"route_type": "fiskal",
"response": response_data,
}
def _send_raw_print_job(job: data.PrintJob, route: dict, text: str, timeout: float) -> dict:
host = _require_route_value(route, "ip")
port = _route_int(route, "port", 9100)
content = _render_escpos_bytes(text, route)
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.sendall(content)
return {
"route_type": "raw",
"bytes": len(content),
}
def _ipp_attr(value_tag: int, name: str, value: str) -> bytes:
name_b = name.encode("utf-8")
value_b = value.encode("utf-8")
return (
bytes([value_tag])
+ len(name_b).to_bytes(2, "big")
+ name_b
+ len(value_b).to_bytes(2, "big")
+ value_b
)
def _send_cups_print_job(job: data.PrintJob, route: dict, text: str, timeout: float) -> dict:
host = _require_route_value(route, "ip")
port = _route_int(route, "port", 631)
queue = _require_route_value(route, "cupsname")
if port in {9100, 9101}:
raise RuntimeError(
"Tlaciaren je nastavena ako CUPS, ale ma port "
f"{port}. Port 9100/9101 je RAW port tlaciarne. "
"Pre RAW tlac nech cmd32_on nie je CUPS, alebo pri CUPS nastav IP/port CUPS servera "
"(typicky port 631) a platny cupsname."
)
printer_uri = f"ipp://{host}:{port}/printers/{queue}"
document = _render_escpos_bytes(text, route)
request_id = max(1, int(job.id or 1))
ipp_body = (
b"\x02\x00"
+ (0x0002).to_bytes(2, "big")
+ request_id.to_bytes(4, "big", signed=False)
+ b"\x01"
+ _ipp_attr(0x47, "attributes-charset", "utf-8")
+ _ipp_attr(0x48, "attributes-natural-language", "sk")
+ _ipp_attr(0x45, "printer-uri", printer_uri)
+ _ipp_attr(0x42, "requesting-user-name", "pokladna")
+ _ipp_attr(0x42, "job-name", f"pokladna-{job.id}")
+ _ipp_attr(0x49, "document-format", "application/octet-stream")
+ b"\x03"
+ document
)
response = requests.post(
f"http://{host}:{port}/printers/{queue}",
data=ipp_body,
headers={"Content-Type": "application/ipp"},
timeout=timeout,
)
response.raise_for_status()
ipp_status = None
if len(response.content) >= 4:
ipp_status = int.from_bytes(response.content[2:4], "big")
if ipp_status >= 0x0300:
raise RuntimeError(f"CUPS IPP error 0x{ipp_status:04x}")
return {
"route_type": "cups",
"bytes": len(document),
"ipp_status": ipp_status,
}
def process_print_job(
job: data.PrintJob,
timeout: float = 10.0,
current_route: dict | None = None,
) -> dict:
payload = job.payload or {}
route = current_route or payload.get("route") or {}
if not isinstance(route, dict) or not route:
raise RuntimeError(f"Print job {job.id} has no printer route")
text, render_meta = render_print_job_text_with_meta(job, route=route)
route_type = str(route.get("route_type") or "raw").strip().lower()
if route_type == "fiskal":
result = _send_fiskal_print_job(job, route, text, timeout)
elif route_type == "cups":
result = _send_cups_print_job(job, route, text, timeout)
else:
result = _send_raw_print_job(job, route, text, timeout)
result.update(render_meta)
result["route"] = route
result["route_source"] = "current_prndef" if current_route else "job_payload"
result["charset"] = _print_charset(route)
return result
def ensure_kitchen_print_job_bon_number(prefix: str, job: data.PrintJob) -> data.PrintJob:
payload = dict(job.payload or {})
if not str(job.document_type or "").startswith("kitchen_"):
return job
changed = False
if not _int_value(payload.get("bon_no"), 0) and str(job.printer_no or "").strip():
bon_no, bon_date = next_print_bon_number_db(prefix, job.printer_no)
payload["bon_no"] = bon_no
payload["bon_date"] = bon_date
logger.info(
"Assigned missing bon number to existing print job: job=%s printer=%s bon=%s date=%s",
job.id,
job.printer_no,
bon_no,
bon_date,
)
changed = True
if "pager_no" not in payload:
payload["pager_no"] = ""
changed = True
if not str(payload.get("table_name") or "").strip():
try:
ucet = data.Ucet.model_validate(payload.get("ucet") or {})
table_name = resolve_table_name_from_map(prefix, job.id_kas, getattr(ucet, "stul", ""))
if table_name:
payload["table_name"] = table_name
changed = True
except Exception:
pass
if not changed:
return job
return update_print_job_payload_db(prefix, job.id, payload)
@app.post("/print/jobs/process-local/", response_model=list[data.PrintJob])
def process_local_print_jobs(
req: data.PrintJobClaimRequest,
timeout: float = Query(default=10.0, ge=1.0, le=120.0),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return process_local_print_jobs_db(prefix, req, timeout=timeout)
def process_local_print_jobs_db(
prefix: str,
req: data.PrintJobClaimRequest,
timeout: float = 10.0,
) -> list[data.PrintJob]:
claimed = claim_print_jobs_db(prefix, req)
processed: list[data.PrintJob] = []
for job in claimed:
try:
update_print_job_status_db(
prefix,
job.id,
data.PrintJobStatusUpdate(status="printing"),
)
job = ensure_kitchen_print_job_bon_number(prefix, job)
current_route = load_current_printer_route(prefix, job.printer_no)
if not current_route:
logger.warning(
"Print job %s printer %s is not in current prndef; using job payload route",
job.id,
job.printer_no,
)
result = process_print_job(
job,
timeout=timeout,
current_route=current_route or None,
)
processed.append(
update_print_job_status_db(
prefix,
job.id,
data.PrintJobStatusUpdate(status="printed", result=result),
)
)
except Exception as exc:
message = f"Print job {job.id} failed: {exc}"
if _env_truthy("POKLADNA_PRINT_DEBUG", default=False):
logger.exception(message)
else:
logger.warning(message)
next_status = "failed_final" if job.attempts >= job.max_attempts else "retry_pending"
processed.append(
update_print_job_status_db(
prefix,
job.id,
data.PrintJobStatusUpdate(status=next_status, error=str(exc)),
)
)
return processed
def _env_truthy(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "ano"}
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except Exception:
return default
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except Exception:
return default
def _env_csv(name: str) -> list[str]:
raw = os.getenv(name, "")
return [item.strip() for item in raw.split(",") if item.strip()]
def _print_worker_prefixes() -> list[str]:
configured = _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES")
if configured:
return configured
with get_db() as conn:
cur = conn.cursor()
cur.execute("SELECT id FROM zakazky WHERE heslo IS NOT NULL AND heslo <> '' ORDER BY id")
return [f"{int(row[0]):05d}" for row in cur.fetchall()]
def _active_print_job_cashiers(prefix: str, printers: list[str] | None = None) -> list[str]:
table = f"{prefix}_print_jobs"
placeholders = ",".join("?" for _ in PRINT_JOB_ACTIVE_STATUSES)
params: list = [*PRINT_JOB_ACTIVE_STATUSES]
sql = f"""
SELECT DISTINCT id_kas
FROM "{table}"
WHERE status IN ({placeholders})
"""
if printers:
prn_placeholders = ",".join("?" for _ in printers)
sql += f" AND (printer_no='' OR printer_no IN ({prn_placeholders}))"
params.extend(printers)
sql += " ORDER BY id_kas"
try:
with get_db() as conn:
cur = conn.cursor()
cur.execute(sql, tuple(params))
return [row[0] for row in cur.fetchall() if row[0]]
except sqlite3.OperationalError as exc:
logger.warning(f"Print worker cannot read queue for prefix {prefix}: {exc}")
return []
def process_local_print_worker_cycle(
*,
prefixes: list[str] | None = None,
printers: list[str] | None = None,
agent_id: str = LOCAL_PRINT_AGENT_ID,
limit: int = 10,
timeout: float = 10.0,
) -> list[data.PrintJob]:
processed: list[data.PrintJob] = []
active_prefixes = prefixes if prefixes is not None else _print_worker_prefixes()
for prefix in active_prefixes:
batch = process_local_print_jobs_db(
prefix,
data.PrintJobClaimRequest(
agent_id=agent_id,
printers=printers or [],
limit=limit,
),
timeout=timeout,
)
processed.extend(batch)
return processed
def _local_print_worker_loop() -> None:
interval = max(0.2, _env_float("POKLADNA_LOCAL_PRINT_INTERVAL", 2.0))
limit = max(1, min(_env_int("POKLADNA_LOCAL_PRINT_LIMIT", 10), 100))
timeout = max(1.0, _env_float("POKLADNA_LOCAL_PRINT_TIMEOUT", 10.0))
printers = _env_csv("POKLADNA_LOCAL_PRINT_PRINTERS")
prefixes = _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES") or None
agent_id = os.getenv("POKLADNA_LOCAL_PRINT_AGENT_ID", LOCAL_PRINT_AGENT_ID)
logger.info(
"Local print worker started: agent=%s interval=%s limit=%s timeout=%s printers=%s prefixes=%s",
agent_id,
interval,
limit,
timeout,
printers or "*",
prefixes or "*",
)
_local_print_worker_state.update({
"started_at": datetime.now().isoformat(sep=" ", timespec="seconds"),
"stopped_at": "",
"last_error": "",
"printers": printers,
"prefixes": prefixes or [],
})
while not _local_print_worker_stop.is_set():
try:
processed = process_local_print_worker_cycle(
prefixes=prefixes,
printers=printers,
agent_id=agent_id,
limit=limit,
timeout=timeout,
)
_local_print_worker_state.update({
"last_cycle_at": datetime.now().isoformat(sep=" ", timespec="seconds"),
"last_processed_count": len(processed),
"last_processed_ids": [job.id for job in processed],
"last_error": "",
})
if processed:
logger.info("Local print worker processed %s job(s)", len(processed))
except Exception as exc:
_local_print_worker_state.update({
"last_cycle_at": datetime.now().isoformat(sep=" ", timespec="seconds"),
"last_error": str(exc),
})
logger.exception("Local print worker cycle failed")
_local_print_worker_stop.wait(interval)
_local_print_worker_state.update({
"stopped_at": datetime.now().isoformat(sep=" ", timespec="seconds"),
})
logger.info("Local print worker stopped")
def start_local_print_worker_if_enabled() -> None:
global _local_print_worker_thread
if not _env_truthy(LOCAL_PRINT_WORKER_ENV, default=False):
logger.info(
"Local print worker disabled. Set %s=1 to process print jobs on this server.",
LOCAL_PRINT_WORKER_ENV,
)
return
if _local_print_worker_thread and _local_print_worker_thread.is_alive():
return
_local_print_worker_stop.clear()
_local_print_worker_thread = threading.Thread(
target=_local_print_worker_loop,
name="pokladna-local-print-worker",
daemon=True,
)
_local_print_worker_thread.start()
def stop_local_print_worker() -> None:
if _local_print_worker_thread and _local_print_worker_thread.is_alive():
_local_print_worker_stop.set()
_local_print_worker_thread.join(timeout=5)
def print_worker_diagnostics_db(
prefix: str,
id_kas: str | None = None,
limit: int = 50,
) -> dict:
table = f"{prefix}_print_jobs"
where = []
params: list = []
if id_kas:
where.append("id_kas=?")
params.append(id_kas)
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
status_counts: dict[str, int] = {}
active_jobs: list[dict] = []
try:
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT status, COUNT(*)
FROM "{table}"
{where_sql}
GROUP BY status
ORDER BY status
""",
tuple(params),
)
status_counts = {row[0] or "": int(row[1]) for row in cur.fetchall()}
current_prndef_map = _load_prndef_map_cur(cur, prefix)
visible_statuses = tuple(dict.fromkeys((
*PRINT_JOB_ACTIVE_STATUSES,
"claimed",
"printing",
"failed",
"failed_final",
)))
active_placeholders = ",".join("?" for _ in visible_statuses)
active_params = [*params, *visible_statuses, max(1, min(limit, 200))]
active_where = [*where, f"status IN ({active_placeholders})"]
cur.execute(
f"""
SELECT {_print_job_select_columns()}
FROM "{table}"
WHERE {" AND ".join(active_where)}
ORDER BY priority, id
LIMIT ?
""",
tuple(active_params),
)
for row in cur.fetchall():
job = _print_job_from_row(row)
route = (job.payload or {}).get("route") or {}
current_route = _printer_route(current_prndef_map.get(str(job.printer_no or "")))
active_jobs.append({
"id": job.id,
"id_kas": job.id_kas,
"printer_no": job.printer_no,
"status": job.status,
"priority": job.priority,
"attempts": job.attempts,
"max_attempts": job.max_attempts,
"agent_id": job.agent_id or "",
"job_type": job.job_type,
"document_type": job.document_type,
"receipt_no": job.receipt_no,
"bon_no": _int_value((job.payload or {}).get("bon_no"), 0),
"bon_date": str((job.payload or {}).get("bon_date") or ""),
"created_at": job.created_at,
"updated_at": job.updated_at,
"error": job.error,
"route": {
"route_type": route.get("route_type", ""),
"prn_no": route.get("prn_no", ""),
"prn_name": route.get("prn_name", ""),
"cmd32_on": route.get("cmd32_on", ""),
"ip": route.get("ip", ""),
"port": route.get("port", ""),
"cupsname": route.get("cupsname", ""),
},
"current_route": {
"route_type": current_route.get("route_type", ""),
"prn_no": current_route.get("prn_no", ""),
"prn_name": current_route.get("prn_name", ""),
"cmd32_on": current_route.get("cmd32_on", ""),
"ip": current_route.get("ip", ""),
"port": current_route.get("port", ""),
"cupsname": current_route.get("cupsname", ""),
},
"route_will_refresh_from_prndef": bool(current_route),
})
except sqlite3.OperationalError as exc:
return {
"ok": False,
"error": str(exc),
"hint": f"Tabulka {table} pravdepodobne este neexistuje alebo nie je inicializovana.",
}
enabled = _env_truthy(LOCAL_PRINT_WORKER_ENV, default=False)
running = bool(_local_print_worker_thread and _local_print_worker_thread.is_alive())
configured_printers = _env_csv("POKLADNA_LOCAL_PRINT_PRINTERS")
outside_filter = []
if configured_printers:
allowed = set(configured_printers)
outside_filter = [
job for job in active_jobs
if job["printer_no"] and job["printer_no"] not in allowed
]
cups_on_raw_port = []
for job in active_jobs:
effective_route = (
job.get("current_route")
if job.get("route_will_refresh_from_prndef")
else job.get("route")
) or {}
if (
str(effective_route.get("route_type") or "").lower() == "cups"
and str(effective_route.get("port") or "").strip() in {"9100", "9101"}
):
cups_on_raw_port.append(job)
hints = []
if status_counts.get("queued", 0) or status_counts.get("retry_pending", 0):
if not running:
hints.append("Lokalnu tlac ma obsluhovat samostatny proces local_print_agent.py na pocitaci/tablete v sieti zakaznika.")
if outside_filter:
hints.append("Niektore cakajuce joby maju printer_no mimo filtra tlaciarni lokalneho agenta.")
if running and not _local_print_worker_state.get("last_cycle_at"):
hints.append("Worker bezi, ale este nema zaznamenany ziaden cyklus.")
if cups_on_raw_port:
hints.append("Niektore joby maju route_type=CUPS, ale port 9100/9101. To je RAW port; pre CUPS pouzi typicky port 631, alebo zmen cmd32_on na RAW sposob tlace.")
return {
"ok": True,
"prefix": prefix,
"id_kas": id_kas or "",
"worker": {
"enabled": enabled,
"running": running,
"state": dict(_local_print_worker_state),
"configured_printers": configured_printers,
"configured_prefixes": _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES"),
},
"status_counts": status_counts,
"active_jobs": active_jobs,
"unprinted_jobs": active_jobs,
"jobs_outside_printer_filter": outside_filter,
"cups_jobs_on_raw_port": cups_on_raw_port,
"hints": hints,
}
@app.get("/print/worker/status/")
def local_print_worker_status(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
return {
"enabled": _env_truthy(LOCAL_PRINT_WORKER_ENV, default=False),
"running": bool(_local_print_worker_thread and _local_print_worker_thread.is_alive()),
"agent_id": os.getenv("POKLADNA_LOCAL_PRINT_AGENT_ID", LOCAL_PRINT_AGENT_ID),
"interval": _env_float("POKLADNA_LOCAL_PRINT_INTERVAL", 2.0),
"limit": _env_int("POKLADNA_LOCAL_PRINT_LIMIT", 10),
"timeout": _env_float("POKLADNA_LOCAL_PRINT_TIMEOUT", 10.0),
"printers": _env_csv("POKLADNA_LOCAL_PRINT_PRINTERS"),
"prefixes": _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES"),
"state": dict(_local_print_worker_state),
}
@app.get("/print/worker/diagnostics/")
def local_print_worker_diagnostics(
id_kas: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return print_worker_diagnostics_db(prefix, id_kas=id_kas, limit=limit)
def _printer_status_from_values(
id_kas: str,
prn_no: str,
prn_name: str,
id_term: str,
prn_data: dict,
status_row,
queue_size: int,
failed_jobs: int,
) -> data.PrinterStatusOut:
printer_type = str(prn_data.get("printer_type") or "")
cmd32_on = str(prn_data.get("cmd32_on") or "")
if status_row:
return data.PrinterStatusOut(
id_kas=id_kas,
prn_no=prn_no,
prn_name=prn_name,
id_term=id_term,
agent_id=status_row[0] or "",
online=bool(status_row[1]),
status=status_row[2] or "unknown",
printer_type=status_row[3] or printer_type,
cmd32_on=status_row[4] or cmd32_on,
message=status_row[5] or "",
queue_size=queue_size,
failed_jobs=failed_jobs,
details=_json_obj(status_row[8]),
checked_at=status_row[6] or "",
updated_at=status_row[7] or "",
)
return data.PrinterStatusOut(
id_kas=id_kas,
prn_no=prn_no,
prn_name=prn_name,
id_term=id_term,
online=False,
status="unknown",
printer_type=printer_type,
cmd32_on=cmd32_on,
queue_size=queue_size,
failed_jobs=failed_jobs,
)
@app.get("/print/printers/status/", response_model=list[data.PrinterStatusOut])
def list_printer_status(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return list_printer_status_db(prefix, id_kas)
def list_printer_status_db(prefix: str, id_kas: str) -> list[data.PrinterStatusOut]:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table_prndef = f"{prefix}_prndef"
table_kasaucp = f"{prefix}_kasaucp"
table_status = f"{prefix}_printer_status"
table_jobs = f"{prefix}_print_jobs"
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'SELECT printers FROM "{table_kasaucp}" WHERE id_kas=?', (id_kas,))
row = cur.fetchone()
allowed: list[str] = []
if row and row[0]:
try:
allowed = [
item.prn_no
for item in TypeAdapter(list[data.KasaUcpPrinters]).validate_json(row[0])
]
except Exception:
allowed = []
if allowed:
placeholders = ",".join("?" for _ in allowed)
cur.execute(
f'SELECT prn_no, prn_name, id_term, data FROM "{table_prndef}" WHERE prn_no IN ({placeholders}) ORDER BY prn_no',
tuple(allowed),
)
else:
cur.execute(f'SELECT prn_no, prn_name, id_term, data FROM "{table_prndef}" ORDER BY prn_no')
printers = {
row[0]: {
"name": row[1] or "",
"id_term": row[2] or "",
"data": _json_obj(row[3]),
}
for row in cur.fetchall()
}
cur.execute(
f'SELECT prn_no, agent_id, online, status, printer_type, cmd32_on, message, checked_at, updated_at, details FROM "{table_status}" WHERE id_kas=?',
(id_kas,),
)
statuses = {row[0]: row[1:] for row in cur.fetchall()}
for prn_no in statuses:
printers.setdefault(prn_no, {"name": "", "id_term": "", "data": {}})
cur.execute(
f"""
SELECT printer_no, COUNT(*)
FROM "{table_jobs}"
WHERE id_kas=?
AND status IN ('queued', 'retry_pending', 'claimed', 'printing')
GROUP BY printer_no
""",
(id_kas,),
)
queued_counts = {row[0] or "": int(row[1]) for row in cur.fetchall()}
cur.execute(
f"""
SELECT printer_no, COUNT(*)
FROM "{table_jobs}"
WHERE id_kas=?
AND status IN ('failed', 'failed_final')
GROUP BY printer_no
""",
(id_kas,),
)
failed_counts = {row[0] or "": int(row[1]) for row in cur.fetchall()}
out = []
for prn_no, prn in sorted(printers.items()):
out.append(
_printer_status_from_values(
id_kas=id_kas,
prn_no=prn_no,
prn_name=prn.get("name") or "",
id_term=prn.get("id_term") or "",
prn_data=prn.get("data") or {},
status_row=statuses.get(prn_no),
queue_size=queued_counts.get(prn_no, 0),
failed_jobs=failed_counts.get(prn_no, 0),
)
)
return out
@app.post("/print/printers/status/", response_model=data.PrinterStatusOut)
def upsert_printer_status(
status: data.PrinterStatusIn,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return upsert_printer_status_db(prefix, status)
def upsert_printer_status_db(
prefix: str,
status: data.PrinterStatusIn,
) -> data.PrinterStatusOut:
if len(status.id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
if not status.prn_no.strip():
raise HTTPException(422, "Invalid prn_no")
table = f"{prefix}_printer_status"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
INSERT INTO "{table}" (
id_kas, prn_no, agent_id, online, status, printer_type,
cmd32_on, message, queue_size, failed_jobs, details,
checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(id_kas, prn_no) DO UPDATE SET
agent_id=excluded.agent_id,
online=excluded.online,
status=excluded.status,
printer_type=excluded.printer_type,
cmd32_on=excluded.cmd32_on,
message=excluded.message,
queue_size=excluded.queue_size,
failed_jobs=excluded.failed_jobs,
details=excluded.details,
checked_at=CURRENT_TIMESTAMP,
updated_at=CURRENT_TIMESTAMP
""",
(
status.id_kas,
status.prn_no,
status.agent_id or "",
int(bool(status.online)),
status.status or "unknown",
status.printer_type or "",
status.cmd32_on or "",
status.message or "",
int(status.queue_size or 0),
int(status.failed_jobs or 0),
_json_dump_obj(status.details),
),
)
conn.commit()
statuses = list_printer_status_db(prefix, status.id_kas)
for item in statuses:
if item.prn_no == status.prn_no:
return item
return data.PrinterStatusOut(**status.model_dump())
@app.post("/kasaucp/")
def update_kasaucp(
ucp: list[data.KasaUcp],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_kasaucp_db(prefix, ucp)
def update_kasaucp_db(prefix: str, ucp: list[data.KasaUcp]):
table = f"{prefix}_kasaucp"
import json
with get_db() as conn:
cur = conn.cursor()
# 🔹 existujúce
cur.execute(f'SELECT id_kas FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 incoming
incoming_ids = {item.id_kas for item in ucp}
# 🔹 DELETE
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE id_kas=?',
[(x,) for x in to_delete]
)
# 🔹 UPSERT
for item in ucp:
printers_json = json.dumps(
[p.model_dump() for p in item.printers]
)
cur.execute(f"""
INSERT INTO "{table}" (id_kas, printers)
VALUES (?, ?)
ON CONFLICT(id_kas) DO UPDATE SET
printers = excluded.printers
""", (item.id_kas, printers_json))
conn.commit()
return {"ok": True}
@app.post("/kasutxt/")
def update_kasutxt(
utxt: list[data.KasUtxt],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_kasutxt_db(prefix, utxt)
def update_kasutxt_db(prefix: str, utxt: list[data.KasUtxt]):
table = f"{prefix}_kasutxt"
import json
with get_db() as conn:
cur = conn.cursor()
# 🔹 existujúce
cur.execute(f'SELECT id_kas FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 incoming
incoming_ids = {item.id_kas for item in utxt}
# 🔹 DELETE
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE id_kas=?',
[(x,) for x in to_delete]
)
# 🔹 UPSERT
for item in utxt:
riadky_json = json.dumps(item.riadky.model_dump())
cur.execute(f"""
INSERT INTO "{table}" (id_kas, riadky)
VALUES (?, ?)
ON CONFLICT(id_kas) DO UPDATE SET
riadky = excluded.riadky
""", (item.id_kas, riadky_json))
conn.commit()
return {"ok": True}
@app.post("/hladiny/")
def update_hladiny(
ucp: list[data.Hladiny],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_hladiny_db(prefix, ucp)
def update_hladiny_db(prefix: str, ucp: list[data.Hladiny]):
table = f"{prefix}_hladiny"
import json
with get_db() as conn:
cur = conn.cursor()
# 🔹 existujúce
cur.execute(f'SELECT id_kas FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 incoming
incoming_ids = {item.id_kas for item in ucp}
# 🔹 DELETE
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE id_kas=?',
[(x,) for x in to_delete]
)
# 🔹 UPSERT
for item in ucp:
riadky_json = json.dumps(
[p.model_dump() for p in item.riadky]
)
cur.execute(f"""
INSERT INTO "{table}" (id_kas, riadky)
VALUES (?, ?)
ON CONFLICT(id_kas) DO UPDATE SET
riadky = excluded.riadky
""", (item.id_kas, riadky_json))
conn.commit()
return {"ok": True}
@app.post("/recepcia/")
def update_recepcia(
ucp: list[data.Recepcia],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_recepcia_db(prefix, ucp)
def update_recepcia_db(prefix: str, ucp: list[data.Recepcia]):
table = f"{prefix}_recepcia"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
rows = []
for item in ucp:
rows.append(
(
item.id,
item.hotel,
item.hor_ip,
item.hor_port,
item.hor_meno,
item.hor_heslo,
item.api_meno,
item.api_heslo,
item.typ_hotel,
item.hor_prefix
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id, hotel, hor_ip, hor_port, hor_meno, hor_heslo, api_meno, api_heslo, typ_hotel, hor_prefix)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(rows)}
def load_receptions_from_db(prefix: str) -> list[data.Recepcia]:
table = f"{prefix}_recepcia"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT id, hotel, hor_ip, hor_port, hor_meno, hor_heslo,
api_meno, api_heslo, typ_hotel, hor_prefix
FROM "{table}"
ORDER BY hotel COLLATE NOCASE, id
"""
)
rows = cur.fetchall()
return [
data.Recepcia(
id=row[0],
hotel=row[1],
hor_ip=row[2],
hor_port=row[3],
hor_meno=row[4],
hor_heslo=row[5],
api_meno=row[6],
api_heslo=row[7],
typ_hotel=row[8],
hor_prefix=row[9],
)
for row in rows
]
def load_reception_from_db(prefix: str, reception_id: int) -> data.Recepcia:
for reception in load_receptions_from_db(prefix):
if int(reception.id) == int(reception_id):
return reception
raise HTTPException(404, f"Recepcia {reception_id} nebola najdena")
def ensure_postgres_for_reception(prefix: str, id_kas: str, reception: data.Recepcia):
if int(getattr(reception, "typ_hotel", 0) or 0) != 6:
return
status = get_postgres_status_db(prefix, id_kas, test_connection=True)
if not status.available:
raise HTTPException(502, status.message or "PostgreSQL nie je dostupny.")
def get_setup_param_values(prefix: str, id_kas: str) -> dict:
return {
param.var_name: param.var_value
for param in get_effective_setup_parameters(prefix, id_kas)
}
def _strip_value(value) -> str:
return "" if value is None else str(value).strip()
def _int_value(value, default: int = 0) -> int:
try:
return int(float(str(value).strip()))
except Exception:
return default
def _float_value(value, default: float = 0.0) -> float:
try:
return float(str(value).strip().replace(",", "."))
except Exception:
return default
def _bool_value(value, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value or "").strip().lower()
if text in {"1", "t", "true", ".t.", "ano", "áno", "yes"}:
return True
if text in {"0", "f", "false", ".f.", "nie", "ne", "no", ""}:
return False
return default
def _safe_pg_schema(conn: data.PostgresConnection) -> str:
schema = _strip_value(getattr(conn, "schema_", "") or "food600") or "food600"
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", schema):
raise HTTPException(422, "Neplatny nazov PostgreSQL schemy.")
return schema
def _pg_fetchall_dict(pg, sql: str, params: tuple = ()) -> list[dict]:
cur = pg.cursor()
try:
cur.execute(sql, params)
cols = [col[0] for col in (cur.description or [])]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
cur.close()
def _pg_fetchone_value(pg, sql: str, params: tuple = ()):
cur = pg.cursor()
try:
cur.execute(sql, params)
row = cur.fetchone()
return row[0] if row else None
finally:
cur.close()
def limits_cashier_enabled(prefix: str, id_kas: str) -> bool:
params = get_setup_param_values(prefix, id_kas)
return _bool_value(params.get("is_limspra"), False)
def ensure_limits_postgres(prefix: str, id_kas: str) -> data.PostgresConnection:
if not limits_cashier_enabled(prefix, id_kas):
raise HTTPException(404, "Limity nie su povolene pre tuto kasu.")
status = get_postgres_status_db(prefix, id_kas, test_connection=True)
if not status.available:
raise HTTPException(502, status.message or "PostgreSQL nie je dostupny.")
return get_postgres_connection_db(prefix, include_password=True)
def limit_table_id(id_limit, id_den) -> str:
return f"LIM:{_int_value(id_limit, 0)}:{_int_value(id_den, 0)}"
def parse_limit_table_id(stul: str) -> tuple[int, int]:
parts = str(stul or "").strip().split(":")
if len(parts) != 3 or parts[0].upper() != "LIM":
raise HTTPException(422, "Neplatny identifikator limitoveho stola.")
id_limit = _int_value(parts[1], 0)
id_den = _int_value(parts[2], 0)
if not id_limit or not id_den:
raise HTTPException(422, "Neplatny limit alebo den limitu.")
return id_limit, id_den
def limit_lock_key(id_limit: int) -> str:
return f"LIMIT_{int(id_limit or 0)}"
def resolve_limit_lock_key(prefix: str, id_kas: str, id_limit: int) -> str:
# Foodman pouziva: "LIMIT_" + allt(str(id_limit, 10, 0)).
# Teda bez nul a bez hladania podobnych historickych riadkov.
return limit_lock_key(id_limit)
def limit_cashier_allowed(txt_kasy: str, id_kas: str) -> bool:
cashiers = {
part.strip()
for part in str(txt_kasy or "").split(";")
if part.strip()
}
id_text = str(id_kas or "").strip()
accepted = {id_text}
try:
accepted.add(str(int(id_text)))
accepted.add(f"{int(id_text):02d}")
except Exception:
pass
normalized_cashiers = set(cashiers)
for cashier in list(cashiers):
try:
normalized_cashiers.add(str(int(cashier)))
normalized_cashiers.add(f"{int(cashier):02d}")
except Exception:
pass
return bool(accepted & normalized_cashiers)
def limit_display_name(menolimit, datum) -> str:
name = _strip_value(menolimit)
date = _strip_value(datum)
return f"{name}\n{date}" if date else name
def limit_display_name_from_rows(rows: list[dict], *, multiline: bool = True) -> str:
name = ""
date = ""
for row in rows or []:
if not name:
name = _strip_value(row.get("menolimit"))
if not date:
date = _strip_value(row.get("datum"))
if name and date:
break
text = limit_display_name(name, date)
if not multiline:
text = " ".join(part for part in text.splitlines() if part.strip())
return text
def load_limit_tables_from_postgres(prefix: str, id_kas: str) -> list[data.LimitTable]:
if not limits_cashier_enabled(prefix, id_kas):
return []
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
sql = f"""
SELECT
id_limit,
id_den,
menolimit,
datum,
txt_kasy,
COUNT(*) AS row_count,
MIN(poradie) AS poradie
FROM "{schema}"."slimity_zoznam"
GROUP BY id_limit, id_den, menolimit, datum, txt_kasy
ORDER BY menolimit, datum, MIN(poradie)
"""
with postgres_service.connect(conn) as pg:
rows = _pg_fetchall_dict(pg, sql)
result: list[data.LimitTable] = []
skipped = 0
for row in rows:
if not limit_cashier_allowed(row.get("txt_kasy", ""), id_kas):
skipped += 1
continue
id_limit = _int_value(row.get("id_limit"), 0)
id_den = _int_value(row.get("id_den"), 0)
result.append(data.LimitTable(
table_id=limit_table_id(id_limit, id_den),
id_limit=id_limit,
id_den=id_den,
menolimit=_strip_value(row.get("menolimit")),
datum=_strip_value(row.get("datum")),
txt_kasy=_strip_value(row.get("txt_kasy")),
name=limit_display_name(row.get("menolimit"), row.get("datum")),
row_count=_int_value(row.get("row_count"), 0),
))
logger.info(
"Limity loaded: prefix=%s id_kas=%s postgres_rows=%s filtered=%s skipped=%s",
prefix,
id_kas,
len(rows),
len(result),
skipped,
)
return result
def acquire_limit_lock(prefix: str, id_kas: str, id_limit: int, id_den: int, client_id: str, user: str) -> data.LimitLockResult:
table = f"{prefix}_limit_locks"
key = limit_lock_key(id_limit)
now = time.time()
with get_db() as conn:
cur = conn.cursor()
init_limit_locks_schema(prefix, cur)
cur.execute(
f'SELECT client_id, user, locked_at FROM "{table}" WHERE lock_key=?',
(key,),
)
row = cur.fetchone()
if row:
owner_client, owner_user, locked_at = row
if str(owner_client or "") != str(client_id or ""):
pg_lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit)
with _limit_pg_locks_guard:
pg_info = _limit_pg_locks.get(pg_lock_id)
pg_live = bool(pg_info and not _pg_connection_closed(pg_info.get("pg")))
if not pg_live:
logger.warning(
"Limit local lock is stale, overriding key=%s owner=%s",
key,
owner_client,
)
else:
age = int(now - float(locked_at or now))
return data.LimitLockResult(
ok=False,
table_id=limit_table_id(id_limit, id_den),
id_limit=id_limit,
id_den=id_den,
message=f"Limit je otvoreny na terminali {owner_client} ({owner_user}), {age}s.",
)
cur.execute(f"""
INSERT INTO "{table}" (lock_key, id_kas, client_id, user, id_limit, id_den, locked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(lock_key) DO UPDATE SET
id_kas=excluded.id_kas,
client_id=excluded.client_id,
user=excluded.user,
id_limit=excluded.id_limit,
id_den=excluded.id_den,
locked_at=excluded.locked_at
""", (key, id_kas, client_id, user, id_limit, id_den, now))
return data.LimitLockResult(
ok=True,
table_id=limit_table_id(id_limit, id_den),
id_limit=id_limit,
id_den=id_den,
message="",
)
def release_limit_lock(prefix: str, id_limit: int, client_id: str | None = None) -> data.LimitLockResult:
table = f"{prefix}_limit_locks"
key = limit_lock_key(id_limit)
with get_db() as conn:
cur = conn.cursor()
init_limit_locks_schema(prefix, cur)
if client_id:
cur.execute(
f'DELETE FROM "{table}" WHERE lock_key=? AND client_id=?',
(key, client_id),
)
else:
cur.execute(f'DELETE FROM "{table}" WHERE lock_key=?', (key,))
return data.LimitLockResult(ok=True, id_limit=id_limit, message="")
def call_pg_semafor(prefix: str, id_kas: str, fn_name: str, *params) -> str:
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
placeholders = ", ".join(["%s"] * len(params))
sql = f'SELECT "{schema}"."{fn_name}"({placeholders})'
with postgres_service.connect(conn) as pg:
value = _pg_fetchone_value(pg, sql, tuple(params))
return _strip_value(value)
def acquire_pg_limit_marker(prefix: str, id_kas: str, key: str, holder: str) -> tuple[bool, str]:
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
with postgres_service.connect(conn) as pg:
cur = pg.cursor()
try:
cur.execute(f'SELECT kto FROM "{schema}"."semafor" WHERE n_semafor=%s', (key,))
row = cur.fetchone()
current_holder = _strip_value(row[0]) if row else ""
if current_holder and current_holder != holder:
pg.rollback()
return False, current_holder
if row:
cur.execute(
f'UPDATE "{schema}"."semafor" SET kedy=now(), kto=%s WHERE n_semafor=%s',
(holder, key),
)
else:
cur.execute(
f'INSERT INTO "{schema}"."semafor" (n_semafor, kedy, kto) VALUES (%s, now(), %s)',
(key, holder),
)
pg.commit()
return True, ""
except Exception:
pg.rollback()
raise
finally:
cur.close()
def release_pg_limit_marker(prefix: str, id_kas: str, key: str, holder: str | None = None) -> None:
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
with postgres_service.connect(conn) as pg:
cur = pg.cursor()
try:
if holder:
cur.execute(
f'UPDATE "{schema}"."semafor" SET kto=%s, kedy=now() WHERE n_semafor=%s AND kto=%s',
("", key, holder),
)
else:
cur.execute(
f'UPDATE "{schema}"."semafor" SET kto=%s, kedy=now() WHERE n_semafor=%s',
("", key),
)
pg.commit()
except Exception:
pg.rollback()
raise
finally:
cur.close()
def _limit_pg_lock_key(prefix: str, id_kas: str, id_limit: int) -> tuple[str, str, int]:
return (str(prefix), str(id_kas), int(id_limit or 0))
def _pg_connection_closed(pg) -> bool:
closed = getattr(pg, "closed", False)
try:
return bool(int(closed))
except Exception:
return bool(closed)
def acquire_pg_limit_advisory_lock(
prefix: str,
id_kas: str,
id_limit: int,
id_den: int,
client_id: str,
user: str,
) -> tuple[bool, str]:
lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit)
key = resolve_limit_lock_key(prefix, id_kas, id_limit)
holder = f"{client_id}:{user}"[:60]
with _limit_pg_locks_guard:
existing = _limit_pg_locks.get(lock_id)
if existing:
pg = existing.get("pg")
if _pg_connection_closed(pg):
_limit_pg_locks.pop(lock_id, None)
elif str(existing.get("client_id", "")) == str(client_id):
existing["locked_at"] = time.time()
return True, ""
else:
owner = _strip_value(existing.get("holder")) or _strip_value(existing.get("client_id"))
return False, owner
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
pg = None
cur = None
try:
pg = postgres_service.open_connection(conn)
cur = pg.cursor()
cur.execute(f'SELECT "{schema}"."semafor_zapni"(%s, %s)', (key, holder))
row = cur.fetchone()
result = _strip_value(row[0] if row else "")
pg.commit()
logger.info("Limit semafor_zapni key=%s holder=%s result=%s", key, holder, result)
if result != "1":
try:
pg.close()
except Exception:
pass
return False, "iny terminal"
_limit_pg_locks[lock_id] = {
"pg": pg,
"schema": schema,
"key": key,
"holder": holder,
"client_id": client_id,
"user": user,
"id_den": id_den,
"locked_at": time.time(),
}
logger.info("Limit advisory lock acquired key=%s holder=%s", key, holder)
return True, ""
except Exception:
if pg is not None:
try:
pg.close()
except Exception:
pass
raise
finally:
if cur is not None:
try:
cur.close()
except Exception:
pass
def release_pg_limit_advisory_lock(
prefix: str,
id_kas: str,
id_limit: int,
client_id: str | None = None,
) -> bool:
lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit)
with _limit_pg_locks_guard:
info = _limit_pg_locks.get(lock_id)
if not info:
return False
if client_id and str(info.get("client_id", "")) != str(client_id):
return False
info = _limit_pg_locks.pop(lock_id, None)
pg = info.get("pg")
cur = None
try:
if pg is not None and not _pg_connection_closed(pg):
cur = pg.cursor()
cur.execute(
f'SELECT "{info["schema"]}"."semafor_vypni"(%s)',
(info["key"],),
)
pg.commit()
logger.info("Limit advisory lock released key=%s holder=%s", info["key"], info.get("holder", ""))
return True
except Exception as e:
logger.warning(f"Limit advisory unlock failed key={info.get('key')}: {e}")
try:
pg.rollback()
except Exception:
pass
return False
finally:
if cur is not None:
try:
cur.close()
except Exception:
pass
if pg is not None:
try:
pg.close()
except Exception:
pass
return False
def acquire_limit_semafor(prefix: str, id_kas: str, id_limit: int, id_den: int, client_id: str, user: str) -> data.LimitLockResult:
lock = acquire_limit_lock(prefix, id_kas, id_limit, id_den, client_id, user)
if not lock.ok:
return lock
try:
pg_ok, pg_owner = acquire_pg_limit_advisory_lock(prefix, id_kas, id_limit, id_den, client_id, user)
except Exception as e:
release_limit_lock(prefix, id_limit, client_id)
raise
if not pg_ok:
release_limit_lock(prefix, id_limit, client_id)
return data.LimitLockResult(
ok=False,
table_id=limit_table_id(id_limit, id_den),
id_limit=id_limit,
id_den=id_den,
message=f"Limit je zamknuty v PostgreSQL ({pg_owner or 'iny terminal'}).",
)
return lock
def release_limit_semafor(prefix: str, id_kas: str, id_limit: int, client_id: str | None = None) -> data.LimitLockResult:
release_pg_limit_advisory_lock(prefix, id_kas, id_limit, client_id)
return release_limit_lock(prefix, id_limit, client_id)
def build_limit_ucet_from_postgres(prefix: str, id_kas: str, id_limit: int, id_den: int, user: str) -> data.Ucet:
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
with postgres_service.connect(conn) as pg:
rows = _pg_fetchall_dict(pg, f"""
SELECT *
FROM "{schema}"."slimity_zoznam"
WHERE id_limit=%s AND id_den=%s
ORDER BY poradie, id_rov
""", (id_limit, id_den))
if not rows:
raise HTTPException(404, "Limit nebol najdeny.")
if not any(limit_cashier_allowed(row.get("txt_kasy", ""), id_kas) for row in rows):
raise HTTPException(403, "Limit nie je povoleny pre tuto kasu.")
items = _pg_fetchall_dict(pg, f"""
SELECT *
FROM "{schema}"."slimity_polozky"
WHERE id_limit=%s AND id_den=%s
ORDER BY id_rov, c_hlad, poradie, idriadok
""", (id_limit, id_den))
rov_map: dict[int, dict] = {}
courses: list[dict] = []
for row in rows:
id_rov = _int_value(row.get("id_rov"), 0)
if not id_rov or id_rov in rov_map:
continue
course = {
"id": f"rov:{id_rov}",
"name": _strip_value(row.get("chod")) or f"Chod {len(courses) + 1}",
"id_rov": id_rov,
"cenhlad": _strip_value(row.get("cenhlad")),
}
rov_map[id_rov] = course
courses.append(course)
guests_by_id: dict[str, dict] = {}
for row in items:
c_hlad = _int_value(row.get("c_hlad"), 0)
guest_id = f"hlad:{c_hlad}"
if guest_id not in guests_by_id:
guests_by_id[guest_id] = {
"id": guest_id,
"name": _strip_value(row.get("hladina")) or f"Hladina {c_hlad}",
"c_hlad": c_hlad,
}
guests = list(guests_by_id.values()) or [{"id": "hlad:0", "name": "Hladina 0", "c_hlad": 0}]
poloz: list[data.UcPol] = []
menu_parent_lines: dict[str, str] = {}
menu_parent_qty: dict[str, float] = {}
def fmenu_id(row) -> str:
return _strip_value(row.get("id_fmenu") or row.get("id_fstmenu"))
def fmenu_key(value: str, id_rov: int, c_hlad: int) -> str:
text = _strip_value(value).upper()
if len(text) > 1 and text[0] in {"H", "P"}:
suffix = text[1:].lstrip("0") or "0"
return f"lim-menu:{id_rov}:{c_hlad}:{suffix}"
return ""
for row in items:
id_card = _int_value(row.get("c_karty"), 0)
if not id_card:
continue
id_rov = _int_value(row.get("id_rov"), 0)
c_hlad = _int_value(row.get("c_hlad"), 0)
id_fmenu = fmenu_id(row)
key = fmenu_key(id_fmenu, id_rov, c_hlad)
if key and id_fmenu.upper().startswith("H"):
menu_parent_lines[key] = f"lim:{_int_value(row.get('idriadok'), 0) or uuid.uuid4().hex}"
for row in items:
id_card = _int_value(row.get("c_karty"), 0)
if not id_card:
continue
id_rov = _int_value(row.get("id_rov"), 0)
course = rov_map.get(id_rov) or {}
c_hlad = _int_value(row.get("c_hlad"), 0)
line_id = f"lim:{_int_value(row.get('idriadok'), 0) or uuid.uuid4().hex}"
id_fmenu = fmenu_id(row)
menu_key = fmenu_key(id_fmenu, id_rov, c_hlad)
fmenu_kind = id_fmenu[:1].upper()
is_menu_parent = bool(menu_key and fmenu_kind == "H")
is_menu_child = bool(menu_key and fmenu_kind == "P")
if is_menu_parent and menu_key in menu_parent_lines:
line_id = menu_parent_lines[menu_key]
dph = _strip_value(row.get("dan"))
if dph.endswith(".0"):
dph = dph[:-2]
price = _float_value(row.get("cena_prod"), 0.0)
qty_units = _float_value(row.get("mnozstvi"), 0.0)
delitel = max(_int_value(row.get("polka"), 0) + 1, 1)
qty = round(qty_units * delitel, 4)
is_decimal_qty = abs(qty_units - round(qty_units)) > 0.0001
typ_menu = 1 if is_menu_parent else (2 if is_menu_child else (12 if is_decimal_qty else 0))
group_id = menu_key or line_id
parent_id = menu_parent_lines.get(menu_key) if is_menu_child else None
pol = data.UcPol(
id_card=id_card,
c_druh=_int_value(row.get("c_druh"), 0),
druh="",
prn_no="",
nazev=_strip_value(row.get("nazev")),
cena=price,
cena_puv=price,
dph=dph,
mena="",
cenhlad=_strip_value(course.get("cenhlad")) or "0",
pocet=qty,
delitel=delitel,
sklad=_strip_value(row.get("id_man")) or _strip_value(row.get("c_stredisk")) or "00",
line_id=line_id,
group_id=group_id,
parent_id=parent_id,
typ_menu=typ_menu,
pol_pocet=qty,
def_cena=price,
def_dph=dph,
def_hlad=_strip_value(course.get("cenhlad")) or "0",
guest_id=f"hlad:{c_hlad}",
course_id=f"rov:{id_rov}",
limit_item_id=_int_value(row.get("idriadok"), 0),
limit_rov_id=id_rov,
limit_hlad_id=c_hlad,
limit_fmenu_id=id_fmenu,
zpravy=[_strip_value(row.get("poznamka"))] if _strip_value(row.get("poznamka")) else [],
)
poloz.append(pol)
if typ_menu == 1:
menu_parent_qty[group_id] = float(pol.pocet or 0)
for pol in poloz:
if int(getattr(pol, "typ_menu", 0) or 0) != 2:
continue
parent_qty = menu_parent_qty.get(pol.group_id, 0)
if parent_qty:
pol.pol_pocet = round(float(pol.pocet or 0) / parent_qty, 4)
ucet = data.Ucet(
id_kas=id_kas,
stul=limit_table_id(id_limit, id_den),
table_name=limit_display_name_from_rows(rows, multiline=False),
room_name="Limity",
autor=user,
open_at=data.stime_str(),
origin="Limit",
poloz=poloz,
guests=guests,
courses=courses or [{"id": "rov:0", "name": "Chod 1", "id_rov": 0}],
guest_count=len(guests),
course_count=len(courses or [1]),
limit_id=id_limit,
limit_den_id=id_den,
limit_rov_ids=sorted({p.limit_rov_id for p in poloz if p.limit_rov_id}),
limit_mode=True,
)
ucet.sumdph()
return ucet
def _limit_int_from_id(value, prefix_text: str = "") -> int:
text = _strip_value(value)
if prefix_text and text.startswith(prefix_text):
text = text[len(prefix_text):]
return _int_value(text, 0)
def _limit_pol_rov_id(pol) -> int:
return (
_int_value(getattr(pol, "limit_rov_id", None), 0)
or _limit_int_from_id(getattr(pol, "course_id", ""), "rov:")
)
def _limit_pol_hlad_id(pol) -> int:
return (
_int_value(getattr(pol, "limit_hlad_id", None), 0)
or _limit_int_from_id(getattr(pol, "guest_id", ""), "hlad:")
)
def limit_receipt_rov_ids(ucet: data.Ucet) -> list[int]:
rov_ids = {
_limit_pol_rov_id(pol)
for pol in (getattr(ucet, "poloz", []) or [])
if _limit_pol_rov_id(pol) > 0
}
if not rov_ids:
rov_ids = {
_int_value(item, 0)
for item in (getattr(ucet, "limit_rov_ids", []) or [])
if _int_value(item, 0) > 0
}
return sorted(rov_ids)
def ensure_limit_lock_owner(prefix: str, id_kas: str, id_limit: int, client_id: str) -> None:
lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit)
with _limit_pg_locks_guard:
info = _limit_pg_locks.get(lock_id)
pg = info.get("pg") if info else None
if (
not info
or _pg_connection_closed(pg)
or str(info.get("client_id", "")) != str(client_id)
):
raise HTTPException(409, "Limitovy stol uz nie je zamknuty tymto terminalom.")
def _pg_table_columns(pg, schema: str, table_name: str) -> set[str]:
rows = _pg_fetchall_dict(pg, """
SELECT column_name
FROM information_schema.columns
WHERE table_schema=%s AND table_name=%s
""", (schema, table_name))
return {_strip_value(row.get("column_name")) for row in rows}
def _limit_item_note(pol) -> str:
notes = getattr(pol, "zpravy", []) or []
if isinstance(notes, list):
return "\n".join(str(note) for note in notes if str(note).strip())
return _strip_value(notes)
def _limit_item_quantity(pol) -> float:
den = int(getattr(pol, "delitel", 1) or 1)
den = den if den > 0 else 1
return round(float(getattr(pol, "pocet", 0) or 0) / den, 4)
def _limit_fmenu_number(value: str) -> int:
text = _strip_value(value).upper()
if len(text) <= 1 or text[0] not in {"H", "P"}:
return 0
return _int_value(text[1:], 0)
def _limit_generated_fmenu_ids(items) -> dict[str, str]:
max_no = 0
group_numbers: dict[str, int] = {}
for pol in items:
existing = _strip_value(getattr(pol, "limit_fmenu_id", ""))
no = _limit_fmenu_number(existing)
if no:
max_no = max(max_no, no)
group_id = _strip_value(getattr(pol, "group_id", ""))
if group_id:
group_numbers[group_id] = no
for pol in items:
typ_menu = _int_value(getattr(pol, "typ_menu", 0), 0)
group_id = _strip_value(getattr(pol, "group_id", ""))
if typ_menu in {1, 2} and group_id and group_id not in group_numbers:
max_no += 1
group_numbers[group_id] = max_no
result: dict[str, str] = {}
for pol in items:
line_id = _strip_value(getattr(pol, "line_id", ""))
if not line_id:
continue
existing = _strip_value(getattr(pol, "limit_fmenu_id", ""))
if existing:
result[line_id] = existing
continue
typ_menu = _int_value(getattr(pol, "typ_menu", 0), 0)
group_id = _strip_value(getattr(pol, "group_id", ""))
no = group_numbers.get(group_id, 0)
if typ_menu == 1 and no:
result[line_id] = f"H{no:06d}"
elif typ_menu == 2 and no:
result[line_id] = f"P{no:06d}"
else:
result[line_id] = ""
return result
def _limit_group_stredisk_map(items, fooddat_map: dict[str, int]) -> dict[str, int]:
result: dict[str, int] = {}
for pol in items:
group_id = _strip_value(getattr(pol, "group_id", ""))
if not group_id or group_id in result:
continue
stredisk = fooddat_stredisk_for_sklad(
fooddat_map,
getattr(pol, "sklad", ""),
)
if stredisk:
result[group_id] = stredisk
return result
def _limit_item_values(
ucet: data.Ucet,
pol,
poradie: int,
fmenu_id: str = "",
fooddat_map: dict[str, int] | None = None,
group_stredisk_map: dict[str, int] | None = None,
) -> dict:
delitel = max(_int_value(getattr(pol, "delitel", 1), 1), 1)
sklad = _strip_value(getattr(pol, "sklad", ""))
c_stredisk = fooddat_stredisk_for_sklad(fooddat_map or {}, sklad)
if not c_stredisk and _int_value(getattr(pol, "typ_menu", 0), 0) in {1, 2}:
c_stredisk = (group_stredisk_map or {}).get(_strip_value(getattr(pol, "group_id", "")), 0)
return {
"id_limit": int(getattr(ucet, "limit_id", 0) or 0),
"id_den": int(getattr(ucet, "limit_den_id", 0) or 0),
"id_rov": _limit_pol_rov_id(pol),
"c_karty": int(getattr(pol, "id_card", 0) or 0),
"cena_prod": float(getattr(pol, "cena", 0) or 0),
"dan": _strip_value(getattr(pol, "dph", "")),
"mnozstvi": _limit_item_quantity(pol),
"c_stredisk": c_stredisk or _int_value(sklad, 0),
"poradie": poradie,
"poznamka": _limit_item_note(pol),
"polka": delitel - 1,
"c_hlad": _limit_pol_hlad_id(pol),
"id_fmenu": _strip_value(fmenu_id or getattr(pol, "limit_fmenu_id", "")),
"vlastnik": _strip_value(getattr(pol, "vlastnik", "")) or _strip_value(getattr(ucet, "id_kas", "")),
}
def save_limit_items_to_postgres(prefix: str, id_kas: str, ucet: data.Ucet, client_id: str | None = None) -> None:
id_limit = int(getattr(ucet, "limit_id", 0) or 0)
id_den = int(getattr(ucet, "limit_den_id", 0) or 0)
if not id_limit or not id_den:
raise HTTPException(422, "Limitovy ucet nema id_limit alebo id_den.")
if client_id is not None:
ensure_limit_lock_owner(prefix, id_kas, id_limit, client_id)
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
with postgres_service.connect(conn) as pg:
cur = pg.cursor()
try:
columns = _pg_table_columns(pg, schema, "nlimity")
if not columns:
raise HTTPException(502, "V PostgreSQL neexistuje tabulka nlimity.")
existing_rows = _pg_fetchall_dict(pg, f"""
SELECT idriadok
FROM "{schema}"."slimity_polozky"
WHERE id_limit=%s AND id_den=%s AND COALESCE(c_karty, 0) <> 0
""", (id_limit, id_den))
existing_ids = {_int_value(row.get("idriadok"), 0) for row in existing_rows}
seen_ids: set[int] = set()
items = [pol for pol in (getattr(ucet, "poloz", []) or []) if int(getattr(pol, "id_card", 0) or 0)]
fmenu_ids = _limit_generated_fmenu_ids(items)
fooddat_map = get_fooddat_stredisk_map(prefix)
group_stredisk_map = _limit_group_stredisk_map(items, fooddat_map)
for poradie, pol in enumerate(items, start=1):
values = _limit_item_values(
ucet,
pol,
poradie,
fmenu_ids.get(_strip_value(getattr(pol, "line_id", "")), ""),
fooddat_map=fooddat_map,
group_stredisk_map=group_stredisk_map,
)
item_id = _int_value(getattr(pol, "limit_item_id", None), 0)
writable = {
key: value
for key, value in values.items()
if key in columns
}
if item_id:
seen_ids.add(item_id)
set_cols = [
key for key in writable
if key not in {"idriadok", "id_limit", "id_den", "vlastnik"}
]
if set_cols:
sql = ", ".join(f'"{key}"=%s' for key in set_cols)
cur.execute(
f'UPDATE "{schema}"."nlimity" SET {sql} WHERE idriadok=%s',
tuple(writable[key] for key in set_cols) + (item_id,),
)
else:
insert_cols = [
key for key in (
"id_limit", "id_den", "id_rov", "c_karty", "cena_prod",
"dan", "mnozstvi", "c_stredisk", "poradie", "poznamka",
"polka", "c_hlad", "id_fmenu", "vlastnik",
)
if key in writable
]
if not {"id_limit", "id_den", "id_rov", "c_karty"} <= set(insert_cols):
raise HTTPException(502, "Tabulka nlimity nema potrebne stlpce pre vlozenie polozky.")
placeholders = ", ".join(["%s"] * len(insert_cols))
cols_sql = ", ".join(f'"{key}"' for key in insert_cols)
cur.execute(
f'INSERT INTO "{schema}"."nlimity" ({cols_sql}) VALUES ({placeholders})',
tuple(writable[key] for key in insert_cols),
)
missing_ids = sorted(existing_ids - seen_ids)
if missing_ids and "mnozstvi" in columns:
placeholders = ", ".join(["%s"] * len(missing_ids))
cur.execute(
f'UPDATE "{schema}"."nlimity" SET "mnozstvi"=0 WHERE idriadok IN ({placeholders})',
tuple(missing_ids),
)
pg.commit()
except HTTPException:
pg.rollback()
raise
except Exception as exc:
pg.rollback()
logger.exception("Limit items save failed")
raise HTTPException(502, f"Limitove polozky sa nepodarilo ulozit: {exc}") from exc
finally:
cur.close()
def next_limit_cenhlad(prefix: str, id_kas: str, id_limit: int, id_den: int) -> str:
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
with postgres_service.connect(conn) as pg:
value = _pg_fetchone_value(pg, f"""
SELECT MAX(NULLIF(TRIM(COALESCE(cenhlad, '')), ''))
FROM "{schema}"."nlimitrov"
WHERE id_limit=%s AND id_den=%s
""", (id_limit, id_den))
current = _strip_value(value)
if not current:
return "A"
ch = current[-1:].upper()
if not ("A" <= ch <= "Y"):
return "Z"
return chr(ord(ch) + 1)
def limit_nlimitrov_ucislo(ucislo: str | None) -> str:
text = _strip_value(ucislo)
if len(text) > 7:
text = text[:2] + text[3:]
return text[:7]
def apply_limit_cenhlad_to_receipt_items(ucet: data.Ucet, cenhlad: str) -> None:
limit_level = _strip_value(cenhlad)
if not limit_level:
return
ucet.limit_cenhlad = limit_level
for pol in getattr(ucet, "poloz", []) or []:
pol.cenhlad = limit_level
def apply_limit_payment_to_postgres(prefix: str, id_kas: str, ucet: data.Ucet, cenhlad: str | None = None) -> None:
id_limit = int(getattr(ucet, "limit_id", 0) or 0)
id_den = int(getattr(ucet, "limit_den_id", 0) or 0)
rov_ids = limit_receipt_rov_ids(ucet)
if not id_limit or not id_den or not rov_ids:
raise HTTPException(422, "Limitovy ucet nema chody na zapis.")
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
cenhlad = _strip_value(cenhlad or getattr(ucet, "limit_cenhlad", "")) or next_limit_cenhlad(prefix, id_kas, id_limit, id_den)
ucislo_limit = limit_nlimitrov_ucislo(getattr(ucet, "ucislo", ""))
placeholders = ", ".join(["%s"] * len(rov_ids))
with postgres_service.connect(conn) as pg:
cur = pg.cursor()
try:
cur.execute(f"""
UPDATE "{schema}"."nlimitrov"
SET u_cis=%s,
cenhlad=%s,
uzaverka=%s
WHERE id_limit=%s
AND id_den=%s
AND id_rov IN ({placeholders})
""", (ucislo_limit, cenhlad, _strip_value(id_kas), id_limit, id_den, *rov_ids))
if cur.rowcount != len(rov_ids):
raise HTTPException(409, f"Limitove chody sa nepodarilo oznacit ({cur.rowcount}/{len(rov_ids)}).")
pg.commit()
except HTTPException:
pg.rollback()
raise
except Exception as exc:
pg.rollback()
logger.exception("Limit payment mark failed")
raise HTTPException(502, f"Limit sa nepodarilo oznacit ako zaplateny: {exc}") from exc
finally:
cur.close()
def clear_limit_payment_in_postgres(prefix: str, id_kas: str, ucet: data.Ucet) -> None:
id_limit = int(getattr(ucet, "limit_id", 0) or 0)
id_den = int(getattr(ucet, "limit_den_id", 0) or 0)
rov_ids = limit_receipt_rov_ids(ucet)
if not id_limit or not id_den or not rov_ids:
return
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
placeholders = ", ".join(["%s"] * len(rov_ids))
with postgres_service.connect(conn) as pg:
cur = pg.cursor()
try:
cur.execute(f"""
UPDATE "{schema}"."nlimitrov"
SET u_cis='',
uzaverka='',
cenhlad=''
WHERE id_limit=%s
AND id_den=%s
AND id_rov IN ({placeholders})
""", (id_limit, id_den, *rov_ids))
pg.commit()
except Exception as exc:
pg.rollback()
logger.exception("Limit payment clear failed")
raise HTTPException(502, f"Limit sa nepodarilo odznacit: {exc}") from exc
finally:
cur.close()
def sync_limit_closure_to_postgres(prefix: str, id_kas: str, raw_receipts: list[str], clsrep_id: int) -> None:
limit_receipts: list[data.Ucet] = []
for raw in raw_receipts:
try:
ucet = data.Ucet.model_validate_json(raw)
except Exception:
continue
if not getattr(ucet, "limit_id", None):
continue
if getattr(ucet, "is_storno", None) or getattr(ucet, "storno", None):
continue
if not getattr(ucet, "ucislo", None):
continue
if not limit_receipt_rov_ids(ucet):
continue
limit_receipts.append(ucet)
if not limit_receipts:
return
conn = ensure_limits_postgres(prefix, id_kas)
schema = _safe_pg_schema(conn)
with postgres_service.connect(conn) as pg:
cur = pg.cursor()
try:
for ucet in limit_receipts:
rov_ids = limit_receipt_rov_ids(ucet)
placeholders = ", ".join(["%s"] * len(rov_ids))
cur.execute(f"""
UPDATE "{schema}"."nlimitrov"
SET uzaverka=%s
WHERE id_limit=%s
AND id_den=%s
AND u_cis=%s
AND id_rov IN ({placeholders})
""", (
str(clsrep_id),
int(getattr(ucet, "limit_id", 0) or 0),
int(getattr(ucet, "limit_den_id", 0) or 0),
limit_nlimitrov_ucislo(getattr(ucet, "ucislo", "")),
*rov_ids,
))
pg.commit()
except Exception as exc:
pg.rollback()
logger.exception("Limit closure sync failed")
raise HTTPException(502, f"Limitovu uzavierku sa nepodarilo zapisat do PostgreSQL: {exc}") from exc
finally:
cur.close()
def insert_limit_closed_receipt_db(cur_pref: str, uct: data.Ucet, client_id: str) -> data.Ucet:
id_limit = int(getattr(uct, "limit_id", 0) or 0)
id_den = int(getattr(uct, "limit_den_id", 0) or 0)
if not id_limit or not id_den:
raise HTTPException(422, "Limitovy ucet nema id_limit alebo id_den.")
ensure_limit_lock_owner(cur_pref, uct.id_kas, id_limit, client_id)
rov_ids = limit_receipt_rov_ids(uct)
if not rov_ids:
raise HTTPException(422, "Limitovy ucet nema vybrany chod.")
uct.limit_mode = True
uct.limit_rov_ids = rov_ids
uct.limit_cenhlad = next_limit_cenhlad(cur_pref, uct.id_kas, id_limit, id_den)
table = f"{cur_pref}_ucty"
now = time_hhmmss()
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
uct.ucislo = generate_ucislo(cur, table, uct.id_kas)
uct.closed_at = uct.closed_at or now
uct.datetime = uct.datetime or uct.closed_at
uct.blocked_by = ""
uct.stul = uct.stul or limit_table_id(id_limit, id_den)
uct.room_name = uct.room_name or "Limity"
strip_transient_hotel_charge_data(uct)
ensure_hotel_charge_payment_targets(uct)
apply_limit_cenhlad_to_receipt_items(uct, uct.limit_cenhlad)
uct.sumdph()
uct.checksum_val = uct.checksum()
finalize_hotel_charge_on_close(cur_pref, uct)
payload = uct.model_dump_json()
cur.execute(f"""
INSERT INTO "{table}"
(ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
uct.ucislo,
uct.id_kas,
uct.stul,
uct.room_name,
"",
uct.closed_at,
uct.c_uzaverka,
payload,
))
apply_limit_payment_to_postgres(cur_pref, uct.id_kas, uct, uct.limit_cenhlad)
return uct
def hotel_raster_table_for_type(typ_hotel: int) -> str:
typ_hotel = int(typ_hotel or 0)
if typ_hotel in {17, 18}:
return "hotrastre"
if typ_hotel == 21:
return "mewsrastre"
if typ_hotel in {6, 10}:
return "fidrastre"
return ""
def hotel_raster_table_name(prefix: str, typ_hotel: int) -> str:
table_type = hotel_raster_table_for_type(typ_hotel)
return f"{prefix}_{table_type}" if table_type else ""
def price_level_raster_index(price_level: str) -> int:
idx = _int_value(price_level, 1)
if idx < 1:
return 1
if idx > 9:
return 9
return idx
def load_c_druh_map(prefix: str, id_kas: str) -> dict[int, int]:
table = f"{prefix}_cenik"
result: dict[int, int] = {}
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'SELECT id_card, c_druh, data FROM "{table}" WHERE pokl = ?', (id_kas,))
rows = cur.fetchall()
if not rows:
cur.execute(f'SELECT id_card, c_druh, data FROM "{table}"')
rows = cur.fetchall()
for id_card_db, c_druh_db, raw_json in rows:
id_card = _int_value(id_card_db, 0)
c_druh = _int_value(c_druh_db, 0)
if not id_card or not c_druh:
try:
payload = json.loads(raw_json)
except Exception:
payload = {}
if not id_card:
id_card = _int_value(payload.get("id_card"), 0)
if not c_druh:
c_druh = _int_value(payload.get("c_druh"), 0)
if id_card:
result[id_card] = c_druh
return result
def load_cenik_print_map(prefix: str, id_kas: str) -> dict[int, dict]:
table = f"{prefix}_cenik"
result: dict[int, dict] = {}
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'SELECT id_card, c_druh, druh, spart, prn_no, data FROM "{table}" WHERE pokl = ?', (id_kas,))
rows = cur.fetchall()
if not rows:
cur.execute(f'SELECT id_card, c_druh, druh, spart, prn_no, data FROM "{table}"')
rows = cur.fetchall()
for id_card_db, c_druh_db, druh_db, spart_db, prn_no_db, raw_json in rows:
try:
payload = json.loads(raw_json or "{}")
except Exception:
payload = {}
id_card = _int_value(id_card_db, 0) or _int_value(payload.get("id_card"), 0)
if not id_card:
continue
result[id_card] = {
"c_druh": _int_value(c_druh_db, 0) or _int_value(payload.get("c_druh"), 0),
"druh": _strip_value(druh_db) or _strip_value(payload.get("druh")),
"spart": _strip_value(spart_db) or _strip_value(payload.get("spart")),
"prn_no": _strip_value(prn_no_db) or _strip_value(payload.get("prn_no")),
}
return result
def load_hotel_raster_rows(prefix: str, id_kas: str, typ_hotel: int, reception_id: int) -> list[dict]:
table = hotel_raster_table_name(prefix, typ_hotel)
if not table:
return []
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT *
FROM "{table}"
WHERE id_kas = ? AND (id_hotel = ? OR id_hotel = 0)
ORDER BY CASE WHEN id_hotel = ? THEN 0 ELSE 1 END, id
''',
(id_kas, reception_id, reception_id),
)
columns = [desc[0] for desc in cur.description]
return [
dict(zip(columns, row))
for row in cur.fetchall()
]
def load_mews_tax_map(prefix: str, reception_id: int) -> dict[float, str]:
table = f"{prefix}_mewsdph"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT koefdph, mews_taxrate
FROM "{table}"
WHERE id_hotel = ?
''',
(reception_id,),
)
result = {}
for koefdph, tax_rate in cur.fetchall():
key = round(_float_value(koefdph, 0.0), 4)
value = _strip_value(tax_rate)
if key and value:
result[key] = value
return result
def mews_tax_code_for_rate(tax_map: dict[float, str], rate_value: str) -> str:
if not tax_map:
return ""
rate = round(_float_value(rate_value, 0.0), 4)
if rate in tax_map:
return tax_map[rate]
for key, value in tax_map.items():
if abs(key - rate) < 0.0001:
return value
return ""
def raster_row_score(row: dict, c_druh: int, tmatr: str, budova: str) -> int | None:
row_c_druh = _int_value(row.get("c_druh"), 0)
if row_c_druh not in {c_druh, -100}:
return None
row_tmatr = _strip_value(row.get("tmatr"))
row_budova = _strip_value(row.get("budova"))
tmatr = _strip_value(tmatr)
budova = _strip_value(budova)
if row_tmatr and row_tmatr != tmatr:
return None
if row_budova and row_budova != budova:
return None
score = 0
if row_c_druh == c_druh:
score += 100
if row_c_druh == -100:
score += 10
if row_tmatr and row_tmatr == tmatr:
score += 8
elif not row_tmatr:
score += 3
if row_budova and row_budova == budova:
score += 4
elif not row_budova:
score += 1
return score
def select_raster_row(rows: list[dict], c_druh: int, tmatr: str, budova: str) -> dict | None:
scored = []
for row in rows:
score = raster_row_score(row, c_druh, tmatr, budova)
if score is not None:
scored.append((score, row))
if not scored:
return None
scored.sort(key=lambda item: item[0], reverse=True)
return scored[0][1]
def raster_value_from_row(row: dict, typ_hotel: int, price_level: str) -> str:
idx = price_level_raster_index(price_level)
if int(typ_hotel or 0) in {6, 10}:
value = _strip_value(row.get("raster"))
if value and value != "0":
return value
value = _strip_value(row.get(f"raster{idx}"))
if value == "0":
return ""
return value
def select_raster_id(rows: list[dict], c_druh: int, tmatr: str, budova: str, typ_hotel: int, price_level: str) -> str:
scored = []
for row in rows:
score = raster_row_score(row, c_druh, tmatr, budova)
if score is not None:
scored.append((score, row))
scored.sort(key=lambda item: item[0], reverse=True)
for _, row in scored:
if _int_value(row.get("c_druh"), 0) == -100:
raster_id = raster_value_from_row(row, typ_hotel, "1")
if not raster_id:
raster_id = raster_value_from_row(row, typ_hotel, price_level)
else:
raster_id = raster_value_from_row(row, typ_hotel, price_level)
if raster_id:
return raster_id
return ""
def _hotel_charge_item_from_pol(pol, c_druh: int) -> data.HotelChargeItem:
quantity = _float_value(getattr(pol, "pocet", 0), 0.0) / max(_int_value(getattr(pol, "delitel", 1), 1), 1)
unit_price = _float_value(getattr(pol, "cena", 0), 0.0)
amount = round(quantity * unit_price, 2)
return data.HotelChargeItem(
line_id=_strip_value(getattr(pol, "line_id", "")),
id_card=_int_value(getattr(pol, "id_card", 0), 0),
name=_strip_value(getattr(pol, "nazev", "")),
c_druh=c_druh,
price_level=_strip_value(getattr(pol, "cenhlad", "")),
dph=_strip_value(getattr(pol, "dph", "")),
quantity=quantity,
unit_price=unit_price,
amount=amount,
)
def _add_hotel_charge_line(
lines: list[data.HotelChargeLine],
item: data.HotelChargeItem,
raster_id: str,
itemized: bool,
):
if itemized:
lines.append(data.HotelChargeLine(
raster_id=raster_id,
c_druh=item.c_druh,
price_level=item.price_level,
dph=item.dph,
description=item.name,
quantity=item.quantity,
unit_price=item.unit_price,
amount=item.amount,
items=[item],
))
return
for line in lines:
if line.raster_id == raster_id and line.dph == item.dph:
line.quantity = 1
line.amount = round(line.amount + item.amount, 2)
line.unit_price = line.amount
line.items.append(item)
if item.c_druh not in {line.c_druh, 0}:
line.c_druh = 0
return
lines.append(data.HotelChargeLine(
raster_id=raster_id,
c_druh=item.c_druh,
price_level=item.price_level,
dph=item.dph,
description="",
quantity=item.quantity,
unit_price=item.amount,
amount=item.amount,
items=[item],
))
def prepare_hotel_charge_db(
prefix: str,
ucet: data.Ucet,
target: data.HotelChargeTarget | None = None,
payment: data.Platba | None = None,
) -> data.HotelChargePreparation:
target = target or getattr(ucet, "hotel_charge", None)
if not target:
return data.HotelChargePreparation(
ready=False,
id_kas=_strip_value(getattr(ucet, "id_kas", "")),
receipt_number=_strip_value(getattr(ucet, "ucislo", "")),
errors=["Ucet nema vybrany hotelovy ciel."],
)
typ_hotel = int(getattr(target, "typ_hotel", 0) or 0)
reception_id = int(getattr(target, "reception_id", 0) or 0)
raster_type = hotel_raster_table_for_type(typ_hotel)
if not raster_type:
return data.HotelChargePreparation(
ready=False,
id_kas=_strip_value(getattr(ucet, "id_kas", "")),
typ_hotel=typ_hotel,
reception_id=reception_id,
reception_name=_strip_value(getattr(target, "reception_name", "")),
receipt_number=_strip_value(getattr(ucet, "ucislo", "")),
target=target,
errors=[f"Typ recepcie {typ_hotel} nema definovanu tabulku rastrov."],
)
id_kas = _strip_value(getattr(ucet, "id_kas", ""))
params = get_setup_param_values(prefix, id_kas) if id_kas else {}
itemized = _bool_setup_value(params.get("rastr_hot"))
tmatr = _strip_value(getattr(target, "time_attribute", ""))
budova = _strip_value(getattr(target, "building", ""))
c_druh_map = load_c_druh_map(prefix, id_kas) if id_kas else {}
raster_rows = load_hotel_raster_rows(prefix, id_kas, typ_hotel, reception_id)
mews_tax_map = load_mews_tax_map(prefix, reception_id) if typ_hotel == 21 else {}
lines: list[data.HotelChargeLine] = []
warnings: list[str] = []
errors: list[str] = []
currency = ""
for pol in getattr(ucet, "poloz", []) or []:
item_c_druh = _int_value(getattr(pol, "c_druh", 0), 0)
if not item_c_druh:
item_c_druh = c_druh_map.get(_int_value(getattr(pol, "id_card", 0), 0), 0)
item = _hotel_charge_item_from_pol(pol, item_c_druh)
if abs(item.amount) < 0.005:
continue
if not currency:
currency = _strip_value(getattr(pol, "mena", ""))
if not item.c_druh:
warnings.append(f"Polozka {item.name or item.id_card} nema nastavene c_druh.")
raster_id = select_raster_id(raster_rows, item.c_druh, tmatr, budova, typ_hotel, item.price_level)
if not raster_id:
errors.append(
f"Pre polozku {item.name or item.id_card} sa nenasiel platny raster "
f"(c_druh={item.c_druh}, hotel={reception_id}, hladina={item.price_level or '1'})."
)
continue
_add_hotel_charge_line(lines, item, raster_id, itemized)
tip_amount = round(_float_value(getattr(payment, "tip", 0), 0.0), 2) if payment else 0.0
if abs(tip_amount) >= 0.005:
tip_item = data.HotelChargeItem(
line_id="TIP",
id_card=0,
name="TIP",
c_druh=-1,
price_level="1",
dph="0",
quantity=1,
unit_price=tip_amount,
amount=tip_amount,
)
raster_id = select_raster_id(raster_rows, -1, tmatr, budova, typ_hotel, tip_item.price_level)
if not raster_id:
errors.append(
f"Pre TIP sa nenasiel platny raster (c_druh=-1, hotel={reception_id})."
)
else:
_add_hotel_charge_line(lines, tip_item, raster_id, itemized)
for line in lines:
line.quantity = round(line.quantity, 3)
line.amount = round(line.amount, 2)
if line.quantity:
line.unit_price = round(line.amount / line.quantity, 2)
else:
line.unit_price = line.amount
total = round(sum(line.amount for line in lines), 2)
logger.info(
"HOTEL charge prepared: prefix=%s id_kas=%s typ=%s reception=%s "
"receipt=%s target_room=%s target_guest=%s raster_rows=%s "
"lines=%s total=%s rasters=%s warnings=%s errors=%s",
prefix,
id_kas,
typ_hotel,
reception_id,
_strip_value(getattr(ucet, "ucislo", "")),
_strip_value(getattr(target, "room_code", "")),
_strip_value(getattr(target, "guest_name", "")),
len(raster_rows),
len(lines),
total,
[
{
"raster": line.raster_id,
"amount": line.amount,
"dph": line.dph,
"description": line.description,
}
for line in lines[:20]
],
"; ".join(warnings),
"; ".join(errors),
)
return data.HotelChargePreparation(
ready=bool(lines) and not errors,
id_kas=id_kas,
typ_hotel=typ_hotel,
reception_id=reception_id,
reception_name=_strip_value(getattr(target, "reception_name", "")),
raster_table=raster_type,
receipt_number=_strip_value(getattr(ucet, "ucislo", "")),
currency=currency,
target=target,
lines=lines,
total=total,
warnings=warnings,
errors=errors,
)
def payment_hotel_charge_amount(ucet: data.Ucet, payment: data.Platba) -> float:
amount = round(_float_value(getattr(payment, "suma_czk", 0), 0.0), 2)
if not amount:
amount = round(_float_value(getattr(payment, "suma", 0), 0.0), 2)
return round(amount + _float_value(getattr(payment, "tip", 0), 0.0), 2)
def scale_hotel_charge_preparation(
preparation: data.HotelChargePreparation,
target_amount: float,
) -> data.HotelChargePreparation:
target_amount = round(_float_value(target_amount, 0.0), 2)
if not preparation.ready or not preparation.lines or not target_amount:
return preparation
source_total = round(_float_value(preparation.total, 0.0), 2)
if not source_total or abs(source_total - target_amount) < 0.01:
return preparation
result = preparation.model_copy(deep=True)
ratio = target_amount / source_total
running = 0.0
for idx, line in enumerate(result.lines):
if idx == len(result.lines) - 1:
amount = round(target_amount - running, 2)
else:
amount = round(line.amount * ratio, 2)
running = round(running + amount, 2)
line.amount = amount
if line.quantity:
line.unit_price = round(amount / line.quantity, 2)
else:
line.unit_price = amount
line.items = []
result.total = round(sum(line.amount for line in result.lines), 2)
return result
def strip_transient_hotel_charge_data(ucet: data.Ucet) -> None:
ucet.hotel_charge = None
ucet.hotel_charge_preparation = None
ucet.hotel_charge_send_result = None
def ensure_hotel_charge_payment_targets(ucet: data.Ucet) -> None:
account_target = getattr(ucet, "hotel_charge", None)
payments = getattr(ucet, "platby", []) or []
if not account_target:
return
if not payments:
raise HTTPException(422, "Hotelovy ucet nema ziadnu platbu.")
if any(getattr(payment, "hotel_charge", None) for payment in payments):
return
logger.info("HOTEL charge target moved from account to first payment before finalize.")
try:
payments[0].hotel_charge = account_target
except Exception:
object.__setattr__(payments[0], "hotel_charge", account_target)
@app.get("/hotel/receptions/", response_model=list[data.HotelReception])
def get_hotel_receptions(
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET hotel receptions: prefix={prefix} user={user}")
return [
hotel_service.reception_public(reception)
for reception in load_receptions_from_db(prefix)
]
@app.get("/hotel/rooms/", response_model=data.HotelRoomsResponse)
def get_hotel_rooms(
reception_id: int,
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET hotel rooms: prefix={prefix} user={user}")
reception = load_reception_from_db(prefix, reception_id)
public = hotel_service.reception_public(reception)
manual_room = hotel_service.manual_room_required(public.typ_hotel)
if manual_room:
return data.HotelRoomsResponse(
reception=public,
manual_room=True,
rooms=[],
message="Recepcny system vyzaduje manualne zadanie izby.",
)
try:
rooms = hotel_service.load_rooms(
reception,
get_setup_param_values(prefix, id_kas),
)
except hotel_service.HotelServiceError as e:
raise HTTPException(502, str(e))
return data.HotelRoomsResponse(
reception=public,
manual_room=False,
rooms=rooms,
)
@app.get("/hotel/guests/", response_model=list[data.HotelGuest])
def get_hotel_guests(
reception_id: int,
id_kas: str,
room_id: str = "",
room_code: str = "",
account_id: str = "",
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
reception = load_reception_from_db(prefix, reception_id)
ensure_postgres_for_reception(prefix, id_kas, reception)
params = get_setup_param_values(prefix, id_kas)
if int(getattr(reception, "typ_hotel", 0) or 0) == 6:
try:
return fidelio_db_service.load_guests(
get_postgres_connection_db(prefix, include_password=True),
id_kas=id_kas,
params=params,
room_code=room_code,
)
except (fidelio_db_service.FidelioDbError, postgres_service.PostgresServiceError) as e:
raise HTTPException(502, str(e))
try:
return hotel_service.load_guests(
reception,
params,
room_id=room_id,
room_code=room_code,
account_id=account_id,
)
except hotel_service.HotelServiceError as e:
raise HTTPException(502, str(e))
@app.post("/hotel/card/", response_model=data.HotelCardResult)
def check_hotel_card(
request: data.HotelCardRequest,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
reception = load_reception_from_db(prefix, request.reception_id)
ensure_postgres_for_reception(prefix, request.id_kas, reception)
params = get_setup_param_values(prefix, request.id_kas)
if int(getattr(reception, "typ_hotel", 0) or 0) == 6:
try:
return fidelio_db_service.check_card(
get_postgres_connection_db(prefix, include_password=True),
id_kas=request.id_kas,
params=params,
card_code=request.card_code,
)
except (fidelio_db_service.FidelioDbError, postgres_service.PostgresServiceError) as e:
raise HTTPException(502, str(e))
try:
return hotel_service.check_card(
reception,
params,
request.card_code,
)
except hotel_service.HotelServiceError as e:
raise HTTPException(502, str(e))
@app.post("/hotel/charge/prepare/", response_model=data.HotelChargePreparation)
def prepare_hotel_charge(
ucet: data.Ucet,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
f"HOTEL charge prepare: prefix={prefix} user={user} "
f"ucet={getattr(ucet, 'ucislo', '')}"
)
return prepare_hotel_charge_db(prefix, ucet)
def ucet_save_response(payload: dict, ucet: data.Ucet) -> dict:
return payload
def send_hotel_charge_db(
prefix: str,
ucet: data.Ucet,
preparation: data.HotelChargePreparation | None = None,
) -> data.HotelChargeSendResult:
preparation = preparation or prepare_hotel_charge_db(prefix, ucet)
if not preparation.ready:
message = "; ".join(preparation.errors or []) or "Hotelovy ucet nie je pripraveny."
return data.HotelChargeSendResult(
ok=False,
message=message,
preparation=preparation,
)
id_kas = _strip_value(getattr(ucet, "id_kas", ""))
reception = load_reception_from_db(prefix, preparation.reception_id)
if int(preparation.typ_hotel or 0) == 6:
ensure_postgres_for_reception(prefix, id_kas, reception)
try:
result = fidelio_db_service.charge_account(
get_postgres_connection_db(prefix, include_password=True),
id_kas=id_kas,
preparation=preparation,
)
except (fidelio_db_service.FidelioDbError, postgres_service.PostgresServiceError) as e:
return data.HotelChargeSendResult(
ok=False,
message=str(e),
preparation=preparation,
)
else:
try:
result = hotel_service.charge_account(
reception,
get_setup_param_values(prefix, id_kas),
preparation,
)
except hotel_service.HotelServiceError as e:
return data.HotelChargeSendResult(
ok=False,
message=str(e),
preparation=preparation,
)
return data.HotelChargeSendResult(
ok=bool(result.get("ok")),
message=str(result.get("message") or "OK"),
request_number=result.get("request_number"),
preparation=preparation,
)
def finalize_hotel_charge_on_close(prefix: str, ucet: data.Ucet) -> None:
if not getattr(ucet, "closed_at", None):
strip_transient_hotel_charge_data(ucet)
return
payment_debug = [
{
"code": getattr(payment, "code", ""),
"name": getattr(payment, "nazev", ""),
"amount": payment_hotel_charge_amount(ucet, payment),
"has_hotel_charge": bool(getattr(payment, "hotel_charge", None)),
}
for payment in (getattr(ucet, "platby", []) or [])
]
logger.info(
"HOTEL charge finalize: prefix=%s receipt=%s closed_at=%s payments=%s account_target=%s",
prefix,
_strip_value(getattr(ucet, "ucislo", "")),
_strip_value(getattr(ucet, "closed_at", "")),
payment_debug,
bool(getattr(ucet, "hotel_charge", None)),
)
hotel_payment_targets = [
(payment, getattr(payment, "hotel_charge", None))
for payment in (getattr(ucet, "platby", []) or [])
if getattr(payment, "hotel_charge", None)
]
if not hotel_payment_targets and getattr(ucet, "hotel_charge", None) and getattr(ucet, "platby", None):
logger.info("HOTEL charge finalize: using account-level transient target for first payment.")
hotel_payment_targets = [(ucet.platby[0], ucet.hotel_charge)]
if not hotel_payment_targets:
logger.info("HOTEL charge finalize: no hotel payments.")
strip_transient_hotel_charge_data(ucet)
return
logger.info("HOTEL charge finalize: selected hotel payments=%s", len(hotel_payment_targets))
for payment, target in hotel_payment_targets:
if target and not getattr(payment, "hotel_charge", None):
try:
payment.hotel_charge = target
except Exception:
object.__setattr__(payment, "hotel_charge", target)
try:
logger.info(
"HOTEL charge prepare start: prefix=%s receipt=%s payment=%s amount=%s target=%s",
prefix,
_strip_value(getattr(ucet, "ucislo", "")),
getattr(payment, "nazev", ""),
payment_hotel_charge_amount(ucet, payment),
target.model_dump(mode="json") if hasattr(target, "model_dump") else target,
)
preparation = prepare_hotel_charge_db(prefix, ucet, target=target, payment=payment)
preparation = scale_hotel_charge_preparation(
preparation,
payment_hotel_charge_amount(ucet, payment),
)
except HTTPException:
raise
except Exception as e:
logger.exception(
"HOTEL charge prepare failed: prefix=%s receipt=%s payment=%s target=%s",
prefix,
_strip_value(getattr(ucet, "ucislo", "")),
getattr(payment, "nazev", ""),
target.model_dump(mode="json") if hasattr(target, "model_dump") else target,
)
raise HTTPException(502, f"Priprava hoteloveho uctu zlyhala: {e}") from e
logger.info(
"HOTEL charge close: prefix=%s id_kas=%s typ=%s reception=%s "
"receipt=%s payment=%s amount=%s ready=%s lines=%s total=%s errors=%s target=%s",
prefix,
_strip_value(getattr(ucet, "id_kas", "")),
preparation.typ_hotel,
preparation.reception_id,
preparation.receipt_number,
getattr(payment, "nazev", ""),
payment_hotel_charge_amount(ucet, payment),
preparation.ready,
len(preparation.lines or []),
preparation.total,
"; ".join(preparation.errors or []),
preparation.target.model_dump(mode="json") if preparation.target else None,
)
logger.info(f"HOTEL charge send request: {preparation}")
try:
result = send_hotel_charge_db(prefix, ucet, preparation=preparation)
except HTTPException:
raise
except Exception as e:
logger.exception(
"HOTEL charge send failed: prefix=%s receipt=%s payment=%s preparation=%s",
prefix,
_strip_value(getattr(ucet, "ucislo", "")),
getattr(payment, "nazev", ""),
preparation.model_dump(mode="json") if hasattr(preparation, "model_dump") else preparation,
)
raise HTTPException(502, f"Odoslanie hoteloveho uctu zlyhalo: {e}") from e
logger.info(
"HOTEL charge send result: prefix=%s receipt=%s payment=%s ok=%s message=%s request=%s",
prefix,
_strip_value(getattr(ucet, "ucislo", "")),
getattr(payment, "nazev", ""),
result.ok,
result.message,
result.request_number,
)
if not result.ok:
raise HTTPException(502, result.message or "Hotelovy ucet sa nepodarilo odoslat do recepcie.")
strip_transient_hotel_charge_data(ucet)
@app.post("/hotel/charge/send/", response_model=data.HotelChargeSendResult)
def send_hotel_charge(
ucet: data.Ucet,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
f"HOTEL charge send: prefix={prefix} user={user} "
f"ucet={getattr(ucet, 'ucislo', '')}"
)
return send_hotel_charge_db(prefix, ucet)
@app.post("/uveryall/")
def update_uvery(
ucp: list[data.Uvery],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_uvery_db(prefix, ucp)
def update_uvery_db(prefix: str, ucp: list[data.Uvery]):
table = f"{prefix}_uvery"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'SELECT hjmeno FROM "{table}"')
db_ids = {row[0] for row in cur.fetchall()}
# 🔹 2. incoming IDs
incoming_ids = {(ucp1.hjmeno) for ucp1 in ucp}
# 🔹 3. DELETE (čo už nie je v requeste)
to_delete = db_ids - incoming_ids
if to_delete:
cur.executemany(
f'DELETE FROM "{table}" WHERE hjmeno=?',
list(to_delete)
) # 🔹 4. INSERT / UPDATE
for item in ucp:
cur.execute(f"""
INSERT INTO "{table}" (hjmeno, adresa1, adresa2, adresa3, ico, dic, icdph)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hjmeno) DO UPDATE SET
adresa1 = excluded.adresa1, adresa2=excluded.adresa2, adresa3=excluded.adresa3, ico=excluded.ico, dic=excluded.dic, icdph=excluded.icdph
""", (item.hjmeno, item.adresa1, item.adresa2, item.adresa3, item.ico, item.dic, item.icdph))
conn.commit()
return {"ok": True}
@app.post("/mewsdph/")
def update_mewsdph(
ucp: list[data.MewsDph],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_mewsdph_db(prefix, ucp)
def update_mewsdph_db(prefix: str, ucp: list[data.MewsDph]):
table = f"{prefix}_mewsdph"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
rows = []
for item in ucp:
rows.append(
(
item.id,
item.id_hotel,
item.mews_taxrate,
item.koefdph
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id, id_hotel, mews_taxrate, koefdph)
VALUES (?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(rows)}
@app.post("/hotplatby/")
def update_hotplatby(
ucp: list[data.HotPlatby],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_hotplatby_db(prefix, ucp)
def update_hotplatby_db(prefix: str, ucp: list[data.HotPlatby]):
table = f"{prefix}_hotplatby"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
rows = []
for item in ucp:
rows.append(
(
item.id_hotel,
item.druh_pl,
item.hot_platba_id,
item.hot_karta_id,
item.hot_platba,
item.hot_karta,
item.po_uctoch,
item.payment,
item.id_meny
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id_hotel, druh_pl, hot_platba_id, hot_karta_id, hot_platba, hot_karta, po_uctoch, payment, id_meny)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(rows)}
@app.get("/uvery/", response_model=list[data.UverFirma])
def get_uvery(
q: str = "",
limit: int = 2000,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET uvery: prefix={prefix} q={q!r}")
#return load_uvery_from_db(prefix, q=q, limit=limit)
return JSONResponse(
content=[item.model_dump() for item in load_uvery_from_db(prefix, q=q, limit=limit)],
media_type="application/json; charset=utf-8",
)
def load_uvery_from_db(prefix: str, q: str = "", limit: int = 2000) -> list[data.UverFirma]:
table = f"{prefix}_uvery"
limit = max(1, min(int(limit or 2000), 10000))
terms = [t.strip().lower() for t in str(q or "").split() if t.strip()]
where = ""
params: list = []
if terms:
searchable = (
"lower(coalesce(hjmeno,'') || ' ' || coalesce(adresa1,'') || ' ' || "
"coalesce(adresa2,'') || ' ' || coalesce(adresa3,'') || ' ' || "
"coalesce(ico,'') || ' ' || coalesce(icdph,'') || ' ' || coalesce(dic,''))"
)
where = "WHERE " + " AND ".join([f"{searchable} LIKE ?" for _ in terms])
params.extend([f"%{term}%" for term in terms])
params.append(limit)
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f"""
SELECT id, hjmeno, adresa1, adresa2, adresa3, ico, icdph, dic
FROM "{table}"
{where}
ORDER BY hjmeno COLLATE NOCASE
LIMIT ?
""",
params,
)
rows = cur.fetchall()
return [
data.UverFirma(
id=row[0],
hjmeno=row[1],
adresa1=row[2],
adresa2=row[3],
adresa3=row[4],
ico=row[5],
icdph=row[6],
dic=row[7],
)
for row in rows
]
@app.post("/uvery/", response_model=data.UverFirma)
def save_uver(
firma: data.UverFirma,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"POST uver: prefix={prefix} firma={firma.hjmeno!r}")
return save_uver_to_db(prefix, firma)
def save_uver_to_db(prefix: str, firma: data.UverFirma) -> data.UverFirma:
if not firma.hjmeno.strip():
raise HTTPException(422, "Meno firmy je povinne")
table = f"{prefix}_uvery"
with get_db() as conn:
cur = conn.cursor()
row_id = firma.id
if row_id is None:
cur.execute(
f'SELECT id FROM "{table}" WHERE hjmeno = ? COLLATE NOCASE',
(firma.hjmeno,),
)
row = cur.fetchone()
row_id = row[0] if row else None
if row_id is None:
cur.execute(
f"""
INSERT INTO "{table}" (hjmeno, adresa1, adresa2, adresa3, ico, icdph, dic)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(firma.hjmeno, firma.adresa1, firma.adresa2, firma.adresa3, firma.ico, firma.icdph, firma.dic),
)
row_id = cur.lastrowid
else:
cur.execute(
f"""
UPDATE "{table}"
SET hjmeno=?, adresa1=?, adresa2=?, adresa3=?, ico=?, icdph=?, dic=?
WHERE id=?
""",
(firma.hjmeno, firma.adresa1, firma.adresa2, firma.adresa3, firma.ico, firma.icdph, firma.dic, row_id),
)
conn.commit()
return data.UverFirma(
id=row_id,
hjmeno=firma.hjmeno,
adresa1=firma.adresa1,
adresa2=firma.adresa2,
adresa3=firma.adresa3,
ico=firma.ico,
icdph=firma.icdph,
dic=firma.dic,
)
@app.post("/hotrastre/")
def update_hotrastre(
ucp: list[data.HotRastre],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_hotrastre_db(prefix, ucp)
def update_hotrastre_db(prefix: str, ucp: list[data.HotRastre]):
table = f"{prefix}_hotrastre"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
rows = []
for item in ucp:
rows.append(
(
item.id,
item.id_kas,
item.id_hotel,
item.c_druh,
item.raster1,
item.raster2,
item.raster3,
item.raster4,
item.raster5,
item.raster6,
item.raster7,
item.raster8,
item.raster9,
item.dph1,
item.dph2,
item.dph3,
item.dph4,
item.dph5,
item.dph6,
item.dph7,
item.dph8,
item.dph9,
item.tmatr,
item.budova,
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id, id_kas, id_hotel, c_druh, raster1, raster2, raster3, raster4, raster5, raster6, raster7, raster8, raster9, dph1, dph2, dph3, dph4, dph5, dph6, dph7, dph8, dph9, tmatr, budova)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(rows)}
@app.post("/mewsrastre/")
def update_mewsrastre(
ucp: list[data.MewsRastre],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_mewsrastre_db(prefix, ucp)
def update_mewsrastre_db(prefix: str, ucp: list[data.MewsRastre]):
table = f"{prefix}_mewsrastre"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
rows = []
for item in ucp:
rows.append(
(
item.id,
item.id_kas,
item.id_hotel,
item.c_druh,
item.raster1,
item.raster2,
item.raster3,
item.raster4,
item.raster5,
item.raster6,
item.raster7,
item.raster8,
item.raster9,
item.dph1,
item.dph2,
item.dph3,
item.dph4,
item.dph5,
item.dph6,
item.dph7,
item.dph8,
item.dph9,
item.tmatr,
item.budova,
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id, id_kas, id_hotel, c_druh, raster1, raster2, raster3, raster4, raster5, raster6, raster7, raster8, raster9, dph1, dph2, dph3, dph4, dph5, dph6, dph7, dph8, dph9, tmatr, budova)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(rows)}
@app.post("/fidrastre/")
def update_fidrastre(
ucp: list[data.FidRastre],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return update_fidrastre_db(prefix, ucp)
def update_fidrastre_db(prefix: str, ucp: list[data.FidRastre]):
table = f"{prefix}_fidrastre"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
rows = []
for item in ucp:
rows.append(
(
item.id,
item.id_kas,
item.id_hotel,
item.c_druh,
item.raster,
item.raster1,
item.raster2,
item.raster3,
item.raster4,
item.raster5,
item.raster6,
item.raster7,
item.raster8,
item.raster9,
item.dph1,
item.dph2,
item.dph3,
item.dph4,
item.dph5,
item.dph6,
item.dph7,
item.dph8,
item.dph9,
item.tmatr,
item.budova,
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id, id_kas, id_hotel, c_druh, raster, raster1, raster2, raster3, raster4, raster5, raster6, raster7, raster8, raster9, dph1, dph2, dph3, dph4, dph5, dph6, dph7, dph8, dph9, tmatr, budova)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(rows)}
@app.get("/zlavy/{id_kas}", response_model=list[data.Zlava])
def get_zlavy_for_kasa(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
logger.info(f"GET zlavy: prefix={prefix} pokladna={id_kas}")
return zlavy_load_for_kasa(prefix, id_kas)
def zlavy_load_for_kasa(
prefix: str,
id_kas: str,
) -> list[data.Zlava]:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_zlavy"
where = ["id_kas = ?"]
params: list = [id_kas]
sql = f'''
SELECT id, data
FROM "{table}"
WHERE {" AND ".join(where)}
ORDER BY meno, id_zlavy_hlav
'''
with get_db() as conn:
cur = conn.cursor()
cur.execute(sql, params)
rows = cur.fetchall()
result = []
for rowid, raw_json in rows:
obj, changed = model_from_json_migrated(data.Zlava, raw_json)
if changed:
payload = obj.model_dump(mode="json")
cur.execute(
f'''
UPDATE "{table}"
SET meno=?, data=?
WHERE id=?
''',
(
obj.meno,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
rowid,
),
)
result.append(obj)
conn.commit()
return result
@app.post("/zlavy/{id_kas}")
def replace_zlavy_for_kasa(
id_kas: str,
zlavy: list[data.Zlava],
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
logger.info(f"POST zlavy: prefix={prefix} pokladna={id_kas} count={len(zlavy)}")
return zlavy_replace_for_kasa(prefix, id_kas, zlavy)
def zlavy_replace_for_kasa(prefix: str, id_kas: str, zlavy: list[data.Zlava]) -> dict:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_zlavy"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}" WHERE id_kas=?', (id_kas,))
rows = []
for item in zlavy:
payload = item.model_dump(mode="json")
raw_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
rows.append(
(
id_kas,
int(item.idriadok),
item.meno,
raw_json,
)
)
if rows:
cur.executemany(
f'''
INSERT INTO "{table}"
(id_kas, id_zlavy_hlav, meno, data)
VALUES (?, ?, ?, ?)
''',
rows,
)
conn.commit()
return {"ok": True, "count": len(zlavy)}
@app.delete("/zlavy/{id_kas}", status_code=204)
def delete_zlavy_for_kasa(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
zlavy_delete_for_kasa(prefix, id_kas)
def zlavy_delete_for_kasa(prefix: str, id_kas: str) -> bool:
if len(id_kas.strip()) != 2:
raise HTTPException(422, "Invalid id_kas")
table = f"{prefix}_zlavy"
with get_db() as conn:
cur = conn.cursor()
cur.execute(f'DELETE FROM "{table}" WHERE id_kas=?', (id_kas,))
deleted = cur.rowcount
conn.commit()
return deleted > 0
# -----------------------------------------------------
# ---Mapa stolu
@app.get("/mapa_stolu/", response_model=data.MapaStolu)
def get_mapa_stolu(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"GET mapa_stolu: prefix={prefix} pokladna={id_kas}")
return get_mapa_stolu_from_db(prefix, id_kas)
def get_mapa_stolu_from_db(cur_pref: str, id_kas: str) -> data.MapaStolu:
table = f"{cur_pref}_mapa_stolu"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT data
FROM "{table}"
WHERE EXISTS (
SELECT 1
FROM json_each(data, '$.pokladny')
WHERE value = ?
)
ORDER BY id DESC
LIMIT 1
''',
(id_kas,),
)
row = cur.fetchone()
if not row:
# fallback → vezmi poslední mapu (nebo seed)
cur.execute(
f'''
SELECT data
FROM "{table}"
ORDER BY id DESC
LIMIT 1
'''
)
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Mapa stolu nenalezena")
raw_json = row[0]
# validace přes pydantic
mapa = data.MapaStolu.model_validate_json(raw_json)
return mapa
@app.post("/mapa_stolu/")
def save_mapa_stolu(
mapa: data.MapaStolu,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f"POST mapa_stolu: prefix={prefix} pokladny={mapa.pokladny}")
save_mapa_to_db(prefix, mapa)
return {"ok": True}
def save_mapa_to_db(cur_pref: str, mapa: data.MapaStolu):
table = f"{cur_pref}_mapa_stolu"
raw_json = json.dumps(
mapa.model_dump(),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
with get_db() as conn:
cur = conn.cursor()
# smaž všechny mapy, které obsahují některou pokladnu
for kas in mapa.pokladny:
cur.execute(
f'''
DELETE FROM "{table}"
WHERE EXISTS (
SELECT 1
FROM json_each(data, '$.pokladny')
WHERE value = ?
)
''',
(kas,),
)
# vlož novou mapu
cur.execute(
f'''
INSERT INTO "{table}" (data)
VALUES (?)
''',
(raw_json,),
)
# -----------------------------------------------------
# ---OPERACE S UCTY
class UcetBlockedError(Exception):
pass
class UcetNotFoundError(Exception):
pass
# -----------
def time_hhmmss() -> str:
from datetime import datetime
return datetime.now().strftime("%H:%M:%S")
def is_block_expired(cur, prefix: str, id_kas: str, blocked_by: str) -> bool:
#blocked_by = "<client_id>|<timestamp>"
try:
client_id, _ = blocked_by.split("|", 1)
except ValueError:
return True
row = cur.execute(
"""
SELECT last_seen
FROM heartbeat_clients
WHERE prefix=? AND id_kas=? AND client_id=?
""",
(prefix, id_kas, client_id),
).fetchone()
if not row:
return True
try:
last_seen = float(row[0])
except (TypeError, ValueError):
return True
return (time.time() - last_seen) > BLOCK_EXPIRATION
# ---najde otevreny ucet stolu-------------------------
def find_open_ucet_by_stul(cur, table: str, stul: str, id_kas: str):
"""
Najde otevřený účet ke stolu.
Vrací: (ucty_id, ucislo, blocked_by, data_json)
nebo None, pokud neexistuje.
"""
cur.execute(f"""
SELECT
ucty_id,
ucislo,
blocked_by,
data
FROM "{table}"
WHERE stul = ? AND id_kas = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '')
LIMIT 1
""", (stul,id_kas))
return cur.fetchone()
# ---generator cisla uctu
def generate_ucislo(cur, table: str, id_kas: str) -> str:
"""
Vygeneruje nové číslo účtu ve tvaru KK000001
- KK = id_kas (2 znaky)
- sekvence z EXISTUJÍCÍCH účtů pro danou pokladnu
"""
if not id_kas or len(id_kas) != 2:
raise ValueError("id_kas must be 2 characters")
cur.execute(f"""
SELECT MAX(CAST(SUBSTR(ucislo, 3) AS INTEGER))
FROM "{table}"
WHERE id_kas = ?
AND ucislo IS NOT NULL
""", (id_kas,))
max_num = cur.fetchone()[0] or 0
return data.next_ucislo(f"{id_kas}{max_num:06d}")
# ---test je-li ucet blokovan
@app.get("/ucet/is_blocked/")
def is_blocked( stul: str = Query(...), id_kas:str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, user, client_id = auth
cleanup_dead_clients( prefix, id_kas)
return load_ucet_block_state(
cur_pref=prefix,
stul=stul, id_kas=id_kas)
def load_ucet_block_state( cur_pref: str, stul: str, id_kas: str,) -> dict:
table = f"{cur_pref}_ucty"
sql = f"""
SELECT blocked_by FROM "{table}" WHERE
stul = ? AND id_kas = ? AND (closed_at IS NULL OR TRIM(closed_at) = '')
"""
with get_db() as conn:
cur = conn.cursor()
cur.execute(sql, (stul, id_kas,))
row = cur.fetchone()
if not row:
return {
"exists": False,
"blocked": False,
"blocked_by": "", }
blocked_by = row[0]
blocked = bool(blocked_by and blocked_by.strip())
return {
"exists": True,
"blocked": blocked,
"blocked_by": blocked_by or "", }
# ---upsert operace endpoint
@app.post("/ucet/")
def upsert_ucet(
uct: data.Ucet,
block: bool = Query(False),
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, user, client_id = auth
cleanup_dead_clients( prefix, uct.id_kas)
logger.info(f'Upsert ucet')
if not uct.id_kas:
raise HTTPException(422, "id_kas must be set in Ucet")
if uct.closed_at is None:
if not uct.stul:
raise HTTPException(422, "Stul must be set for open ucet")
return upsert_ucet_db(prefix, uct, client_id, block)
def insert_storno_ucet(cur_pref: str, uct: data.Ucet, client_id: str):
table = f"{cur_pref}_ucty"
now = time_hhmmss()
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
# storno účet NESMÍ mít stůl, to teda uplne nevim proc
# uct.stul = None
# nové číslo účtu
uct.ucislo = generate_ucislo(cur, table, uct.id_kas)
uct.closed_at = uct.closed_at or now
#uct.open_at = None
uct.blocked_by = ""
ensure_ucet_room_name(cur_pref, uct)
ensure_hotel_charge_payment_targets(uct)
uct.checksum_val = uct.checksum()
finalize_hotel_charge_on_close(cur_pref, uct)
payload = uct.model_dump_json()
cur.execute(f"""
INSERT INTO "{table}"
(ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
uct.ucislo,
uct.id_kas,
uct.stul,
uct.room_name,
"",
uct.closed_at,
uct.c_uzaverka,
payload, ))
return ucet_save_response({
"operation": "insert-storno",
"ucislo": uct.ucislo, }, uct)
def upsert_ucet_db(cur_pref: str, uct: data.Ucet, client_id: str, block: bool):
table = f"{cur_pref}_ucty"
id_kas = uct.id_kas
now = time_hhmmss()
logger.info(f"Upsert_ucet_db")
# --- STORNO = VŽDY INSERT ---
if uct.is_storno and uct.origin !="StorPaymChg":
return insert_storno_ucet(cur_pref, uct, client_id)
# --- UPDATE EXISTUJÍCÍHO ÚČTU PODLE UCISLA (STORNO) ---
if uct.ucislo:
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f"""
SELECT ucty_id, blocked_by, c_uzaverka, data
FROM "{table}"
WHERE ucislo=?
""", (uct.ucislo,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Ucet not found")
ucty_id, blocked_by_db, c_uzaverka_db, data_db = row
if c_uzaverka_db:
uct.c_uzaverka = c_uzaverka_db
if blocked_by_db:
#Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
#Petr 8.5.2026 ^
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={uct.stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {uct.stul} je blokován: {blocked_by_db}"
)
# historický účet povol jen update pole storno
strip_transient_hotel_charge_data(uct)
ensure_ucet_room_name(cur_pref, uct)
payload = uct.model_dump_json()
new_block = f"{client_id}|{now}" if block else ""
cur.execute(f"""
UPDATE "{table}"
SET blocked_by=?, room_name=?, data=?
WHERE ucty_id=?
""", (new_block, uct.room_name, payload, ucty_id))
return ucet_save_response({
"operation": "update-storno",
"ucislo": uct.ucislo, }, uct)
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
# najdi otevřený účet ke stolu
cur.execute(f"""
SELECT ucty_id, blocked_by, data
FROM "{table}"
WHERE stul=? AND id_kas=?
AND (closed_at IS NULL OR TRIM(closed_at)='')
""", (uct.stul,id_kas,))
row = cur.fetchone()
# update
if row:
ucty_id, blocked_by_db, data_db = row
if blocked_by_db:
owner = blocked_by_db.split("|", 1)[0]
#if owner != client_id:
# raise HTTPException(409, f"Ucet blocked by {blocked_by_db}")
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={uct.stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {uct.stul} je blokován: {blocked_by_db}"
)
if uct.closed_at:
uct.ucislo = generate_ucislo(cur, table, uct.id_kas)
ensure_hotel_charge_payment_targets(uct)
uct.checksum_val = uct.checksum()
finalize_hotel_charge_on_close(cur_pref, uct)
else:
uct.ucislo = None
uct.checksum_val = ""
ensure_ucet_room_name(cur_pref, uct)
payload = uct.model_dump_json()
new_block = f"{client_id}|{now}" if block else ""
cur.execute(f"""
UPDATE "{table}"
SET data=?, ucislo=?, closed_at=?, blocked_by=?, c_uzaverka=?, room_name=?
WHERE ucty_id=?
""", (payload, uct.ucislo, uct.closed_at, new_block, uct.c_uzaverka, uct.room_name, ucty_id))
return ucet_save_response({
"operation": "update",
"stul": uct.stul,
"ucislo": uct.ucislo,
"blocked": bool(new_block), }, uct)
# create
if uct.closed_at:
if not uct.ucislo: #pri oprave uzavreneho uctu
uct.ucislo = generate_ucislo(cur, table, uct.id_kas)
ensure_hotel_charge_payment_targets(uct)
uct.checksum_val = uct.checksum()
finalize_hotel_charge_on_close(cur_pref, uct)
else:
uct.ucislo = None
uct.checksum_val = ""
uct.open_at = now
ensure_ucet_room_name(cur_pref, uct)
payload = uct.model_dump_json()
new_block = f"{client_id}|{now}" if block else ""
cur.execute(f"""
INSERT INTO "{table}"
(ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (uct.ucislo, uct.id_kas, uct.stul, uct.room_name, new_block, uct.closed_at, uct.c_uzaverka, payload))
return ucet_save_response({
"operation": "create",
"stul": uct.stul,
"ucislo": uct.ucislo,
"blocked": bool(new_block),
}, uct)
# ---pripoji ucet k existujicimu
@app.post("/ucet/merge/")
def merge_ucet( req: data.MergeUcetRequest, auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, user, client_id = auth
if not (id_kas := req.ucet.id_kas):
raise HTTPException(422, "id_kas in object Ucet must be set")
cleanup_dead_clients(prefix, id_kas)
if not req.target_stul:
raise HTTPException(422, "target_stul must be set")
return merge_ucet_db(
cur_pref=prefix,
id_kas=id_kas,
source=req.ucet,
target_stul=req.target_stul,
client_id=client_id, )
def merge_polozky(target: list, incoming: list):
"""
Přidá položky z incoming do target.
NESLUČUJE, pouze append.
"""
if not incoming:
return
target.extend(incoming)
def merge_ucet_db(
cur_pref: str,
id_kas: str,
source: data.Ucet,
target_stul: str,
client_id: str,
) -> dict:
"""
Atomicky:
- sloučí položky do otevřeného účtu na target_stul
- NEBO vytvoří nový účet, pokud neexistuje
"""
table = f"{cur_pref}_ucty"
now = time_hhmmss()
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
# --- 1️⃣ pokus najít otevřený účet ---
cur.execute(f"""
SELECT ucty_id, blocked_by, data
FROM "{table}"
WHERE stul = ? AND id_kas = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '')
""", (target_stul, id_kas,))
row = cur.fetchone()
# účet EXISTUJE → MERGE
if row:
ucty_id, blocked_by_db, data_db = row
# --- kontrola blokace ---
if blocked_by_db:
# Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
# Petr 8.5. ^
#if owner != client_id:
# raise HTTPException(
# 409,
# f"Cílový účet je blokován jiným terminálem: {blocked_by_db}"
# )
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={target_stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {target_stul} je blokován: {blocked_by_db}"
)
# --- načti cílový účet ---
if not data_db:
raise HTTPException(500, "Cílový účet nemá data")
if isinstance(data_db, str):
target_payload = json.loads(data_db)
elif isinstance(data_db, dict):
target_payload = data_db
else:
raise HTTPException(500, f"Invalid data type: {type(data_db)}")
target_ucet = data.Ucet(**target_payload)
# --- MERGE POLOŽEK ---
merge_polozky(
target_ucet.poloz or [],
source.poloz or [],
)
target_ucet.guests = source.guests
target_ucet.courses = source.courses
ensure_ucet_room_name(cur_pref, target_ucet)
payload = target_ucet.model_dump_json()
new_block = f"{client_id}|{now}"
cur.execute(f"""
UPDATE "{table}"
SET data = ?, room_name = ?, blocked_by = ?
WHERE ucty_id = ?
""", (payload, target_ucet.room_name, new_block, ucty_id))
return {
"operation": "merge",
"target_stul": target_stul,
"created": False,
"merged_items": len(source.poloz or []),
}
# VARIANTA B: účet NEEXISTUJE → CREATE
new_ucet = source.model_copy(deep=True)
new_ucet.stul = target_stul
new_ucet.open_at = now
new_ucet.ucislo = None
new_ucet.checksum_val = ""
ensure_ucet_room_name(cur_pref, new_ucet)
payload = new_ucet.model_dump_json()
new_block = f"{client_id}|{now}"
cur.execute(f"""
INSERT INTO "{table}"
(ucislo, id_kas, stul, room_name, blocked_by, closed_at, data)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
None,
new_ucet.id_kas,
target_stul,
new_ucet.room_name,
new_block,
None,
payload,
))
return {
"operation": "create",
"target_stul": target_stul,
"created": True,
"merged_items": len(source.poloz or []),
}
# ---nacte ucet ze serveru, bud dle stul (otevreny) nebo ucislo (uzavreny)
@app.get("/ucet/")
def get_ucet(
id_kas: str = Query(...),
ucislo: str | None = Query(None),
stul: str | None = Query(None),
block: bool = Query(True), # ✅ DEFAULTNĚ BLOKUJE
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, user, client_id = auth
cleanup_dead_clients(prefix, id_kas)
logger.info(f'Get ucet ')
if (ucislo is None) == (stul is None):
raise HTTPException(
422, "Zadej právě jeden parametr: ucislo NEBO stul" )
return get_ucet_db(
cur_pref=prefix,
id_kas=id_kas,
ucislo=ucislo,
stul=stul,
block=block,
client_id=client_id,
)
def get_ucet_db(
cur_pref: str,
id_kas: str,
ucislo: str | None,
stul: str | None,
block: bool,
client_id: str,
) -> dict:
table = f"{cur_pref}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
# --- 1️⃣ načtení účtu ---
if ucislo is not None:
cur.execute(f"""
SELECT ucty_id, ucislo, blocked_by, c_uzaverka, room_name, data
FROM "{table}"
WHERE ucislo = ?
""", (ucislo,))
else:
cur.execute(f"""
SELECT ucty_id, ucislo, blocked_by, c_uzaverka, room_name, data
FROM "{table}"
WHERE stul = ?
AND id_kas = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '')
""", (stul, id_kas))
row = cur.fetchone()
if not row:
raise HTTPException(
404,
f"Účet nenalezen ({'ucislo' if ucislo else 'stul'})"
)
ucty_id, ucislo_db, blocked_by_db, c_uzaverka_db, room_name_db, data_db = row
if not data_db:
raise HTTPException(500, "Ucet data is empty")
if isinstance(data_db, str): #tohle dost blblo, radeji to tu necham
payload = json.loads(data_db)
payload, changed = migrate_ucet_payload(payload)
if changed:
cur.execute(
f'UPDATE "{table}" SET data=? WHERE ucty_id=?',
(json.dumps(payload), ucty_id)
)
conn.commit()
elif isinstance(data_db, dict):
payload = data_db
payload, changed = migrate_ucet_payload(payload)
if changed:
cur.execute(
f'UPDATE "{table}" SET data=? WHERE ucty_id=?',
(json.dumps(payload), ucty_id)
)
conn.commit()
else:
raise HTTPException(500, f"Invalid data type: {type(data_db)}")
try:
ucet = data.Ucet(**payload)
if not getattr(ucet, "room_name", ""):
ucet.room_name = room_name_db or ""
if not getattr(ucet, "room_name", ""):
ensure_ucet_room_name(cur_pref, ucet)
#ucet.c_uzaverka = c_uzaverka_db
#logger.info(f"get_ucet_db ucet.c_uzaverka {ucet.c_uzaverka}")
except ValidationError as e:
raise HTTPException(500, "Invalid Ucet payload")
# BLOKACE
if block:
if blocked_by_db:
# Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
# Petr 8.5. ^
#if owner != client_id:
# raise HTTPException(
# 409,
# f"Účet je blokován jiným terminálem: {blocked_by_db}"
# )
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}"
)
# blokovaný mnou → OK
ucet.blocked_by = blocked_by_db
else:
# zablokuj účet
ucet.blocked_by = f"{client_id}|{time_hhmmss()}"
cur.execute(
f'UPDATE "{table}" SET blocked_by=? WHERE ucty_id=?',
(ucet.blocked_by, ucty_id),
)
else:
# jen načtení bez blokace
ucet.blocked_by = blocked_by_db or ""
# Petr 11.5.
ucet.id_kas = id_kas
# Petr 11.5.
return ucet.model_dump(mode="json")
# ---vymaze ucet ze serveru dle stolu nebo cisla uctu bez ohledu na blokaci
@app.delete("/ucet/")
def delete_ucet(
ucislo: str | None = Query(None),
stul: str | None = Query(None),
id_kas: str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, user, client_id = auth
cleanup_dead_clients(prefix, id_kas)
logger.info(f'Ucet delete {stul}')
if (ucislo is None) == (stul is None):
raise HTTPException(
422,
"Zadej právě jeden parametr: ucislo NEBO stul" )
result = delete_ucet_db(
cur_pref=prefix,
ucislo=ucislo,
stul=stul,
id_kas = id_kas,
client_id=client_id, )
return {
"operation": "delete",
**result, }
def delete_ucet_db(
cur_pref: str,
ucislo: str | None,
stul: str | None,
id_kas: str,
client_id: str,) -> dict:
table = f"{cur_pref}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
# --- 1️⃣ najdi účet ---
if ucislo is not None:
cur.execute(f"""
SELECT ucty_id, ucislo, blocked_by
FROM "{table}"
WHERE ucislo = ?
""", (ucislo,))
else:
cur.execute(f"""
SELECT ucty_id, ucislo, blocked_by
FROM "{table}"
WHERE stul = ? AND id_kas=?
AND (closed_at IS NULL OR TRIM(closed_at) = '')
""", (stul,id_kas))
row = cur.fetchone()
if not row:
#print(f'stul {stul} id_kas {id_kas}')
raise HTTPException(
404,
f"Účet nenalezen ({'ucislo' if ucislo else 'stul'} )"
)
ucty_id, ucislo_db, blocked_by_db = row
# kontrola blokace ---
if blocked_by_db:
# Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
# Petr 8.5. ^
#if owner != client_id:
# raise HTTPException(
# 409,
# f"Účet je blokován jiným terminálem: {blocked_by_db}"
# )
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}"
)
# smazání ---
cur.execute(
f'DELETE FROM "{table}" WHERE ucty_id = ?',
(ucty_id,)
)
return {
"ucislo": ucislo_db,
"stul": stul,
}
# --- dump cisel uctu
@app.get("/ucty/")
def get_ucty(
id_kas: str = Query(...),
closed: bool = Query(False),
onlynonclsrep: bool = Query(True),
limit: int | None = Query(None, ge=1, le=2000), # 👈 nepovinný limit
auth: tuple[str, str, str] = Depends(auth_ctx),):
prefix, user, client_id = auth
logger.info(f'Ucty dump')
return {
"ucty": load_ucty_for_select(
cur_pref=prefix,
id_kas=id_kas,
closed=closed,
onlynonclsrep=onlynonclsrep,
limit=limit, )}
def load_ucty_for_select(
cur_pref: str,
id_kas: str | None = None,
closed: bool | None = None,
onlynonclsrep: bool | None = None,
limit: int | None = None,) -> list[dict]:
table = f"{cur_pref}_ucty"
out: list[dict] = []
where: list[str] = []
params: list = []
# --- filtr pokladny ---
if id_kas is not None:
where.append("u.id_kas = ?")
params.append(id_kas)
# --- pouze uzavrene ucty bez prirazene uzaverky ---
if onlynonclsrep and closed is True and id_kas is not None:
where.append("(u.c_uzaverka IS NULL OR u.c_uzaverka = 0)")
# --- otevřené / uzavřené ---
if closed is True:
where.append("u.closed_at IS NOT NULL AND TRIM(u.closed_at) <> ''")
elif closed is False:
where.append("(u.closed_at IS NULL OR TRIM(u.closed_at) = '')")
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
# --- řazení + limit ---
if closed is True:
# uzavřené účty podle čísla účtu
print("dle ucislo")
order_sql = "ORDER BY u.ucislo DESC"
if limit is None:
limit = 50
limit_sql = "LIMIT ?"
params.append(limit)
else:
# otevřené účty podle stolu
order_sql = """
ORDER BY
CASE WHEN u.stul IS NULL THEN 1 ELSE 0 END,
u.stul
"""
limit_sql = ""
# --- SQL ---
sql = f"""
SELECT
u.ucty_id,
u.id_kas,
u.c_uzaverka,
u.ucislo,
u.stul,
u.room_name,
u.blocked_by,
u.closed_at,
json_extract(u.data, '$.open_at') AS open_at,
json_extract(u.data, '$.storno') AS storno,
json_extract(u.data, '$.is_storno') AS is_storno,
json_extract(u.data, '$.origin') AS origin,
json_extract(u.data, '$.pohladavka') AS pohladavka,
json_extract(u.data, '$.cash_operation') AS cash_operation,
json_extract(u.data, '$.autor') AS autor,
json_extract(u.data, '$.total_base_currency') AS total_base_currency,
u.data AS raw_data
FROM "{table}" u
{where_sql}
{order_sql}
{limit_sql}
"""
with get_db() as conn:
cur = conn.cursor()
ensure_ucty_room_name_schema(cur_pref, cur)
cur.execute(sql, params)
rows = cur.fetchall()
for (
ucty_id, id_kas, c_uzaverka, ucislo, stul, room_name, blocked_by,
closed_at, open_at, storno, is_storno,
origin, pohladavka, cash_operation, autor, total_base_currency, raw_data
) in rows:
payments_text = ""
status_text = "UCET"
try:
payload = json.loads(raw_data or "{}")
payment_parts = []
for payment in payload.get("platby", []) or []:
name = _strip_value(payment.get("nazev") or payment.get("code") or "")
amount = float(payment.get("suma_czk", payment.get("suma", 0)) or 0)
payment_parts.append(f"{name} {amount:.2f}".strip())
payments_text = ", ".join(payment_parts)
if is_storno:
status_text = "STORNO"
elif storno:
status_text = "STORNOVANY"
elif cash_operation:
status_text = "VKLAD" if str(cash_operation) == "manual_deposit" else "VYBER"
else:
item_status = _receipt_item_storno_status(payload.get("poloz", []) or [])
if item_status:
status_text = item_status
except Exception:
payments_text = ""
status_text = "STORNO" if is_storno else ("STORNOVANY" if storno else "UCET")
out.append({
"ucty_id": ucty_id,
"id_kas": id_kas,
"c_uzaverka": c_uzaverka,
"ucislo": ucislo or "",
"stul": stul,
"room_name": room_name or "",
"open_at": open_at,
"closed_at": closed_at,
"blocked_by": blocked_by or "",
"closed": bool(closed_at and closed_at.strip()),
"storno": storno,
"is_storno": is_storno,
"origin": origin,
"pohladavka": _int_value(pohladavka, 0) or None,
"cash_operation": cash_operation,
"autor": autor,
"total_base_currency": float(total_base_currency or 0.0),
"payments_text": payments_text,
"status_text": status_text,
})
return out
def _receipt_item_storno_status(items: list[dict]) -> str:
has_partial = False
has_closed = False
has_available = False
has_kstornu = False
for item in items:
try:
units = abs(float(item.get("pocet", 0) or 0))
except Exception:
units = 0.0
if units <= 0:
continue
if "kstornu" not in item or item.get("kstornu") is None:
has_available = True
continue
has_kstornu = True
try:
available = max(min(float(item.get("kstornu") or 0), units), 0.0)
except Exception:
available = units
if available > 0:
has_available = True
if available < units:
has_partial = True
if available <= 0:
has_closed = True
if has_partial and has_available:
return "CIAST. STORNO"
if has_kstornu and has_closed and not has_available:
return "VYSTORNOVANY"
return ""
def _parse_usage_date(value: str | None):
text = _strip_value(value)
if not text:
return None
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y%m%d", "%y%m%d"):
try:
return datetime.strptime(text, fmt).date()
except Exception:
pass
return None
def _parse_usage_datetime(value: str | None):
text = _strip_value(value)
if not text:
return None
text = text.replace("T", " ")
for fmt in (
"%y%m%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%d.%m.%Y %H:%M:%S",
"%Y-%m-%d %H:%M",
"%d.%m.%Y %H:%M",
"%y%m%d",
"%Y-%m-%d",
"%d.%m.%Y",
):
try:
return datetime.strptime(text, fmt)
except Exception:
pass
try:
return datetime.fromisoformat(text)
except Exception:
return None
def _usage_interval(mode: str, date_from: str | None, date_to: str | None, days_back: int | None):
mode_l = _strip_value(mode).lower() or "current"
if mode_l == "current":
return mode_l, None, None
today = datetime.now().date()
days = max(0, _int_value(days_back, 0))
start_date = _parse_usage_date(date_from)
end_date = _parse_usage_date(date_to)
if days and not start_date and not end_date:
start_date = today - timedelta(days=max(days - 1, 0))
end_date = today
if not start_date:
start_date = today
if not end_date:
end_date = today
if start_date > end_date:
start_date, end_date = end_date, start_date
start_dt = datetime.combine(start_date, datetime.min.time())
end_dt = datetime.combine(end_date, datetime.max.time())
return "period", start_dt, end_dt
def _usage_category_key(item: dict) -> tuple[str, int, str, str]:
c_druh = _int_value(item.get("c_druh"), 0)
druh = _strip_value(item.get("druh"))
spart = _strip_value(item.get("spart"))
name = druh or spart or "Bez druhu"
return f"{c_druh}|{name}|{spart}", c_druh, name, spart
def _compute_usage_report_db(
prefix: str,
id_kas: str,
mode: str = "current",
date_from: str | None = None,
date_to: str | None = None,
days_back: int | None = None,
) -> data.UsageReportOut:
mode_l, start_dt, end_dt = _usage_interval(mode, date_from, date_to, days_back)
table = f"{prefix}_ucty"
where = [
"u.id_kas = ?",
"u.closed_at IS NOT NULL",
"TRIM(u.closed_at) <> ''",
"TRIM(COALESCE(u.ucislo, '')) <> ''",
]
params: list[Any] = [id_kas]
if mode_l == "current":
where.append("(u.c_uzaverka IS NULL OR u.c_uzaverka = 0)")
sql = f"""
SELECT u.ucislo, u.closed_at, u.c_uzaverka, u.data
FROM "{table}" u
WHERE {' AND '.join(where)}
ORDER BY u.ucislo
"""
categories: dict[str, dict] = {}
total_quantity = 0.0
total_amount = 0.0
ucislo_from = ""
ucislo_to = ""
closed_at_from = ""
closed_at_to = ""
with get_db() as conn:
cur = conn.cursor()
ensure_ucty_room_name_schema(prefix, cur)
cur.execute(sql, params)
rows = cur.fetchall()
for ucislo, closed_at, c_uzaverka, raw_data in rows:
closed_dt = _parse_usage_datetime(closed_at)
if mode_l != "current":
if not closed_dt or (start_dt and closed_dt < start_dt) or (end_dt and closed_dt > end_dt):
continue
try:
payload = json.loads(raw_data or "{}")
except Exception:
logger.exception("Usage report skipped invalid receipt json: %s", ucislo)
continue
if _strip_value(payload.get("cash_operation")):
continue
is_storno = bool(_strip_value(payload.get("is_storno")))
receipt_has_items = False
for item in payload.get("poloz", []) or []:
if not isinstance(item, dict):
continue
if _int_value(item.get("typ_menu"), 0) == 1:
continue
item_name = _strip_value(item.get("nazev") or item.get("name"))
if not item_name:
continue
delitel = _int_value(item.get("delitel"), 1) or 1
qty = _float_value(item.get("pocet"), 0.0) / delitel
if abs(qty) <= 0.000001:
continue
price = _float_value(item.get("cena"), 0.0)
amount = qty * price
if is_storno and qty > 0:
qty = -qty
amount = -amount
key, c_druh, category_name, spart = _usage_category_key(item)
category = categories.setdefault(key, {
"key": key,
"name": category_name,
"c_druh": c_druh,
"spart": spart,
"quantity": 0.0,
"amount": 0.0,
"items": {},
})
item_key = f"{_int_value(item.get('id_card'), 0)}|{item_name}"
entry = category["items"].setdefault(item_key, {
"id_card": _int_value(item.get("id_card"), 0),
"name": item_name,
"c_druh": c_druh,
"druh": category_name,
"spart": spart,
"quantity": 0.0,
"amount": 0.0,
"receipts": set(),
})
category["quantity"] += qty
category["amount"] += amount
entry["quantity"] += qty
entry["amount"] += amount
entry["receipts"].add(_strip_value(ucislo))
total_quantity += qty
total_amount += amount
receipt_has_items = True
if receipt_has_items:
ucislo_text = _strip_value(ucislo)
if not ucislo_from:
ucislo_from = ucislo_text
ucislo_to = ucislo_text
if not closed_at_from:
closed_at_from = _strip_value(closed_at)
closed_at_to = _strip_value(closed_at)
out_categories: list[data.UsageCategory] = []
for category in categories.values():
items = []
for item in category["items"].values():
receipts = item.pop("receipts", set())
item["receipts"] = len(receipts)
item["quantity"] = round(item["quantity"], 4)
item["amount"] = round(item["amount"], 2)
items.append(data.UsageItem.model_validate(item))
items.sort(key=lambda x: (x.name or "").lower())
out_categories.append(data.UsageCategory(
key=category["key"],
name=category["name"],
c_druh=category["c_druh"],
spart=category["spart"],
quantity=round(category["quantity"], 4),
amount=round(category["amount"], 2),
items=items,
))
out_categories.sort(key=lambda x: (x.name or "").lower())
return data.UsageReportOut(
id_kas=id_kas,
mode=mode_l,
date_from=start_dt.date().isoformat() if start_dt else "",
date_to=end_dt.date().isoformat() if end_dt else "",
ucislo_from=ucislo_from,
ucislo_to=ucislo_to,
closed_at_from=closed_at_from,
closed_at_to=closed_at_to,
categories=out_categories,
total_quantity=round(total_quantity, 4),
total_amount=round(total_amount, 2),
)
@app.get("/usage/report/", response_model=data.UsageReportOut)
def get_usage_report(
id_kas: str = Query(...),
mode: str = Query("current"),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
days_back: int | None = Query(0, ge=0, le=3650),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(
"GET usage report: prefix=%s id_kas=%s mode=%s from=%s to=%s days=%s",
prefix,
id_kas,
mode,
date_from,
date_to,
days_back,
)
return _compute_usage_report_db(prefix, id_kas, mode, date_from, date_to, days_back)
# --- manualni blokovani a odblokovni uctu
@app.post("/ucet/block/")
def block_ucet_by_stul(
stul: str = Query(...), id_kas: str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
cleanup_dead_clients(prefix, id_kas)
logger.info(f'Ucet block {stul}')
return db_block_ucet_by_stul(prefix, stul, id_kas, client_id)
def db_block_ucet_by_stul(cur_pref: str, stul: str, id_kas, client_id: str):
table = f"{cur_pref}_ucty"
now = time_hhmmss()
with get_db() as conn:
cur = conn.cursor()
# 🔎 najdi OTEVŘENÝ účet ke stolu
cur.execute(f"""
SELECT ucty_id, blocked_by
FROM "{table}"
WHERE stul=? AND id_kas=?
AND (closed_at IS NULL OR TRIM(closed_at)='')
""", (stul,id_kas))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Otevřený účet ke stolu neexistuje")
ucty_id, blocked_by = row
if blocked_by and not blocked_by.startswith(client_id):
raise HTTPException(409, f"Účet blokován {blocked_by}")
cur.execute(f"""
UPDATE "{table}"
SET blocked_by=?
WHERE ucty_id=?
""", (f"{client_id}|{now}", ucty_id))
return {"status": "blocked", "stul": stul, "id_kas": id_kas}
from fastapi import Query, Depends, HTTPException
@app.post("/ucet/unblock/")
def unblock_ucet(
stul: str | None = Query(None),
ucislo: str | None = Query(None),
id_kas: str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
logger.info(f'Ucet unblock stul={stul}, ucislo={ucislo}')
cleanup_dead_clients(prefix, id_kas)
# musí být zadán právě jeden parametr
if not stul and not ucislo:
raise HTTPException(status_code=400, detail="Musí být zadán stul nebo ucislo")
if stul and ucislo:
raise HTTPException(status_code=400, detail="Nelze zadat současně stul i ucislo")
if stul:
return db_unblock_ucet_by_stul(prefix, stul, id_kas, client_id)
if ucislo:
print(ucislo)
return db_unblock_ucet_by_ucislo(prefix, ucislo, id_kas, client_id)
def db_unblock_ucet_by_stul(cur_pref: str, stul: str, id_kas: str, client_id: str):
table = f"{cur_pref}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute(f"""
SELECT ucty_id, blocked_by
FROM "{table}"
WHERE stul=? AND id_kas=?
AND (closed_at IS NULL OR TRIM(closed_at)='')
""", (stul,id_kas))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Otevřený účet ke stolu neexistuje")
ucty_id, blocked_by_db = row
# smí odblokovat jen vlastník blokace
if blocked_by_db:
# Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
# Petr 8.5. ^
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}"
)
cur.execute(f"""
UPDATE "{table}"
SET blocked_by=''
WHERE ucty_id=?
""", (ucty_id,))
return {"status": "unblocked", "stul": stul}
def db_unblock_ucet_by_ucislo(cur_pref: str, ucislo: str, id_kas: str, client_id: str):
table = f"{cur_pref}_ucty"
with get_db() as conn:
cur = conn.cursor()
cur.execute(f"""
SELECT ucty_id, blocked_by
FROM "{table}"
WHERE ucislo=? AND id_kas=?
AND closed_at IS NOT NULL AND TRIM(closed_at) != ''
""", (ucislo, id_kas))
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Otevřený účet č. {ucislo} neexistuje")
ucty_id, blocked_by_db = row
# smí odblokovat jen vlastník blokace
if blocked_by_db:
# Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
# Petr 8.5. ^
if owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (ucislo={ucislo}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet č. {ucislo} je blokován: {blocked_by_db}"
)
cur.execute(f"""
UPDATE "{table}"
SET blocked_by=''
WHERE ucty_id=?
""", (ucty_id,))
return {"status": "unblocked", "ucislo": ucislo}
# --- otevře účet ke stolu + atomicky ho zablokuje (nebo vytvoří)
@app.post("/ucet/open/")
def open_block_create_ucet(
stul: str = Query(...),
id_kas: str = Query(...),
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
cleanup_dead_clients(prefix, id_kas)
return open_block_create_ucet_db(
cur_pref=prefix,
stul=stul,
id_kas=id_kas,
client_id=client_id,
)
def open_block_create_ucet_db(
cur_pref: str,
stul: str,
id_kas: str,
client_id: str,
) -> dict:
"""
Atomicky:
- najde OTEVŘENÝ účet ke stolu (closed_at prázdné, ucislo NULL)
- pokud existuje:
- je-li blokovaný jiným → 409
- jinak ho zablokuje
- pokud neexistuje:
- vytvoří PRÁZDNÝ otevřený účet
- rovnou ho zablokuje
"""
table = f"{cur_pref}_ucty"
now = time_hhmmss()
block_val = f"{client_id}|{now}"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_ucty_room_name_schema(cur_pref, cur)
# --- najdi OTEVŘENÝ účet ke stolu ---
cur.execute(f"""
SELECT ucty_id, blocked_by
FROM "{table}"
WHERE stul = ?
AND id_kas = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '')
AND ucislo IS NULL
LIMIT 1
""", (stul, id_kas))
row = cur.fetchone()
# otevřený účet EXISTUJE
if row:
ucty_id, blocked_by_db = row
# Petr 8.5.
owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else ""
# Petr 8.5. ^
if owner and owner != client_id:
# kontrola expirace blocku
if is_block_expired(cur, cur_pref, id_kas, blocked_by_db):
logger.warning(
f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})"
)
# uvolni expirovaný block
cur.execute(
f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?',
(ucty_id,),
)
else:
raise HTTPException(
409,
f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}"
)
# zablokuj existující otevřený účet
cur.execute(
f'UPDATE "{table}" SET blocked_by=? WHERE ucty_id=?',
(block_val, ucty_id),
)
return {
"operation": "open-existing",
"stul": stul,
"created": False,
"blocked": True,
}
# otevřený účet NEEXISTUJE → CREATE
empty_ucet = data.Ucet(
id_kas=id_kas,
stul=stul,
poloz=[],
open_at=now,
closed_at=None,
ucislo=None,
blocked_by=block_val,
guests = [{"id": "g1", "name": "Hosť 1"}],
courses = [{"id": "c1", "name": "Chod 1"}]
)
ensure_ucet_room_name(cur_pref, empty_ucet)
payload = empty_ucet.model_dump_json()
cur.execute(
f"""
INSERT INTO "{table}"
(ucislo, id_kas, stul, room_name, blocked_by, closed_at, data)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
None, # ucislo
id_kas,
stul,
empty_ucet.room_name,
block_val,
None, # closed_at
payload,
),
)
return {
"operation": "create",
"stul": stul,
"created": True,
"blocked": True,
}
# -----------------------------------------------------
# Uzaverka
# -----------------------------------------------------
def ensure_closure_runtime_schema(prefix: str, cur) -> None:
init_tab_closerep(prefix=prefix, cur=cur)
init_platby_schema(prefix=prefix, cur=cur)
init_closure_cash_state_schema(prefix=prefix, cur=cur)
init_closure_transfer_outbox_schema(prefix=prefix, cur=cur)
CLOSURE_REPORT_FLAG_NAMES = [
"t_uz_ucet",
"t_uz_trzdr",
"t_uz_harek",
"t_uz_dph",
"t_uz_man",
"t_uz_cenhl",
"t_uz_man_dph",
"t_uz_puctu",
"t_uz_stzur",
"t_uz_casni",
"t_uz_cshot",
"t_uz_spdph",
"t_uz_poh_drpl",
"t_uz_vkl_drpl",
"t_uz_trz_vkl_drpl",
"t_uz_vklad_vyber",
"t_uz_drpl",
"t_uz_odovzdanie",
"t_uz_fisk_platby",
"t_uz_drpldan",
"t_uz_drplfisdan",
"t_uz_dph_fis",
"t_uz_terminal",
"t_uz_mena",
"t_uz_stoly",
"t_uz_puctu_cas",
]
def closure_report_settings(setup_params: dict) -> dict:
settings = {
"men_sp_man": _strip_value(setup_params.get("men_sp_man")),
"uzav_odvod": _strip_value(setup_params.get("uzav_odvod")),
"is_fiskal": _bool_value(setup_params.get("is_fiskal"), False),
}
settings["flags"] = {
name: _bool_value(setup_params.get(name), False)
for name in CLOSURE_REPORT_FLAG_NAMES
}
return settings
def get_ucty_notinclsrep_DB(
cur,
table_ucty: str,
table_clsrep: str,
id_kas: str,
):
sql = f"""
SELECT data, c_uzaverka
FROM "{table_ucty}"
WHERE id_kas = ?
AND closed_at IS NOT NULL
AND TRIM(COALESCE(ucislo, '')) != ''
AND (c_uzaverka IS NULL OR c_uzaverka = 0)
ORDER BY ucislo
"""
cur.execute(sql, (id_kas,))
rows = cur.fetchall()
result = []
for raw_json, c_uzaverka in rows:
payload = json.loads(raw_json)
payload["c_uzaverka"] = c_uzaverka
result.append(payload)
return result
@app.get("/ucty/notinclsrep/", response_model=list[data.Ucet])
def get_ucty_notinclsrep(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
table_ucty = f"{prefix}_ucty"
table_clsrep = f"{prefix}_clsrep"
logger.info(f"Ucty notinclsrep kas={id_kas} user={user}")
with get_db() as conn:
cur = conn.cursor()
ensure_closure_runtime_schema(prefix, cur)
rows = get_ucty_notinclsrep_DB(
cur=cur,
table_ucty=table_ucty,
table_clsrep=table_clsrep,
id_kas=id_kas,
)
return rows
@app.get("/closure/detail/", response_model=data.ClosureDetailOut)
def get_closure_detail(
clsrep_no: str,
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
table_clsrep = f"{prefix}_clsrep"
table_ucty = f"{prefix}_ucty"
logger.info(f"Closure detail clsrep={clsrep_no} kas={id_kas} user={user}")
with get_db() as conn:
cur = conn.cursor()
ensure_closure_runtime_schema(prefix, cur)
result = get_closure_detail_DB(
cur=cur,
table_clsrep=table_clsrep,
table_ucty=table_ucty,
clsrep_no=clsrep_no,
id_kas=id_kas,
)
if not result:
raise HTTPException(404, "Uzávěrka nenalezena")
return result
def get_closure_detail_DB(
cur,
table_clsrep: str,
table_ucty: str,
clsrep_no: str,
id_kas: str,
):
sql = f"""
SELECT clsrep_id, data, ucislo_st, ucislo_end, men_sp_man, uzav_odvod, closure_warnings
FROM "{table_clsrep}"
WHERE clsrep_no = ? AND id_kas = ?
"""
cur.execute(sql, (clsrep_no, id_kas))
row = cur.fetchone()
if not row:
return None
clsrep_id = row[0]
clsrep_data = json.loads(row[1])
ucislo_od = row[2]
ucislo_do = row[3]
men_sp_man = row[4] if len(row) > 4 else ""
uzav_odvod = row[5] if len(row) > 5 else ""
closure_warnings = []
if len(row) > 6 and row[6]:
try:
closure_warnings = json.loads(row[6])
except Exception:
closure_warnings = []
sql = f"""
SELECT data, c_uzaverka
FROM "{table_ucty}"
WHERE id_kas = ?
AND c_uzaverka = ?
ORDER BY ucislo
"""
cur.execute(sql, (id_kas, clsrep_id))
rows = cur.fetchall()
if not rows:
sql = f"""
SELECT data, c_uzaverka
FROM "{table_ucty}"
WHERE id_kas = ?
AND ucislo BETWEEN ? AND ?
ORDER BY ucislo
"""
cur.execute(sql, (id_kas, ucislo_od, ucislo_do))
rows = cur.fetchall()
ucty = []
for raw_json, c_uzaverka in rows:
payload = json.loads(raw_json)
payload["c_uzaverka"] = c_uzaverka
ucty.append(payload)
return {
"clsrep": {
"clsrep_id": clsrep_id,
"clsrep_no": clsrep_no,
"ucislo_od": ucislo_od,
"ucislo_do": ucislo_do,
"men_sp_man": men_sp_man,
"uzav_odvod": uzav_odvod,
"closure_warnings": closure_warnings,
},
"data": clsrep_data,
"ucty": ucty,
}
@app.get("/closure/list/", response_model=list[data.ClosureIntervalOut])
def get_closure_list(
id_kas: str,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, user, client_id = auth
table_clsrep = f"{prefix}_clsrep"
logger.info(f"Closure list request kas={id_kas} user={user}")
with get_db() as conn:
cur = conn.cursor()
ensure_closure_runtime_schema(prefix, cur)
rows = get_closure_intervals(
cur=cur,
table_clsrep=table_clsrep,
id_kas=id_kas,
)
return rows
def get_closure_intervals(cur, table_clsrep: str, id_kas: str):
sql = f"""
SELECT
clsrep_no,
ucislo_st,
ucislo_end,
dta_from,
dta_to
FROM "{table_clsrep}"
WHERE id_kas = ?
ORDER BY clsrep_id DESC
"""
cur.execute(sql, (id_kas,))
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"clsrep_no": r[0],
"ucislo_od": r[1],
"ucislo_do": r[2],
"closed_at_od": r[3],
"closed_at_do": r[4],
})
return result
def _closure_cash_row_for_db(row: dict[str, Any]) -> dict[str, Any]:
return {
"prn_no": _strip_value(row.get("prn_no")),
"payment_code": _strip_value(row.get("payment_code")),
"payment_name": _strip_value(row.get("payment_name")),
"opening_amount": _float_value(row.get("opening_amount"), 0.0),
"sales_amount": _float_value(row.get("sales_amount"), 0.0),
"receivable_amount": _float_value(row.get("receivable_amount"), 0.0),
"manual_deposit_amount": _float_value(row.get("manual_deposit_amount"), 0.0),
"manual_withdrawal_amount": _float_value(row.get("manual_withdrawal_amount"), 0.0),
"auto_deposit_amount": _float_value(row.get("auto_deposit_amount"), 0.0),
"auto_withdrawal_amount": _float_value(row.get("auto_withdrawal_amount"), 0.0),
"carry_amount": _float_value(row.get("carry_amount"), 0.0),
"generated_ucislo": _strip_value(row.get("generated_ucislo")),
"fiscal_result": json.dumps(row.get("fiscal_result") or {}, ensure_ascii=False),
"status": _strip_value(row.get("status") or "pending"),
"error": _strip_value(row.get("error")),
}
def persist_closure_cash_state(prefix: str, clsrep_id: int, id_kas: str, rows: list[dict[str, Any]]) -> None:
if not rows:
return
table = f"{prefix}_closure_cash_state"
with get_db() as conn:
cur = conn.cursor()
init_closure_cash_state_schema(prefix, cur)
for row in rows:
db_row = _closure_cash_row_for_db(row)
cur.execute(f"""
INSERT INTO "{table}" (
clsrep_id, id_kas, prn_no, payment_code, payment_name,
opening_amount, sales_amount, receivable_amount,
manual_deposit_amount, manual_withdrawal_amount,
auto_deposit_amount, auto_withdrawal_amount, carry_amount, generated_ucislo,
fiscal_result, status, error, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(clsrep_id, id_kas, prn_no, payment_code) DO UPDATE SET
payment_name=excluded.payment_name,
opening_amount=excluded.opening_amount,
sales_amount=excluded.sales_amount,
receivable_amount=excluded.receivable_amount,
manual_deposit_amount=excluded.manual_deposit_amount,
manual_withdrawal_amount=excluded.manual_withdrawal_amount,
auto_deposit_amount=excluded.auto_deposit_amount,
auto_withdrawal_amount=excluded.auto_withdrawal_amount,
carry_amount=excluded.carry_amount,
generated_ucislo=excluded.generated_ucislo,
fiscal_result=excluded.fiscal_result,
status=excluded.status,
error=excluded.error,
updated_at=CURRENT_TIMESTAMP
""", (
clsrep_id,
id_kas,
db_row["prn_no"],
db_row["payment_code"],
db_row["payment_name"],
db_row["opening_amount"],
db_row["sales_amount"],
db_row["receivable_amount"],
db_row["manual_deposit_amount"],
db_row["manual_withdrawal_amount"],
db_row["auto_deposit_amount"],
db_row["auto_withdrawal_amount"],
db_row["carry_amount"],
db_row["generated_ucislo"],
db_row["fiscal_result"],
db_row["status"],
db_row["error"],
))
conn.commit()
def _update_closure_report_payload(prefix: str, clsrep_id: int, report: data.ClosureReportOut) -> None:
table = f"{prefix}_clsrep"
with get_db() as conn:
cur = conn.cursor()
cur.execute(f"""
UPDATE "{table}"
SET data=?,
closure_warnings=?
WHERE clsrep_id=?
""", (
json.dumps(report.model_dump(), ensure_ascii=False),
json.dumps(report.warnings or [], ensure_ascii=False),
clsrep_id,
))
conn.commit()
def _load_closure_transfer_rows_db(
prefix: str,
id_kas: str,
clsrep_id: int | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
table = f"{prefix}_closure_transfer_outbox"
where = ["id_kas=?"]
params: list[Any] = [id_kas]
if clsrep_id is not None:
where.append("clsrep_id=?")
params.append(clsrep_id)
if status:
where.append("status=?")
params.append(status)
with get_db() as conn:
cur = conn.cursor()
init_closure_transfer_outbox_schema(prefix, cur)
cur.execute(f"""
SELECT id, clsrep_id, id_kas, target_type, reception_id, reception_name,
typ_hotel, payload, response, status, attempts, last_error,
created_at, updated_at, sent_at
FROM "{table}"
WHERE {" AND ".join(where)}
ORDER BY clsrep_id DESC, id DESC
""", params)
rows = cur.fetchall()
def load_json_cell(value: str | None) -> dict:
try:
loaded = json.loads(value or "{}")
return loaded if isinstance(loaded, dict) else {}
except Exception:
return {}
return [
{
"id": row[0],
"clsrep_id": row[1],
"id_kas": row[2],
"target_type": row[3],
"reception_id": row[4],
"reception_name": row[5],
"typ_hotel": row[6],
"payload": load_json_cell(row[7]),
"response": load_json_cell(row[8]),
"status": row[9],
"attempts": row[10],
"last_error": row[11],
"created_at": row[12],
"updated_at": row[13],
"sent_at": row[14],
}
for row in rows
]
def _insert_closure_transfer_outbox(
prefix: str,
clsrep_id: int,
id_kas: str,
reception: data.Recepcia,
payload: dict,
) -> int:
table = f"{prefix}_closure_transfer_outbox"
with get_db() as conn:
cur = conn.cursor()
init_closure_transfer_outbox_schema(prefix, cur)
cur.execute(f"""
INSERT INTO "{table}"
(clsrep_id, id_kas, target_type, reception_id, reception_name,
typ_hotel, payload, response, status, attempts, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
clsrep_id,
id_kas,
"hotel_closure",
int(getattr(reception, "id", 0) or 0),
_strip_value(getattr(reception, "hotel", "")),
int(getattr(reception, "typ_hotel", 0) or 0),
json.dumps(payload, ensure_ascii=False),
"{}",
"pending",
0,
"",
))
transfer_id = int(cur.lastrowid)
conn.commit()
return transfer_id
def _update_closure_transfer_outbox(
prefix: str,
transfer_id: int,
status: str,
response: dict | None = None,
error: str = "",
) -> None:
table = f"{prefix}_closure_transfer_outbox"
sent_sql = ", sent_at=CURRENT_TIMESTAMP" if status == "sent" else ""
with get_db() as conn:
cur = conn.cursor()
init_closure_transfer_outbox_schema(prefix, cur)
cur.execute(f"""
UPDATE "{table}"
SET status=?,
response=?,
attempts=attempts+1,
last_error=?,
updated_at=CURRENT_TIMESTAMP
{sent_sql}
WHERE id=?
""", (
status,
json.dumps(response or {}, ensure_ascii=False),
(error or "")[:1000],
transfer_id,
))
conn.commit()
def _closure_cash_register_code(prefix: str, id_kas: str) -> str:
table = f"{prefix}_fooddat"
candidates = {_strip_value(id_kas)}
try:
candidates.add(str(int(float(id_kas))))
candidates.add(str(int(float(id_kas))).zfill(2))
except Exception:
pass
with get_db() as conn:
cur = conn.cursor()
try:
cur.execute(f'SELECT id, id_zkratka FROM "{table}"')
except sqlite3.Error:
return _strip_value(id_kas)
for raw_id, raw_name in cur.fetchall():
if _strip_value(raw_id) in candidates and _strip_value(raw_name):
return _strip_value(raw_name)
return _strip_value(id_kas)
def _closure_transfer_reception(
prefix: str,
setup_params: dict,
) -> tuple[data.Recepcia | None, str]:
receptions = load_receptions_from_db(prefix)
if not receptions:
return None, "Uzavierkovy prenos do recepcie je povoleny, ale nie je nastavena ziadna recepcia."
wanted_id = _int_value(setup_params.get("id_prenh"), 0)
if wanted_id:
for reception in receptions:
if int(getattr(reception, "id", 0) or 0) == wanted_id:
return reception, ""
return None, f"Recepcia id_prenh={wanted_id} nebola najdena."
if len(receptions) == 1:
return receptions[0], ""
return None, "Pre uzavierkovy prenos je nastavenych viac recepcii; vypln parameter id_prenh."
def _load_hotplatby_map(prefix: str, reception_id: int) -> dict[str, dict[str, Any]]:
table = f"{prefix}_hotplatby"
with get_db() as conn:
cur = conn.cursor()
try:
cur.execute(f"""
SELECT id_hotel, druh_pl, hot_platba_id, hot_karta_id,
hot_platba, hot_karta, po_uctoch, payment, id_meny
FROM "{table}"
WHERE id_hotel = ? OR id_hotel = 0
ORDER BY CASE WHEN id_hotel = ? THEN 0 ELSE 1 END, druh_pl
""", (reception_id, reception_id))
except sqlite3.Error:
return {}
rows = cur.fetchall()
result: dict[str, dict[str, Any]] = {}
for row in rows:
item = {
"id_hotel": row[0],
"druh_pl": _strip_value(row[1]),
"hot_platba_id": _int_value(row[2], 0),
"hot_karta_id": _int_value(row[3], 0),
"hot_platba": _strip_value(row[4]),
"hot_karta": _strip_value(row[5]),
"po_uctoch": _int_value(row[6], 0),
"payment": _strip_value(row[7]),
"id_meny": _int_value(row[8], 0),
}
for key in (item["druh_pl"], item["hot_platba"], item["payment"]):
norm = key.strip().lower()
if norm and norm not in result:
result[norm] = item
return result
def _closure_payment_amount(payment: data.Platba) -> float:
amount = _float_value(getattr(payment, "suma_czk", 0), 0.0)
if abs(amount) < 0.0001:
amount = _float_value(getattr(payment, "suma", 0), 0.0) * (_float_value(getattr(payment, "rate", 1), 1.0) or 1.0)
return round(amount, 2)
def _closure_payment_tip(payment: data.Platba) -> float:
tip = _float_value(getattr(payment, "tip", 0), 0.0)
rate = _float_value(getattr(payment, "rate", 1), 1.0) or 1.0
return round(tip * rate, 2)
def _closure_payment_mapping(
payment: data.Platba,
hotplatby: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
candidates = [
_strip_value(getattr(payment, "code", "")),
_strip_value(getattr(payment, "nazev", "")),
]
for candidate in candidates:
mapping = hotplatby.get(candidate.lower())
if mapping:
return mapping
return None
def _closure_item_qty(pol: data.UcPol) -> float:
return _float_value(getattr(pol, "pocet", 0), 0.0) / max(_int_value(getattr(pol, "delitel", 1), 1), 1)
def _closure_item_amount(pol: data.UcPol) -> float:
return round(_float_value(getattr(pol, "cena", 0), 0.0) * _closure_item_qty(pol), 4)
def _closure_vat_amount(rate_value: str, gross_amount: float) -> float:
rate_text = _strip_value(rate_value).replace(",", ".")
try:
rate = float(rate_text)
except Exception:
return 0.0
if rate == -1:
return 0.0
if rate > 2.0:
return round(gross_amount * rate / (100.0 + rate), 4)
if rate > 1.0:
return round(gross_amount * (rate - 1.0) / rate, 4)
if rate > 0.0:
return round(gross_amount * rate / (1.0 + rate), 4)
return 0.0
def _closure_raster_id(
raster_rows: list[dict],
c_druh: int,
tmatr: str,
budova: str,
typ_hotel: int,
price_level: str,
) -> str:
raster_id = select_raster_id(raster_rows, c_druh, tmatr, budova, typ_hotel, price_level)
if raster_id:
return raster_id
if c_druh not in {-100, -200}:
raster_id = select_raster_id(raster_rows, -100, tmatr, budova, typ_hotel, price_level)
if not raster_id and c_druh == -200:
raster_id = select_raster_id(raster_rows, -100, tmatr, budova, typ_hotel, price_level)
return raster_id
def _add_closure_transfer_item(
grouped: dict[tuple, dict[str, Any]],
*,
receipt_number: str,
note: str,
mapping: dict[str, Any],
raster_id: str,
amount: float,
vat_amount: float,
currency: str,
dph: str,
c_druh: int,
):
amount = round(amount, 4)
vat_amount = round(vat_amount, 4)
if abs(amount) < 0.0001 and abs(vat_amount) < 0.0001:
return
key = (
receipt_number,
raster_id,
mapping.get("hot_platba_id", 0),
mapping.get("hot_karta_id", 0),
mapping.get("id_meny", 0),
currency,
dph,
c_druh,
)
if key not in grouped:
grouped[key] = {
"receipt_number": receipt_number,
"note": note,
"raster_id": raster_id,
"payment_method_id": mapping.get("hot_platba_id", 0),
"credit_card_type_id": mapping.get("hot_karta_id", 0),
"currency_id": mapping.get("id_meny", 0),
"currency": currency,
"dph": dph,
"c_druh": c_druh,
"amount": 0.0,
"amount_currency": 0.0,
"vat_amount": 0.0,
"credit_card_number": "",
"exchange_rate": "",
}
grouped[key]["amount"] = round(grouped[key]["amount"] + amount, 4)
grouped[key]["amount_currency"] = round(grouped[key]["amount_currency"] + amount, 4)
grouped[key]["vat_amount"] = round(grouped[key]["vat_amount"] + vat_amount, 4)
def _build_closure_reception_payload(
prefix: str,
id_kas: str,
clsrep_id: int,
report: data.ClosureReportOut,
receipts: list[data.Ucet],
reception: data.Recepcia,
setup_params: dict,
) -> tuple[dict | None, list[str]]:
reception_id = int(getattr(reception, "id", 0) or 0)
typ_hotel = int(getattr(reception, "typ_hotel", 0) or 0)
logger.info(f"Recepcia typ_hotel: {typ_hotel}")
hotplatby = _load_hotplatby_map(prefix, reception_id)
logger.info(f"Recepcia hotplatby: {hotplatby}")
warnings: list[str] = []
if not hotplatby:
return None, [f"Pre recepciu {getattr(reception, 'hotel', '')} nie su nastavene hotplatby."]
raster_rows = load_hotel_raster_rows(prefix, id_kas, typ_hotel, reception_id)
logger.info(f"Recepcia raster_rows: {raster_rows}")
if not raster_rows:
return None, [f"Pre recepciu {getattr(reception, 'hotel', '')} nie su nastavene hotelove rastre."]
use_time_attr = _bool_value(setup_params.get("is_uzprenhtimeatr"), False)
grouped: dict[tuple, dict[str, Any]] = {}
missing_payments: set[str] = set()
missing_rasters: set[str] = set()
note = f"UZ{id_kas}/{report.clsrep_no or clsrep_id}"
for ucet in receipts:
if _strip_value(getattr(ucet, "cash_operation", "")):
continue
if _int_value(getattr(ucet, "pohladavka", 0), 0) == 1:
continue
payments = list(getattr(ucet, "platby", []) or [])
if not payments:
continue
mapped_payments = []
for payment in payments:
mapping = _closure_payment_mapping(payment, hotplatby)
logger.info(f"Recepcia mapping: {mapping}")
if not mapping:
payment_name = _strip_value(getattr(payment, "code", "")) or _strip_value(getattr(payment, "nazev", ""))
if payment_name:
missing_payments.add(payment_name)
continue
amount = _closure_payment_amount(payment)
mapped_payments.append((payment, mapping, amount))
if not mapped_payments:
continue
payment_total = round(sum(amount for _, _, amount in mapped_payments), 4)
if abs(payment_total) < 0.0001:
continue
receipt_currency = ""
for pol in getattr(ucet, "poloz", []) or []:
if not receipt_currency:
receipt_currency = _strip_value(getattr(pol, "mena", "")) or "EUR"
receipt_currency = receipt_currency or "EUR"
for payment, mapping, payment_amount in mapped_payments:
share = payment_amount / payment_total if abs(payment_total) >= 0.0001 else 0.0
receipt_number = _strip_value(getattr(ucet, "ucislo", "")) if _int_value(mapping.get("po_uctoch"), 0) == 1 else "0"
target = getattr(payment, "hotel_charge", None) or getattr(ucet, "hotel_charge", None)
tmatr = ""
budova = ""
if target and use_time_attr:
tmatr = _strip_value(getattr(target, "time_attribute", ""))
budova = _strip_value(getattr(target, "building", ""))
currency = _strip_value(getattr(payment, "unit", "")) or receipt_currency
for pol in getattr(ucet, "poloz", []) or []:
amount = round(_closure_item_amount(pol) * share, 4)
if abs(amount) < 0.0001:
continue
c_druh = _int_value(getattr(pol, "c_druh", 0), 0)
price_level = _strip_value(getattr(pol, "cenhlad", "")) or "1"
raster_id = _closure_raster_id(raster_rows, c_druh, tmatr, budova, typ_hotel, price_level)
if not raster_id:
missing_rasters.add(f"{getattr(pol, 'nazev', '') or getattr(pol, 'id_card', '')} (c_druh={c_druh}, hladina={price_level})")
continue
dph = _strip_value(getattr(pol, "dph", ""))
_add_closure_transfer_item(
grouped,
receipt_number=receipt_number,
note=note,
mapping=mapping,
raster_id=raster_id,
amount=amount,
vat_amount=_closure_vat_amount(dph, amount),
currency=currency,
dph=dph,
c_druh=c_druh,
)
tip = _closure_payment_tip(payment)
if abs(tip) >= 0.005:
raster_id = _closure_raster_id(raster_rows, -1, tmatr, budova, typ_hotel, "1")
if not raster_id:
missing_rasters.add("TIP (c_druh=-1)")
else:
_add_closure_transfer_item(
grouped,
receipt_number=receipt_number,
note=note,
mapping=mapping,
raster_id=raster_id,
amount=tip,
vat_amount=0.0,
currency=currency,
dph="0",
c_druh=-1,
)
round50 = round(_float_value(getattr(ucet, "round50", 0), 0.0) * share, 4)
if abs(round50) >= 0.0001:
raster_id = _closure_raster_id(raster_rows, -200, tmatr, budova, typ_hotel, "1")
if not raster_id:
missing_rasters.add("Zaokruhlenie (c_druh=-200)")
else:
_add_closure_transfer_item(
grouped,
receipt_number=receipt_number,
note=note,
mapping=mapping,
raster_id=raster_id,
amount=round50,
vat_amount=0.0,
currency=currency,
dph="0",
c_druh=-200,
)
if missing_payments:
warnings.append(
"Uzavierkovy prenos: v hotplatby nie su namapovane platby "
+ ", ".join(sorted(missing_payments)[:10])
+ ("..." if len(missing_payments) > 10 else "")
)
if missing_rasters:
warnings.append(
"Uzavierkovy prenos: chybaju rastre pre "
+ ", ".join(sorted(missing_rasters)[:10])
+ ("..." if len(missing_rasters) > 10 else "")
)
items = sorted(
grouped.values(),
key=lambda item: (
_strip_value(item.get("receipt_number")),
_strip_value(item.get("raster_id")),
_int_value(item.get("payment_method_id"), 0),
_int_value(item.get("credit_card_type_id"), 0),
),
)
if not items:
return None, warnings or ["Uzavierkovy prenos do recepcie nema ziadne odosielatelne polozky."]
logger.info(f"Recepcia items: {items}")
payload = {
"kind": "closure_reception_transfer",
"clsrep_id": clsrep_id,
"clsrep_no": report.clsrep_no,
"id_kas": id_kas,
"cash_register_code": _closure_cash_register_code(prefix, id_kas),
"reception_id": reception_id,
"reception_name": _strip_value(getattr(reception, "hotel", "")),
"typ_hotel": typ_hotel,
"interval": report.interval.model_dump(mode="json"),
"note": note,
"items": items,
}
logger.info(f"Recepcia payloads: {payload}")
logger.info(f"Recepcia warnings: {warnings}")
return payload, warnings
def finalize_closure_reception_transfers(
prefix: str,
id_kas: str,
clsrep_id: int,
report: data.ClosureReportOut,
setup_params: dict,
raw_receipts: list[str],
) -> data.ClosureReportOut:
warnings = list(report.warnings or [])
if not _bool_value(setup_params.get("is_uzprenh"), False):
report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id)
report.warnings = warnings
_update_closure_report_payload(prefix, clsrep_id, report)
return report
reception, reception_error = _closure_transfer_reception(prefix, setup_params)
if not reception:
warnings.append(reception_error)
report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id)
report.warnings = warnings
_update_closure_report_payload(prefix, clsrep_id, report)
return report
receipts: list[data.Ucet] = []
for raw in raw_receipts:
try:
receipts.append(data.Ucet.model_validate_json(raw))
except Exception:
logger.exception("Closure reception transfer skipped invalid receipt JSON.")
payload, payload_warnings = _build_closure_reception_payload(
prefix,
id_kas,
clsrep_id,
report,
receipts,
reception,
setup_params,
)
warnings.extend(payload_warnings)
if not payload:
report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id)
report.warnings = warnings
_update_closure_report_payload(prefix, clsrep_id, report)
return report
transfer_id = _insert_closure_transfer_outbox(prefix, clsrep_id, id_kas, reception, payload)
try:
result = hotel_service.transfer_cash(reception, setup_params, payload)
_update_closure_transfer_outbox(prefix, transfer_id, "sent", response=result)
logger.info(
"Closure reception transfer sent: prefix=%s clsrep_id=%s transfer_id=%s reception=%s",
prefix,
clsrep_id,
transfer_id,
getattr(reception, "hotel", ""),
)
except Exception as exc:
message = str(exc)
logger.exception(
"Closure reception transfer failed: prefix=%s clsrep_id=%s transfer_id=%s reception=%s",
prefix,
clsrep_id,
transfer_id,
getattr(reception, "hotel", ""),
)
_update_closure_transfer_outbox(prefix, transfer_id, "failed", response={}, error=message)
warnings.append(f"Prenos uzavierky do recepcie zlyhal: {message}")
report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id)
report.warnings = warnings
_update_closure_report_payload(prefix, clsrep_id, report)
return report
def finalize_closure_cash_actions(
prefix: str,
id_kas: str,
clsrep_id: int,
report: data.ClosureReportOut,
setup_params: dict,
client_id: str,
cash_carry: list[data.ClosureCarryInput] | None = None,
) -> data.ClosureReportOut:
rows = [dict(row) for row in (report.cash_state or [])]
warnings = list(report.warnings or [])
is_fiskal = _bool_value(setup_params.get("is_fiskal"), False)
uzav_odvod = _strip_value(setup_params.get("uzav_odvod"))
payment_map = {
_strip_value(payment.code): payment
for payment in get_setup_platby_from_db(prefix, id_kas)
}
carry_map: dict[tuple[str, str], float] = {}
for item in cash_carry or []:
key = (_strip_value(item.prn_no), _strip_value(item.payment_code))
carry_map[key] = round(max(_float_value(item.carry_amount, 0.0), 0.0), 2)
for row in rows:
balance = round(_float_value(row.get("balance_amount"), 0.0), 2)
row["balance_amount"] = balance
row["carry_amount"] = balance
row["auto_deposit_amount"] = round(_float_value(row.get("auto_deposit_amount"), 0.0), 2)
row["auto_withdrawal_amount"] = round(_float_value(row.get("auto_withdrawal_amount"), 0.0), 2)
row["status"] = "settled" if abs(balance) < 0.005 else "carry"
row["error"] = ""
if not is_fiskal or uzav_odvod not in {"1", "2"}:
continue
if int(row.get("payment_odvod") or 0) != 1:
continue
payment_code = _strip_value(row.get("payment_code"))
payment = payment_map.get(payment_code)
if not payment:
row["status"] = "failed"
row["error"] = f"Platba {payment_code} nie je v nastaveni platobnych metod."
warnings.append(row["error"])
continue
operation = "auto_withdrawal"
amount = balance
target_carry = 0.0
if uzav_odvod == "2":
key = (_strip_value(row.get("prn_no")), payment_code)
if key not in carry_map:
continue
target_carry = carry_map[key]
amount = round(balance - target_carry, 2)
if abs(amount) <= 0.004:
row["carry_amount"] = target_carry
row["status"] = "settled" if abs(target_carry) < 0.005 else "carry"
continue
if amount < 0:
operation = "auto_deposit"
amount = abs(amount)
elif balance <= 0.004:
continue
try:
req = data.FiscalCashOperationRequest(
id_kas=id_kas,
operation=operation,
amount=amount,
payment=payment,
printer_no=_strip_value(row.get("prn_no")),
author="Uzavierka",
pos_name=report.clsrep_no or "",
)
result = print_fiscal_cash_operation_db(
prefix,
req,
client_id,
c_uzaverka=clsrep_id,
)
if operation == "auto_deposit":
row["auto_deposit_amount"] = amount
else:
row["auto_withdrawal_amount"] = amount
row["carry_amount"] = target_carry
row["status"] = "settled" if abs(target_carry) < 0.005 else "carry"
row["generated_ucislo"] = _strip_value(getattr(result.ucet, "ucislo", ""))
row["fiscal_result"] = result.fiscal_result or {}
except Exception as exc:
message = str(exc)
logger.exception(
"Closure auto cash operation failed: prefix=%s clsrep_id=%s operation=%s prn=%s payment=%s amount=%s",
prefix,
clsrep_id,
operation,
row.get("prn_no"),
payment_code,
amount,
)
row["status"] = "failed"
row["error"] = message[:500]
row["carry_amount"] = balance
warnings.append(
f"Automaticky vklad/vyber zlyhal ({row.get('prn_no')}/{payment_code}): {message}"
)
persist_closure_cash_state(prefix, clsrep_id, id_kas, rows)
report.cash_state = rows
report.warnings = warnings
_update_closure_report_payload(prefix, clsrep_id, report)
return report
def get_closure_cash_state_db(prefix: str, id_kas: str, clsrep_id: int | None = None, status: str | None = None) -> list[dict]:
table = f"{prefix}_closure_cash_state"
where = ["id_kas=?"]
params: list[Any] = [id_kas]
if clsrep_id is not None:
where.append("clsrep_id=?")
params.append(clsrep_id)
if status:
where.append("status=?")
params.append(status)
with get_db() as conn:
cur = conn.cursor()
init_closure_cash_state_schema(prefix, cur)
cur.execute(f"""
SELECT id, clsrep_id, id_kas, prn_no, payment_code, payment_name,
opening_amount, sales_amount, receivable_amount,
manual_deposit_amount, manual_withdrawal_amount,
auto_deposit_amount, auto_withdrawal_amount, carry_amount, generated_ucislo,
fiscal_result, status, error, created_at, updated_at
FROM "{table}"
WHERE {" AND ".join(where)}
ORDER BY clsrep_id DESC, prn_no, payment_name, payment_code
""", params)
rows = cur.fetchall()
result = []
for row in rows:
fiscal_result = {}
if row[15]:
try:
fiscal_result = json.loads(row[15])
except Exception:
fiscal_result = {}
result.append({
"id": row[0],
"clsrep_id": row[1],
"id_kas": row[2],
"prn_no": row[3],
"payment_code": row[4],
"payment_name": row[5],
"opening_amount": row[6],
"sales_amount": row[7],
"receivable_amount": row[8],
"manual_deposit_amount": row[9],
"manual_withdrawal_amount": row[10],
"auto_deposit_amount": row[11],
"auto_withdrawal_amount": row[12],
"carry_amount": row[13],
"generated_ucislo": row[14],
"fiscal_result": fiscal_result,
"status": row[16],
"error": row[17],
"created_at": row[18],
"updated_at": row[19],
})
return result
@app.get("/closure/cash-state/")
def get_closure_cash_state(
id_kas: str,
clsrep_id: int | None = None,
status: str | None = None,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return get_closure_cash_state_db(prefix, id_kas, clsrep_id=clsrep_id, status=status)
@app.get("/closure/transfers/")
def get_closure_transfers(
id_kas: str,
clsrep_id: int | None = None,
status: str | None = None,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
return _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id, status=status)
@app.post("/closure/transfers/{transfer_id}/retry")
def retry_closure_transfer(
transfer_id: int,
auth: tuple[str, str, str] = Depends(auth_ctx),
):
prefix, _, _ = auth
table = f"{prefix}_closure_transfer_outbox"
with get_db() as conn:
cur = conn.cursor()
init_closure_transfer_outbox_schema(prefix, cur)
cur.execute(f"""
SELECT id_kas, reception_id, payload
FROM "{table}"
WHERE id=?
""", (transfer_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Prenos uzavierky {transfer_id} nebol najdeny.")
id_kas = _strip_value(row[0])
reception_id = _int_value(row[1], 0)
try:
payload = json.loads(row[2] or "{}")
except Exception:
payload = {}
if not payload:
raise HTTPException(422, "Prenos uzavierky nema ulozeny payload.")
reception = load_reception_from_db(prefix, reception_id)
setup_params = get_setup_param_values(prefix, id_kas)
try:
result = hotel_service.transfer_cash(reception, setup_params, payload)
_update_closure_transfer_outbox(prefix, transfer_id, "sent", response=result)
except Exception as exc:
message = str(exc)
logger.exception("Closure transfer retry failed: prefix=%s transfer_id=%s", prefix, transfer_id)
_update_closure_transfer_outbox(prefix, transfer_id, "failed", response={}, error=message)
raise HTTPException(502, message) from exc
rows = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=_int_value(payload.get("clsrep_id"), 0) or None)
return next((item for item in rows if int(item.get("id") or 0) == transfer_id), {"ok": True})
@app.get("/closure/",response_model=server_clsrep.ClosureReportOut)
def get_closure_report(
id_kas: str,
ucislo_od: str | None = None,
ucislo_do: str | None = None,
auth: tuple[str, str, str] = Depends(auth_ctx),):
logger.info(f'Closing report from check = {ucislo_od} to check={ucislo_do}')
prefix, user, client_id = auth
table_ucty = f"{prefix}_ucty"
table_clsrep = f"{prefix}_clsrep"
setup_params = get_setup_param_values(prefix, id_kas)
with get_db() as conn:
cur=conn.cursor()
ensure_closure_runtime_schema(prefix, cur)
report = server_clsrep.compute_closure_report(
cur=cur,
table_ucty=table_ucty,
table_clsrep=table_clsrep,
ucislo_st=ucislo_od,
ucislo_end=ucislo_do,
id_kas=id_kas,
)
if not report:
raise HTTPException(404, "Uzávěrka je prázdná")
report.closure_settings = closure_report_settings(setup_params)
return report
@app.post("/closure/save/",response_model=server_clsrep.ClosureReportOut)
def save_closure_report(
id_kas: str,
ucislo_od: str | None = None,
ucislo_do: str | None = None,
request: data.ClosureSaveRequest | None = Body(None),
auth: tuple[str, str, str] = Depends(auth_ctx),):
logger.info(f"SAVE closure from {ucislo_od} to {ucislo_do}")
prefix, user, client_id = auth
table_ucty = f"{prefix}_ucty"
table_clsrep = f"{prefix}_clsrep"
setup_params = get_setup_param_values(prefix, id_kas)
closure_settings = closure_report_settings(setup_params)
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
ensure_closure_runtime_schema(prefix, cur)
cur.execute("""
SELECT 1
FROM heartbeat_clients
WHERE prefix=?
AND id_kas=?
AND client_id!=?
LIMIT 1
""", (prefix, id_kas, client_id))
if cur.fetchone():
raise HTTPException(
409,
"Uzávěrku nelze provést jiný terminál je aktivní."
)
#with get_db() as conn:
# SPOČÍTEJ UZÁVĚRKU
report = server_clsrep.compute_closure_report(
cur=cur,
table_ucty=table_ucty,
table_clsrep=table_clsrep,
ucislo_st=ucislo_od,
ucislo_end=ucislo_do,
id_kas=id_kas,
)
if not report:
raise HTTPException(400, "Uzávěrka je prázdná")
report.closure_settings = closure_settings
cur = conn.cursor()
# KONTROLA DUPLICITY (ochrana proti race condition)
cur.execute(f"""
SELECT 1
FROM "{table_clsrep}"
WHERE id_kas=? AND clsrep_no=?
""", (id_kas, report.clsrep_no))
if cur.fetchone():
raise HTTPException(409, "Uzávěrka s tímto číslem již existuje")
# INSERT
cur.execute(f"""
INSERT INTO "{table_clsrep}"
(clsrep_no,
blocked_by,
ucislo_st,
ucislo_end,
dta_from,
dta_to,
id_kas,
data,
men_sp_man,
uzav_odvod,
closure_warnings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
report.clsrep_no, # ← TADY TO CHYBĚLO
client_id,
report.interval.ucislo_od,
report.interval.ucislo_do,
report.interval.closed_at_od,
report.interval.closed_at_do,
id_kas,
json.dumps(report.model_dump(), ensure_ascii=False),
closure_settings.get("men_sp_man", ""),
closure_settings.get("uzav_odvod", ""),
json.dumps(report.warnings or [], ensure_ascii=False),
))
clsrep_id = cur.lastrowid
cur.execute(f"""
UPDATE "{table_ucty}"
SET c_uzaverka = ?
WHERE id_kas = ?
AND closed_at IS NOT NULL
AND TRIM(COALESCE(ucislo, '')) != ''
AND (c_uzaverka IS NULL OR c_uzaverka = 0)
AND CAST(ucislo AS INTEGER) >= CAST(? AS INTEGER)
AND CAST(ucislo AS INTEGER) <= CAST(? AS INTEGER)
""", (
clsrep_id,
id_kas,
report.interval.ucislo_od,
report.interval.ucislo_do,
))
if cur.rowcount <= 0:
raise HTTPException(409, "Uzávěrka nebyla přiřazena k žádnému účtu")
assigned_count = cur.rowcount
cur.execute(f"""
SELECT data
FROM "{table_ucty}"
WHERE id_kas = ?
AND c_uzaverka = ?
""", (id_kas, clsrep_id))
raw_receipts = [row[0] for row in cur.fetchall()]
sync_limit_closure_to_postgres(
prefix,
id_kas,
raw_receipts,
clsrep_id,
)
logger.info(
"Closure saved clsrep_id=%s clsrep_no=%s assigned=%s interval=%s-%s",
clsrep_id,
report.clsrep_no,
assigned_count,
report.interval.ucislo_od,
report.interval.ucislo_do,
)
conn.commit()
report = finalize_closure_cash_actions(
prefix=prefix,
id_kas=id_kas,
clsrep_id=clsrep_id,
report=report,
setup_params=setup_params,
client_id=client_id,
cash_carry=request.cash_carry if request else None,
)
report = finalize_closure_reception_transfers(
prefix=prefix,
id_kas=id_kas,
clsrep_id=clsrep_id,
report=report,
setup_params=setup_params,
raw_receipts=raw_receipts,
)
return report
# -----------------------------------------------------
# Operace s cenikem
# -----------------------------------------------------
#od Milana -------------
@app.post("/cenik/items/{pokl}")
def save_cen_items(
pokl: str,
cenp: data.CenPolCreate | list[data.CenPolCreate],
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
if isinstance(cenp, list):
ok=cenik_delete_pokl(prefix, pokl)
if ok:
print(f'\nSmazan cenik pokladny {pokl} ')
else:
print(f'Mazani ceniku pokladny {pokl} selhalo nebo neexistuje')
result=cen_add_batch(prefix, cenp)
else:
result=cen_add(prefix, cenp)
return result
#Milan 11.03.26
def _cenik_db_tuple(item: data.CenPolCreate | data.CenPol) -> tuple:
return (
item.pokl,
int(getattr(item, "id_card", 0) or 0),
int(getattr(item, "c_druh", 0) or 0),
getattr(item, "druh", "") or "",
getattr(item, "spart", "") or "",
getattr(item, "prn_no", "") or "",
item.model_dump_json(),
)
def cen_add_batch(cur_pref: str, items: list[data.CenPolCreate]) -> dict:
table = f"{cur_pref}_cenik"
rows = [
_cenik_db_tuple(i)
for i in items
]
try:
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.executemany(
f'INSERT INTO "{table}" (pokl, id_card, c_druh, druh, spart, prn_no, data) VALUES (?, ?, ?, ?, ?, ?, ?)',
rows,
)
return {
"ok": True,
"inserted": len(rows),
"error": "OK"
}
except Exception as e:
return {
"ok": False,
"error": str(e),
"inserted": 0
}
#-----od Milana
@app.get("/fstmenu/pokl/{pokl}", response_model=list[data.FstMenuKasa])
def get_fstmenu_for_pokl(pokl: str, auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
logger.info(f"GET_fstmenu: prefix={prefix} pokl={pokl}")
return fstmenu_load_for_pokl(prefix, pokl)
def fstmenu_load_for_pokl(prefix, pokl):
table = f"{prefix}_fstmenu"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT c_karty, polozky
FROM "{table}"
WHERE id_kas = ?
''',
(pokl,)
)
rows = cur.fetchall()
result = []
for c_karty, polozky in rows:
if isinstance(polozky, str):
polozky = json.loads(polozky)
result.append({
"c_karty": c_karty,
"polozky": polozky # už je JSON → nechaj tak
})
return result
@app.post("/save_cenpol/", status_code=204)
def save_cen(cenp: data.CenPolCreate, auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
logger.info(f'Save_CenPol {cenp.ch_name}')
#Milan 11.03.26
result=cen_add(prefix, cenp)
def cen_add(cur_pref: str, cenp: data.CenPolCreate) -> dict:
table = f"{cur_pref}_cenik"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
#Milan 11.03.26
try:
cur.execute(
f'INSERT INTO "{table}" (pokl, id_card, c_druh, druh, spart, prn_no, data) VALUES (?, ?, ?, ?, ?, ?, ?)',
_cenik_db_tuple(cenp),
)
return {
"ok": True,
"inserted": 1,
"error": "OK"
}
except Exception as e:
return {
"ok": False,
"error": str(e),
"inserted": 0
}
@app.get("/cenik/pokl/{pokl}", response_model=list[data.CenPol])
def get_cenik_for_pokl(pokl: str, auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
logger.info(f"GET_cenik: prefix={prefix} pokl={pokl}")
return cen_load_for_pokl(prefix, pokl)
def cen_load_for_pokl(prefix, pokl):
table = f"{prefix}_cenik"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'SELECT rowid, data FROM "{table}" WHERE pokl=?',
(pokl,)
)
rows = cur.fetchall()
result = []
for rowid, json_data in rows:
obj, changed = model_from_json_migrated(data.CenPol, json_data)
cur.execute(
f'UPDATE "{table}" SET id_card=?, c_druh=?, druh=?, spart=?, prn_no=?, data=? WHERE rowid=?',
(
int(getattr(obj, "id_card", 0) or 0),
int(getattr(obj, "c_druh", 0) or 0),
getattr(obj, "druh", "") or "",
getattr(obj, "spart", "") or "",
getattr(obj, "prn_no", "") or "",
obj.model_dump_json() if changed else json_data,
rowid,
)
)
result.append(obj)
conn.commit()
return result
@app.delete("/cenik/pokl/{pokl}", status_code=204)
def delete_cenik_pokl(pokl: str, auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
if not cenik_delete_pokl(prefix, pokl):
raise HTTPException(404, f"Ceník pro pokladnu {pokl} neexistuje")
def cenik_delete_pokl(cur_pref: str, pokl: str) -> bool:
table = f"{cur_pref}_cenik"
with get_db() as conn:
cur = conn.cursor()
cur.execute( f'DELETE FROM "{table}" WHERE pokl = ?', (pokl,) )
deleted = cur.rowcount
return deleted > 0
@app.post("/cenik/replace/", status_code=204)
def replace_cenik(cenik: data.Cenik, auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, user, client_id = auth
cenik_replace(prefix, cenik)
def cenik_replace(cur_pref: str, cenik: data.Cenik) -> None:
table = f"{cur_pref}_cenik"
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
cur.execute(f'DELETE FROM "{table}"')
for p in cenik.cenpol:
cur.execute(
f'INSERT INTO "{table}" (pokl, id_card, c_druh, druh, spart, prn_no, data) VALUES (?, ?, ?, ?, ?, ?, ?)',
_cenik_db_tuple(p),
)
def _cenik_text_db_tuple(item: data.CenikText) -> tuple:
lang = str(getattr(item, "jazyk", "sk") or "sk").strip().lower()
if lang == "cz":
lang = "cs"
text = item.model_copy(update={"jazyk": lang})
return (
int(text.id_card or 0),
text.jazyk,
text.d_name or "",
text.ch_name or "",
text.dat_cas_zm or "",
text.model_dump_json(),
)
@app.post("/foodman/zmeny")
def update_s_zmeny(
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, _, _ = auth
return touch_foodman_data_change(prefix)
@app.post("/cenik/texty")
def replace_cenik_texty(
texty: list[data.CenikText],
lang: str | None = Query(None),
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, _, _ = auth
return cenik_texty_replace(prefix, texty, lang=lang)
@app.post("/cenik/texty/{pokl}")
def replace_cenik_texty_for_pokl(
pokl: str,
texty: list[data.CenikText],
lang: str | None = Query(None),
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, _, _ = auth
return cenik_texty_replace(prefix, texty, lang=lang)
def cenik_texty_replace(prefix: str, texty: list[data.CenikText], lang: str | None = None) -> dict:
table = f"{prefix}_cenik_texty"
lang = (lang or "").strip().lower()
if lang == "cz":
lang = "cs"
rows = [
_cenik_text_db_tuple(
item.model_copy(update={"jazyk": lang}) if lang else item,
)
for item in texty
]
try:
with get_db() as conn:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
if lang:
cur.execute(f'DELETE FROM "{table}" WHERE jazyk = ?', (lang,))
else:
cur.execute(f'DELETE FROM "{table}"')
cur.executemany(
f'''
INSERT INTO "{table}" (id_card, jazyk, d_name, ch_name, dat_cas_zm, data)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id_card, jazyk) DO UPDATE SET
d_name=excluded.d_name,
ch_name=excluded.ch_name,
dat_cas_zm=excluded.dat_cas_zm,
data=excluded.data
''',
rows,
)
return {"ok": True, "inserted": len(rows), "error": "OK"}
except Exception as e:
logger.exception("Cenik texty replace failed")
return {"ok": False, "inserted": 0, "error": str(e)}
@app.get("/cenik/texty", response_model=list[data.CenikText])
def get_cenik_texty(
lang: str = Query("sk"),
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, _, _ = auth
return cenik_texty_load(prefix, lang)
@app.get("/cenik/texty/{pokl}", response_model=list[data.CenikText])
def get_cenik_texty_for_pokl(
pokl: str,
lang: str = Query("sk"),
auth: tuple[str, str, str] = Depends(auth_ctx)):
prefix, _, _ = auth
return cenik_texty_load(prefix, lang)
def cenik_texty_load(prefix: str, lang: str) -> list[data.CenikText]:
table = f"{prefix}_cenik_texty"
lang = str(lang or "sk").strip().lower()
if lang == "cz":
lang = "cs"
with get_db() as conn:
cur = conn.cursor()
cur.execute(
f'''
SELECT data
FROM "{table}"
WHERE jazyk = ?
ORDER BY id_card
''',
(lang,),
)
result = []
for (raw_json,) in cur.fetchall():
try:
result.append(data.CenikText.model_validate_json(raw_json))
except Exception:
logger.exception("Invalid cenik_texty JSON")
return result
#ucty_ram=data.Ucty(ucty=[]) #struktura pro serverove ukladani uctu do pameti
# L.L. (22.06.2026) - Zaciatok:aktualizácia cenníka, setupu a mapy stolov podľa datumu a času poslednej zmeny
def init_s_zmena_schema(prefix: str, cur):
table = f"{prefix}_s_zmena"
cur.execute(f"""
CREATE TABLE IF NOT EXISTS "{table}" (
id INT PRIMARY KEY,
zmena TIMESTAMP NOT NULL
)
""")
def foodman_change_timestamp() -> str:
return datetime.now().isoformat(timespec="milliseconds")
def ensure_foodman_data_change_cur(
cur: sqlite3.Cursor,
prefix: str,
) -> data.FoodManDataChange:
init_s_zmena_schema(prefix, cur)
table = f"{prefix}_s_zmena"
cur.execute(
f'SELECT zmena FROM "{table}" ',
)
row = cur.fetchone()
if row:
return data.FoodManDataChange(zmena=row[0])
zmena = foodman_change_timestamp()
cur.execute(
f'INSERT INTO "{table}" ( zmena) VALUES (?)',
(zmena),
)
return data.FoodManDataChange(zmena=zmena)
def touch_foodman_data_change_cur(
cur: sqlite3.Cursor,
prefix: str,
) -> dict:
init_s_zmena_schema(prefix, cur)
table = f"{prefix}_s_zmena"
zmena = foodman_change_timestamp()
cur.execute(
f'SELECT zmena FROM "{table}" LIMIT 1',
)
row = cur.fetchone()
if row:
cur.execute(
f'UPDATE "{table}" SET zmena = ?',
(zmena,),
)
else:
cur.execute(
f'INSERT INTO "{table}" (zmena) VALUES (?)',
(zmena,),
)
return {"ok": True, "zmena": zmena}
def touch_foodman_data_change(prefix: str) -> dict:
with get_db() as conn:
cur = conn.cursor()
result=touch_foodman_data_change_cur(cur, prefix)
conn.commit()
return result
# L.L. (22.06.2026) - Koniec: aktualizácia cenníka, setupu a mapy stolov podľa datumu a času poslednej zmeny
#migrace json dle modelu
def migrate_dict_to_model(model_cls, data: dict):
#Rekurzivně doplní chybějící pole podle Pydantic modelu.
#Vrací (data, changed)
changed = False
for name, field in model_cls.model_fields.items():
# pole chybí
if name not in data:
if field.default is not None:
data[name] = field.default
changed = True
elif field.default_factory is not None:
data[name] = field.default_factory()
changed = True
value = data.get(name)
# -------- nested BaseModel --------
if isinstance(value, dict) and isinstance(field.annotation, type) and issubclass(field.annotation, BaseModel):
new_val, ch = migrate_dict_to_model(field.annotation, value)
data[name] = new_val
changed |= ch
# -------- list[BaseModel] --------
if isinstance(value, list):
inner = getattr(field.annotation, "__args__", None)
if inner:
inner_type = inner[0]
if isinstance(inner_type, type) and issubclass(inner_type, BaseModel):
new_list = []
for item in value:
if isinstance(item, dict):
new_item, ch = migrate_dict_to_model(inner_type, item)
changed |= ch
new_list.append(new_item)
else:
new_list.append(item)
data[name] = new_list
return data, changed
def model_from_json_migrated(model_cls, json_str: str):
data = json.loads(json_str)
data, changed = migrate_dict_to_model(model_cls, data)
obj = model_cls.model_validate(data)
return obj, changed
import uuid
def migrate_ucet_payload(payload: dict):
changed = False
if "round50" not in payload:
payload["round50"] = 0.0
changed = True
fiscal_result = payload.get("fiscal_result")
if isinstance(fiscal_result, dict) and "response" in fiscal_result:
fiscal_result.pop("response", None)
changed = True
for payment in payload.get("platby", []) or []:
if isinstance(payment, dict) and "round50" in payment:
payment.pop("round50", None)
changed = True
if "guests" not in payload:
payload["guests"] = [{"id": "g1", "name": "Hosť 1"}]
payload["guest_count"] = 1
changed = True
if "courses" not in payload:
payload["courses"] = [{"id": "c1", "name": "Chod 1"}]
payload["course_count"] = 1
changed = True
for p in payload.get("poloz", []):
if "line_id" not in p:
p["line_id"] = uuid.uuid4().hex
changed = True
if "group_id" not in p:
# obyčajná položka = vlastná group
p["group_id"] = p["line_id"]
changed = True
if "parent_id" not in p:
p["parent_id"] = None
changed = True
if "typ_menu" not in p:
p["typ_menu"] = 0
changed = True
if "pol_pocet" not in p:
p["pol_pocet"] = 1
changed = True
if "def_cena" not in p:
p["def_cena"] = p["cena"]
changed = True
if "def_dph" not in p:
p["def_dph"] = p["dph"]
changed = True
if "def_hlad" not in p:
p["def_hlad"] = p["cenhlad"]
changed = True
if "zpravy" not in p:
p["zpravy"] = []
changed = True
#if "guests" not in p:
# p["guests"] = [{"id": "g1", "name": "Hosť 1"}]
# p["guest_count"] = 1
# changed = True
#if "courses" not in p:
# p["courses"] = [{"id": "c1", "name": "Chod 1"}]
# p["courses"] = 1
# changed = True
return payload, changed
aa=data.Ucet(autor="Petr Kobrle", poloz=[])