10482 lines
368 KiB
Python
10482 lines
368 KiB
Python
#-----------------------------------------------
|
||
# 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
|
||
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
|
||
# L.L. (22.06.2026) Pridané kvôli spúšťaniu flutter aplikácie v chrome
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
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_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)
|
||
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()
|
||
|
||
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
|
||
)
|
||
""")
|
||
# pokladna
|
||
cur.execute(f"""
|
||
CREATE INDEX IF NOT EXISTS idx_clsrep_kas
|
||
ON "{table}"(id_kas)
|
||
""")
|
||
|
||
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_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,
|
||
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, "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, heslo, heslo_karta, is_admin, permits, payments, discounts, levels)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
"SUP",
|
||
"AltoAdmin",
|
||
"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 '', 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 "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_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))),
|
||
)
|
||
|
||
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,
|
||
) = 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),
|
||
)
|
||
|
||
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
|
||
)
|
||
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,
|
||
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')
|
||
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')
|
||
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
|
||
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 "[]"),
|
||
})
|
||
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,
|
||
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, heslo, heslo_karta, is_admin, permits, payments, discounts, levels)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (
|
||
u.name,
|
||
u.user_id,
|
||
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, 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_, 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_, 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]]:
|
||
vysledok = []
|
||
for p in data.Perm:
|
||
match lang:
|
||
case "sk":
|
||
text = p.descriptionsk
|
||
case "cz":
|
||
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", "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",
|
||
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",
|
||
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, 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,
|
||
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, 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, heslo, heslo_karta, permits, payments, discounts, is_admin, levels)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
user.name,
|
||
user.user_id,
|
||
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,
|
||
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
|
||
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)
|
||
|
||
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
|
||
FROM {table_prndef}
|
||
WHERE prn_no IN ({placeholders})
|
||
ORDER BY prn_no
|
||
""",
|
||
tuple(prn_map.keys())
|
||
)
|
||
|
||
rows = cur.fetchall()
|
||
|
||
out = []
|
||
|
||
for r in rows:
|
||
out.append(
|
||
data.PrnDefShort(
|
||
prn_no=r[0],
|
||
prn_name=r[1],
|
||
poradie=prn_map.get(r[0], 0),
|
||
id_term=r[2] 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, "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/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))
|
||
|
||
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] = []
|
||
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", ""))
|
||
rows.append({
|
||
"nazov": _strip_value(getattr(pol, "nazev", "")),
|
||
"cena": abs(price),
|
||
"mnozstvo": abs(qty),
|
||
"dil_porce": delitel,
|
||
"mj": "por",
|
||
"dph": dph,
|
||
"typ_fiskal": "F",
|
||
"okp": "",
|
||
"uid": "",
|
||
"identifikator": None,
|
||
})
|
||
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)
|
||
return {
|
||
"ucet": _strip_value(getattr(ucet, "ucislo", "")),
|
||
"typ_poloziek": "F",
|
||
"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 _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,
|
||
)
|
||
|
||
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 {"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,
|
||
template_ucet=path.name if output_kind == "ucet" 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"}:
|
||
raise HTTPException(422, f"Unsupported print template kind {kind}")
|
||
template_prefix = "TP-ucet" if template_kind in {"ucet", "receipt", "bill"} else "TP-bon"
|
||
templates_dir = _print_templates_dir()
|
||
if not templates_dir.exists():
|
||
return []
|
||
files: list[Path] = []
|
||
for suffix in PRINT_TEMPLATE_EXTENSIONS:
|
||
files.extend(templates_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 len(req.id_kas.strip()) != 2:
|
||
raise HTTPException(422, "Invalid id_kas")
|
||
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 = [req.id_kas, *PRINT_JOB_ACTIVE_STATUSES]
|
||
sql = f"""
|
||
SELECT id
|
||
FROM "{table}"
|
||
WHERE id_kas=?
|
||
AND 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)
|
||
|
||
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 _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:
|
||
for id_kas in _active_print_job_cashiers(prefix, printers):
|
||
batch = process_local_print_jobs_db(
|
||
prefix,
|
||
data.PrintJobClaimRequest(
|
||
id_kas=id_kas,
|
||
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)
|
||
active_placeholders = ",".join("?" for _ in PRINT_JOB_ACTIVE_STATUSES)
|
||
active_params = [*params, *PRINT_JOB_ACTIVE_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,
|
||
"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, prn_no, data FROM "{table}" WHERE pokl = ?', (id_kas,))
|
||
rows = cur.fetchall()
|
||
if not rows:
|
||
cur.execute(f'SELECT id_card, c_druh, druh, prn_no, data FROM "{table}"')
|
||
rows = cur.fetchall()
|
||
for id_card_db, c_druh_db, druh_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")),
|
||
"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, '$.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, 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"
|
||
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,
|
||
"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 ""
|
||
|
||
# --- 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 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()
|
||
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()
|
||
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
|
||
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]
|
||
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,
|
||
},
|
||
"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()
|
||
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
|
||
|
||
|
||
@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"
|
||
with get_db() as conn:
|
||
cur=conn.cursor()
|
||
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á")
|
||
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,
|
||
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"
|
||
with get_db() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute("BEGIN IMMEDIATE")
|
||
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á")
|
||
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)
|
||
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),
|
||
))
|
||
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 ucislo >= ?
|
||
AND ucislo <= ?
|
||
""", (
|
||
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))
|
||
sync_limit_closure_to_postgres(
|
||
prefix,
|
||
id_kas,
|
||
[row[0] for row in cur.fetchall()],
|
||
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()
|
||
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, "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, 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, 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=?, 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, "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, prn_no, data) VALUES (?, ?, ?, ?, ?, ?)',
|
||
_cenik_db_tuple(p),
|
||
)
|
||
#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_kas TEXT 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,
|
||
id_kas: str,
|
||
) -> data.FoodManDataChange:
|
||
if len(id_kas.strip()) != 2:
|
||
raise HTTPException(422, "Invalid id_kas")
|
||
|
||
init_s_zmena_schema(prefix, cur)
|
||
table = f"{prefix}_s_zmena"
|
||
cur.execute(
|
||
f'SELECT zmena FROM "{table}" WHERE id_kas=?',
|
||
(id_kas,),
|
||
)
|
||
row = cur.fetchone()
|
||
if row:
|
||
return data.FoodManDataChange(id_kas=id_kas, zmena=row[0])
|
||
|
||
zmena = foodman_change_timestamp()
|
||
cur.execute(
|
||
f'INSERT INTO "{table}" (id_kas, zmena) VALUES (?, ?)',
|
||
(id_kas, zmena),
|
||
)
|
||
return data.FoodManDataChange(id_kas=id_kas, zmena=zmena)
|
||
|
||
def touch_foodman_data_change_cur(
|
||
cur: sqlite3.Cursor,
|
||
prefix: str,
|
||
id_kas: str,
|
||
) -> None:
|
||
if len(id_kas.strip()) != 2:
|
||
raise HTTPException(422, "Invalid id_kas")
|
||
|
||
init_s_zmena_schema(prefix, cur)
|
||
table = f"{prefix}_s_zmena"
|
||
zmena = foodman_change_timestamp()
|
||
cur.execute(
|
||
f"""
|
||
INSERT INTO "{table}" (id_kas, zmena)
|
||
VALUES (?, ?)
|
||
ON CONFLICT(id_kas)
|
||
DO UPDATE SET zmena=excluded.zmena
|
||
""",
|
||
(id_kas, zmena),
|
||
)
|
||
|
||
def touch_foodman_data_change(prefix: str, id_kas: str) -> None:
|
||
with get_db() as conn:
|
||
cur = conn.cursor()
|
||
touch_foodman_data_change_cur(cur, prefix, id_kas)
|
||
|
||
# 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=[])
|
||
|
||
|
||
|