#----------------------------------------------- # server_sqlite.py, v.:1.0 # pro SQLite, ma oddelene endpointy a DB operace #----------------------------------------------- #dbt ="testIX.db" #jmeno databaze dbt ="testVIII.db" #jmeno databaze #v consoli v adresari kde je serverovy pgm #uvicorn server:app --reload version = "072_8_Kivy" import secrets import data import logging import sqlite3 import json import time import re import socket import os import threading import ast import uuid from typing import List, Optional, Any from fastapi import FastAPI, HTTPException, Header, Depends, Query, Body from fastapi.responses import JSONResponse from pathlib import Path from contextlib import contextmanager from datetime import datetime, timedelta from urllib.parse import quote from pydantic import ValidationError from pydantic import BaseModel, TypeAdapter from types import SimpleNamespace import requests import server_clsrep import hotel_service import fidelio_db_service import postgres_service from collections import defaultdict from i18n import available_locales, normalize_lang # L.L. (22.06.2026) Pridané kvôli spúšťaniu flutter aplikácie v chrome from fastapi.middleware.cors import CORSMiddleware app = FastAPI() # L.L. (22.06.2026) Pridané kvôli spúšťaniu flutter aplikácie v chrome app.add_middleware( CORSMiddleware, allow_origin_regex=r"https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?", allow_credentials=True, allow_methods=["*"], allow_headers=["Authorization", "Content-Type", "X-Client-ID"], ) p = data.Krypt() #kodovani HEART_BEAT:int = 10 HEARTBEAT_INTERVAL = HEART_BEAT# jak často klient posílá heartbeat (s) HEARTBEAT_TIMEOUT = 30 # po kolika sekundách je klient považován za mrtvého BLOCK_EXPIRATION = 6 * HEART_BEAT # sekund (heartbeat máš po 10 s) ACCESS_MINUTES = 15 REFRESH_DAYS = 7 LOCAL_PRINT_WORKER_ENV = "POKLADNA_LOCAL_PRINT_WORKER" LOCAL_PRINT_AGENT_ID = os.getenv( "POKLADNA_LOCAL_PRINT_AGENT_ID", f"server-{socket.gethostname()}", ) _local_print_worker_stop = threading.Event() _local_print_worker_thread: threading.Thread | None = None _local_print_worker_state = { "started_at": "", "stopped_at": "", "last_cycle_at": "", "last_processed_count": 0, "last_processed_ids": [], "last_error": "", "printers": [], "prefixes": [], } _limit_pg_locks_guard = threading.Lock() _limit_pg_locks: dict[tuple[str, str, int], dict] = {} # ----------------------------------------------------- # logovani # ----------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",) logger = logging.getLogger(__name__) LOG_FILE = Path(__file__).with_name("server_sqlite.log") if not any( isinstance(handler, logging.FileHandler) and Path(getattr(handler, "baseFilename", "")) == LOG_FILE for handler in logging.getLogger().handlers ): file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") file_handler.setLevel(logging.INFO) file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) logging.getLogger().addHandler(file_handler) logging.getLogger().setLevel(logging.INFO) ''' #do souboru logging.basicConfig( filename="server.log", level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",) ''' # ----------------------------------------------------- # SQLite (JSON storage) # ----------------------------------------------------- def init_global_schema(cur): init_tab_zakazky(cur) init_tab_tokens(cur) init_tab_heartbeat(cur) def init_user_schema(cur, prefix: str, zakazka: str=""): init_tab_uct(prefix, cur) init_tab_cen(prefix, cur) init_cenik_texty_schema(prefix, cur) init_cook_items_schema(prefix, cur) init_tab_setup(prefix, cur, zakazka) init_platby_schema(prefix, cur) init_tab_users(prefix=prefix, cur=cur) init_tab_closerep(prefix=prefix, cur=cur) init_closure_cash_state_schema(prefix=prefix, cur=cur) init_closure_transfer_outbox_schema(prefix=prefix, cur=cur) backfill_ucty_closure_links(prefix=prefix, cur=cur) init_mapa_stolu_schema(prefix=prefix, cur=cur) seed_mapa_stolu_if_empty(prefix=prefix, cur=cur) init_fstmenu_schema(prefix=prefix, cur=cur) init_prndef_schema(prefix=prefix, cur=cur) init_bankterm_schema(prefix=prefix, cur=cur) init_print_jobs_schema(prefix=prefix, cur=cur) init_print_bon_counters_schema(prefix=prefix, cur=cur) init_printer_status_schema(prefix=prefix, cur=cur) init_kasaucp_schema(prefix=prefix, cur=cur) init_kasutxt_schema(prefix=prefix, cur=cur) init_hladiny_schema(prefix=prefix, cur=cur) init_zlavy_schema(prefix=prefix, cur=cur) init_setup_parameters_schema(prefix=prefix, cur=cur) init_postgres_connection_schema(prefix=prefix, cur=cur) init_limit_locks_schema(prefix=prefix, cur=cur) init_recepcia_schema(prefix=prefix, cur=cur) init_hotrastre_schema(prefix=prefix, cur=cur) init_mewsrastre_schema(prefix=prefix, cur=cur) init_fidrastre_schema(prefix=prefix, cur=cur) init_hotplatby_schema(prefix=prefix, cur=cur) init_mewsdph_schema(prefix=prefix, cur=cur) init_uvery_schema(prefix=prefix, cur=cur) init_fooddat_schema(prefix=prefix, cur=cur) init_clients_schema(prefix=prefix, cur=cur) init_s_zmena_schema(prefix=prefix, cur=cur) ## L.L. (22.06.2026) Načítavanie cenníka, nastavení a mapy na základe posledného dátumu a času zmeny @app.on_event("startup") def startup(): logger.info(f"Server version {version}\nStarting DB initialization") print("Server version:",version) with get_db() as conn: conn.execute("PRAGMA journal_mode=WAL;") cur = conn.cursor() init_global_schema(cur) # globální tabulky cur.execute(""" SELECT id, jmeno_zakazky FROM zakazky WHERE heslo IS NOT NULL AND heslo <> '' """) rows = cur.fetchall() for zak_id, name in rows: # existující zakázky prefix = f"{zak_id:05d}" logger.info(f"Ensuring schema for {prefix} ({name})") init_user_schema(cur, prefix) #upgrade_all_setups(cur, prefix) logger.info("Embedded local print worker is not started with server. Use local_print_agent.py for local printing.") @app.on_event("shutdown") def shutdown(): stop_local_print_worker() @app.get("/locales/") def get_locales(): return {"locales": available_locales()} @app.get("/locales/{lang}") def get_locale(lang: str): lang = normalize_lang(lang) path = Path(__file__).with_name("locales") / f"{lang}.json" if not path.exists(): raise HTTPException(404, f"Locale {lang} neexistuje") with path.open("r", encoding="utf-8") as f: return json.load(f) def upgrade_all_setups(cur, prefix: str): table = f"{prefix}_setup" # pokud tabulka setup ještě neexistuje, není co řešit cur.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,), ) if not cur.fetchone(): return cur.execute(f'SELECT id_kas, data FROM "{table}"') rows = cur.fetchall() for id_kas, raw_json in rows: try: setup = data.PosSetup.model_validate_json(raw_json) except Exception as e: logger.error(f"Invalid setup JSON {prefix}/{id_kas}: {e}") continue normalized = json.dumps( setup.model_dump(exclude_none=False), ensure_ascii=False, separators=(",", ":"), sort_keys=False, ) if normalized != raw_json: logger.info(f"SETUP schema upgraded: {prefix}/{id_kas}") cur.execute( f'UPDATE "{table}" SET data=? WHERE id_kas=?', (normalized, id_kas), ) def init_tab_zakazky(cur): cur.execute(""" CREATE TABLE IF NOT EXISTS zakazky ( id INTEGER PRIMARY KEY AUTOINCREMENT, jmeno_zakazky TEXT NOT NULL, uzivatel TEXT NOT NULL UNIQUE, heslo TEXT NOT NULL ) """) cur.execute( "SELECT 1 FROM zakazky WHERE uzivatel=?", ("Kobrle",) ) if not cur.fetchone(): cur.execute( "INSERT INTO zakazky (jmeno_zakazky, uzivatel, heslo) VALUES (?,?,?)", ("Alto", "Kobrle", p.code("heslo")) ) def init_tab_heartbeat(cur): cur.execute(""" CREATE TABLE IF NOT EXISTS heartbeat_clients ( prefix TEXT NOT NULL, id_kas TEXT NOT NULL, client_id TEXT NOT NULL, user TEXT, last_seen REAL NOT NULL, PRIMARY KEY (prefix, id_kas, client_id) ) """) def init_tab_tokens(cur): cur.execute(""" CREATE TABLE IF NOT EXISTS tokens ( user TEXT NOT NULL, client_id TEXT NOT NULL, prefix TEXT NOT NULL, access_token TEXT NOT NULL, expires_at TIMESTAMP NOT NULL, refresh_token TEXT NOT NULL, refresh_expires_at TIMESTAMP NOT NULL, last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user, client_id) ) """) cur.execute("CREATE INDEX IF NOT EXISTS idx_tokens_access ON tokens (access_token)") cur.execute("CREATE INDEX IF NOT EXISTS idx_tokens_refresh ON tokens (refresh_token)") def init_fstmenu_schema(prefix: str, cur): table = f"{prefix}_fstmenu" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, c_karty INTEGER, polozky TEXT NOT NULL ) """) index_name = f"{prefix}_fstmenu_kasa_karta" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas, c_karty) """) def init_fooddat_schema(prefix: str, cur): table = f"{prefix}_fooddat" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id TEXT PRIMARY KEY, c_stredisk INTEGER NOT NULL DEFAULT 0, id_zkratka TEXT NOT NULL DEFAULT '', pgm TEXT NOT NULL DEFAULT '' ) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_fooddat_stredisk" ON "{table}" (c_stredisk) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_fooddat_pgm" ON "{table}" (pgm) """) def init_mapa_stolu_schema(prefix: str, cur): table = f"{prefix}_mapa_stolu" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL ) """) def seed_mapa_stolu_if_empty(prefix: str, cur): table = f"{prefix}_mapa_stolu" cur.execute(f'SELECT COUNT(*) FROM "{table}"') count = cur.fetchone()[0] if count == 0: empty_map = { "rooms": [], "pokladny": [] } raw_json = json.dumps( empty_map, ensure_ascii=False, separators=(",", ":"), sort_keys=True, ) cur.execute( f'INSERT INTO "{table}" (data) VALUES (?)', (raw_json,), ) def init_tab_closerep(prefix: str, cur): table = f"{prefix}_clsrep" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( clsrep_id INTEGER PRIMARY KEY AUTOINCREMENT, blocked_by TEXT, ucislo_st TEXT NOT NULL, ucislo_end TEXT NOT NULL, dta_from TEXT NOT NULL, dta_to TEXT NOT NULL, id_kas TEXT NOT NULL, clsrep_no TEXT NOT NULL, data TEXT NOT NULL ) """) cur.execute(f'PRAGMA table_info("{table}")') columns = {str(row[1]) for row in cur.fetchall()} if "men_sp_man" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN men_sp_man TEXT NOT NULL DEFAULT ""') if "uzav_odvod" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN uzav_odvod TEXT NOT NULL DEFAULT ""') if "closure_warnings" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN closure_warnings TEXT NOT NULL DEFAULT "[]"') # pokladna cur.execute(f""" CREATE INDEX IF NOT EXISTS idx_clsrep_kas ON "{table}"(id_kas) """) def init_closure_cash_state_schema(prefix: str, cur): table = f"{prefix}_closure_cash_state" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, clsrep_id INTEGER NOT NULL, id_kas TEXT NOT NULL, prn_no TEXT NOT NULL DEFAULT '', payment_code TEXT NOT NULL DEFAULT '', payment_name TEXT NOT NULL DEFAULT '', opening_amount REAL NOT NULL DEFAULT 0, sales_amount REAL NOT NULL DEFAULT 0, receivable_amount REAL NOT NULL DEFAULT 0, manual_deposit_amount REAL NOT NULL DEFAULT 0, manual_withdrawal_amount REAL NOT NULL DEFAULT 0, auto_deposit_amount REAL NOT NULL DEFAULT 0, auto_withdrawal_amount REAL NOT NULL DEFAULT 0, carry_amount REAL NOT NULL DEFAULT 0, generated_ucislo TEXT NOT NULL DEFAULT '', fiscal_result TEXT NOT NULL DEFAULT '{{}}', status TEXT NOT NULL DEFAULT 'pending', error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(clsrep_id, id_kas, prn_no, payment_code) ) """) cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if "auto_deposit_amount" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN auto_deposit_amount REAL NOT NULL DEFAULT 0') cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_closure_cash_state_status" ON "{table}"(id_kas, status, prn_no, payment_code) """) def init_closure_transfer_outbox_schema(prefix: str, cur): table = f"{prefix}_closure_transfer_outbox" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, clsrep_id INTEGER NOT NULL, id_kas TEXT NOT NULL, target_type TEXT NOT NULL DEFAULT 'hotel', reception_id INTEGER, reception_name TEXT NOT NULL DEFAULT '', typ_hotel INTEGER NOT NULL DEFAULT 0, payload TEXT NOT NULL DEFAULT '{{}}', response TEXT NOT NULL DEFAULT '{{}}', status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, sent_at TEXT ) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_closure_transfer_outbox_status" ON "{table}"(id_kas, status, clsrep_id) """) def init_prndef_schema(prefix: str, cur): table = f"{prefix}_prn_def" cur.execute(f""" DROP TABLE IF EXISTS "{table}" """) index_name = f"{prefix}_prndef_prnno" cur.execute(f""" DROP INDEX IF EXISTS "{index_name}" """) table = f"{prefix}_prndef" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, prn_no TEXT NOT NULL, prn_name TEXT NOT NULL, data TEXT NOT NULL ) """) cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if "id_term" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN id_term TEXT') index_name = f"{prefix}_prndef_prnno" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (prn_no) """) def init_bankterm_schema(prefix: str, cur): table = f"{prefix}_bankterm" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_term TEXT NOT NULL, term_name TEXT NOT NULL, term_data TEXT NOT NULL ) """) cur.execute(f'PRAGMA table_info("{table}")') #columns = {row[1] for row in cur.fetchall()} index_name = f"{prefix}_bankterm_id_term" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_term) """) def init_print_jobs_schema(prefix: str, cur): table = f"{prefix}_print_jobs" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, printer_no TEXT NOT NULL DEFAULT '', agent_id TEXT, job_type TEXT NOT NULL DEFAULT 'other', document_type TEXT NOT NULL DEFAULT '', receipt_no TEXT, required INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'queued', priority INTEGER NOT NULL DEFAULT 100, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 3, payload TEXT NOT NULL DEFAULT '{{}}', result TEXT NOT NULL DEFAULT '{{}}', error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, claimed_at TEXT, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_status" ON "{table}" (status, priority, id) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_kasa_prn" ON "{table}" (id_kas, printer_no, status) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_status_prn" ON "{table}" (status, printer_no, priority, id) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_print_jobs_receipt" ON "{table}" (receipt_no) """) def init_print_bon_counters_schema(prefix: str, cur): table = f"{prefix}_print_bon_counters" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( prn_no TEXT NOT NULL, bon_date TEXT NOT NULL, last_no INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (prn_no, bon_date) ) """) def init_printer_status_schema(prefix: str, cur): table = f"{prefix}_printer_status" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id_kas TEXT NOT NULL, prn_no TEXT NOT NULL, agent_id TEXT NOT NULL DEFAULT '', online INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'unknown', printer_type TEXT NOT NULL DEFAULT '', cmd32_on TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '', queue_size INTEGER NOT NULL DEFAULT 0, failed_jobs INTEGER NOT NULL DEFAULT 0, details TEXT NOT NULL DEFAULT '{{}}', checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id_kas, prn_no) ) """) def init_kasaucp_schema(prefix: str, cur): table = f"{prefix}_kasa_ucp" cur.execute(f""" DROP TABLE IF EXISTS "{table}" """) index_name = f"{prefix}_kasaucp_idkas" cur.execute(f""" DROP INDEX IF EXISTS "{index_name}" """) table = f"{prefix}_kasauct" cur.execute(f""" DROP TABLE IF EXISTS "{table}" """) index_name = f"{prefix}_kasauct_idkas" cur.execute(f""" DROP INDEX IF EXISTS "{index_name}" """) table = f"{prefix}_kasaucp" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, printers TEXT NOT NULL ) """) index_name = f"{prefix}_kasaucp_idkas" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas) """) def init_kasutxt_schema(prefix: str, cur): table = f"{prefix}_kas_utxt" cur.execute(f""" DROP TABLE IF EXISTS "{table}" """) index_name = f"{prefix}_kasutxt_idkas" cur.execute(f""" DROP INDEX IF EXISTS "{index_name}" """) table = f"{prefix}_kasutxt" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, riadky TEXT NOT NULL ) """) index_name = f"{prefix}_kasutxt_idkas" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas) """) def init_hladiny_schema(prefix: str, cur): table = f"{prefix}_hladiny" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, riadky TEXT NOT NULL ) """) index_name = f"{prefix}_hladiny_idkas" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas) """) def init_zlavy_schema(prefix: str, cur): table = f"{prefix}_zlavy" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, id_zlavy_hlav INTEGER NOT NULL, meno TEXT NOT NULL, data TEXT NOT NULL ) """) cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{prefix}_zlavy_idkas_hlav" ON "{table}" (id_kas, id_zlavy_hlav) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_zlavy_idkas" ON "{table}" (id_kas) """) drop_column_if_exists(cur.connection, table=table, column='aktivna') drop_column_if_exists(cur.connection, table=table, column='zmazana') drop_column_if_exists(cur.connection, table=table, column='dat_cas_zm') def init_uvery_schema(prefix: str, cur): table = f"{prefix}_uvery" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, hjmeno TEXT NOT NULL DEFAULT '', adresa1 TEXT NOT NULL DEFAULT '', adresa2 TEXT NOT NULL DEFAULT '', adresa3 TEXT NOT NULL DEFAULT '', ico TEXT NOT NULL DEFAULT '', icdph TEXT NOT NULL DEFAULT '', dic TEXT NOT NULL DEFAULT '' ) """) for column in ("hjmeno", "adresa1", "adresa2", "adresa3", "ico", "icdph", "dic"): add_column_if_not_exists(cur.connection, f'"{table}"', column, "TEXT NOT NULL DEFAULT ''") index_name = f"{prefix}_uvery_hjmeno" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (hjmeno COLLATE NOCASE) """) def init_clients_schema(prefix: str, cur): table = f"{prefix}_clients" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '', prn_no TEXT NOT NULL DEFAULT '', room_name TEXT NOT NULL DEFAULT '' ) """) for column in ("id_kas", "client_id", "prn_no", "room_name"): add_column_if_not_exists(cur.connection, f'"{table}"', column, "TEXT NOT NULL DEFAULT ''") index_name = f"{prefix}_clients_id_kas__client_id" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas COLLATE NOCASE, client_id COLLATE NOCASE) """) def init_recepcia_schema(prefix: str, cur): table = f"{prefix}_recepcia" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, hotel TEXT NOT NULL, hor_ip TEXT NOT NULL, hor_port TEXT NOT NULL, hor_meno TEXT NOT NULL, hor_heslo TEXT NOT NULL, api_meno TEXT NOT NULL, api_heslo TEXT NOT NULL, typ_hotel int NOT NULL, hor_prefix TEXT NOT NULL ) """) index_name = f"{prefix}_recepcia_id" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id) """) def init_hotplatby_schema(prefix: str, cur): table = f"{prefix}_hotplatby" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_hotel int, druh_pl text not null, hot_platba_id int, hot_karta_id int, hot_platba str, hot_karta str, po_uctoch int, payment text, id_meny int ) """) index_name = f"{prefix}_hotplatby_id" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_hotel, druh_pl) """) def init_mewsdph_schema(prefix: str, cur): table = f"{prefix}_mewsdph" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_hotel int, mews_taxrate text, koefdph float ) """) index_name = f"{prefix}_mewsdph_id" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_hotel, koefdph) """) def init_hotrastre_schema(prefix: str, cur): table = f"{prefix}_hotrastre" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, id_hotel int, c_druh int, raster1 int, raster2 int, raster3 int, raster4 int, raster5 int, raster6 int, raster7 int, raster8 int, raster9 int, dph1 float, dph2 float, dph3 float, dph4 float, dph5 float, dph6 float, dph7 float, dph8 float, dph9 float, tmatr TEXT NOT NULL, budova TEXT NOT NULL ) """) index_name = f"{prefix}_hot_rastre_idkas_druh_hotel_tmatr_budova" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas, c_druh, id_hotel, tmatr, budova) """) # pokladna index_name = f"{prefix}_hot_rastre_idkas" cur.execute(f""" CREATE INDEX IF NOT EXISTS "{index_name}" ON "{table}"(id_kas) """) def init_mewsrastre_schema(prefix: str, cur): table = f"{prefix}_mewsrastre" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, id_hotel int, c_druh int, raster1 text, raster2 text, raster3 text, raster4 text, raster5 text, raster6 text, raster7 text, raster8 text, raster9 text, dph1 float, dph2 float, dph3 float, dph4 float, dph5 float, dph6 float, dph7 float, dph8 float, dph9 float, tmatr TEXT NOT NULL, budova TEXT NOT NULL ) """) index_name = f"{prefix}_mewsrastre_idkas_druh_hotel_tmatr_budova" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas, c_druh, id_hotel, tmatr, budova) """) # pokladna index_name = f"{prefix}_mewsrastre_idkas" cur.execute(f""" CREATE INDEX IF NOT EXISTS "{index_name}" ON "{table}"(id_kas) """) def init_fidrastre_schema(prefix: str, cur): table = f"{prefix}_fidrastre" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, id_hotel int, c_druh int, raster text, raster1 text, raster2 text, raster3 text, raster4 text, raster5 text, raster6 text, raster7 text, raster8 text, raster9 text, dph1 float, dph2 float, dph3 float, dph4 float, dph5 float, dph6 float, dph7 float, dph8 float, dph9 float, tmatr TEXT NOT NULL, budova TEXT NOT NULL ) """) index_name = f"{prefix}_fidrastre_idkas_druh_hotel_tmatr_budova" cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" ON "{table}" (id_kas, c_druh, id_hotel, tmatr, budova) """) # pokladna index_name = f"{prefix}_fidrastre_idkas" cur.execute(f""" CREATE INDEX IF NOT EXISTS "{index_name}" ON "{table}"(id_kas) """) def init_tab_uct(prefix: str, cur): table = f"{prefix}_ucty" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( ucty_id INTEGER PRIMARY KEY AUTOINCREMENT, ucislo TEXT, id_kas TEXT NOT NULL, stul TEXT, room_name TEXT, blocked_by TEXT, closed_at TEXT, c_uzaverka INTEGER, data TEXT NOT NULL ) """) cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if "c_uzaverka" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN c_uzaverka INTEGER') if "room_name" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN room_name TEXT') # ucislo jen pro uzavřené účty cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS idx_ucty_ucislo ON "{table}"(ucislo) WHERE ucislo IS NOT NULL AND TRIM(ucislo) != '' """) # rychlé hledání otevřeného účtu cur.execute(f""" CREATE INDEX IF NOT EXISTS idx_ucty_open_stul ON "{table}"(stul) WHERE closed_at IS NULL OR TRIM(closed_at) = '' """) # pokladna cur.execute(f""" CREATE INDEX IF NOT EXISTS idx_ucty_kas ON "{table}"(id_kas) """) index_name = f"{prefix}_ucty_uzaverka" cur.execute(f""" CREATE INDEX IF NOT EXISTS "{index_name}" ON "{table}"(id_kas, c_uzaverka, ucislo) """) index_name = f"{prefix}_ucty_room_closed" cur.execute(f""" CREATE INDEX IF NOT EXISTS "{index_name}" ON "{table}"(id_kas, room_name, closed_at) """) def ensure_ucty_room_name_schema(prefix: str, cur) -> None: table = f"{prefix}_ucty" cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if "room_name" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN room_name TEXT') index_name = f"{prefix}_ucty_room_closed" cur.execute(f""" CREATE INDEX IF NOT EXISTS "{index_name}" ON "{table}"(id_kas, room_name, closed_at) """) def backfill_ucty_closure_links(prefix: str, cur): table_ucty = f"{prefix}_ucty" table_clsrep = f"{prefix}_clsrep" cur.execute(f""" UPDATE "{table_ucty}" AS u SET c_uzaverka = ( SELECT c.clsrep_id FROM "{table_clsrep}" c WHERE c.id_kas = u.id_kas AND u.ucislo >= c.ucislo_st AND u.ucislo <= c.ucislo_end ORDER BY c.clsrep_id DESC LIMIT 1 ) WHERE (u.c_uzaverka IS NULL OR u.c_uzaverka = 0) AND u.closed_at IS NOT NULL AND TRIM(COALESCE(u.ucislo, '')) != '' AND EXISTS ( SELECT 1 FROM "{table_clsrep}" c WHERE c.id_kas = u.id_kas AND u.ucislo >= c.ucislo_st AND u.ucislo <= c.ucislo_end ) """) #Milan 15.04.26 - doplnene polia def add_column_if_not_exists(conn, table: str, column: str, col_def: str): cur = conn.cursor() cur.execute(f"PRAGMA table_info({table})") columns = [row[1] for row in cur.fetchall()] if column not in columns: cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_def}") conn.commit() def drop_column_if_exists(conn, table: str, column: str): cur = conn.cursor() cur.execute(f"PRAGMA table_info([{table}])") columns = [row[1] for row in cur.fetchall()] if column in columns: cur.execute(f"ALTER TABLE [{table}] DROP COLUMN {column}") conn.commit() def init_tab_users(prefix: str, cur: sqlite3.Cursor): table = f'"{prefix}_users"' cur.execute(f""" CREATE TABLE IF NOT EXISTS {table} ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL UNIQUE, jazyk TEXT NOT NULL DEFAULT 'sk', heslo TEXT NOT NULL, heslo_karta TEXT NOT NULL, is_admin BOOL NOT NULL DEFAULT False, permits TEXT NOT NULL, payments TEXT NOT NULL, discounts TEXT NOT NULL, levels TEXT NOT NULL ) """) add_column_if_not_exists(cur.connection, table, "user_id", "TEXT NOT NULL UNIQUE") add_column_if_not_exists(cur.connection, table, "jazyk", "TEXT NOT NULL DEFAULT 'sk'") add_column_if_not_exists(cur.connection, table, "heslo_karta", "TEXT NOT NULL DEFAULT 'asdadada'") add_column_if_not_exists(cur.connection, table, "payments", "TEXT NOT NULL DEFAULT '[]'") add_column_if_not_exists(cur.connection, table, "discounts", "TEXT NOT NULL DEFAULT '[]'") add_column_if_not_exists(cur.connection, table, "levels", "TEXT NOT NULL DEFAULT '[]'") add_column_if_not_exists(cur.connection, table, "is_admin", "BOOL NOT NULL DEFAULT False") cur.execute(f"SELECT COUNT(*) FROM {table}") count = cur.fetchone()[0] if count == 0: cur.execute( f""" INSERT INTO {table} (user_id, name, jazyk, heslo, heslo_karta, is_admin, permits, payments, discounts, levels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( "SUP", "AltoAdmin", "sk", "123", "asdfasfgasf", True, json.dumps([]), json.dumps([]), json.dumps([]), json.dumps([]) ) ) def init_tab_cen(prefix: str, cur) -> None: table = f"{prefix}_cenik" cur.execute( f''' CREATE TABLE IF NOT EXISTS "{table}" (id INTEGER PRIMARY KEY AUTOINCREMENT, pokl TEXT NOT NULL, id_card INTEGER NOT NULL DEFAULT 0, c_druh INTEGER NOT NULL DEFAULT 0, druh TEXT NOT NULL DEFAULT '', spart TEXT NOT NULL DEFAULT '', prn_no TEXT NOT NULL DEFAULT '', data TEXT NOT NULL) ''' ) cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if "id_card" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN id_card INTEGER NOT NULL DEFAULT 0') if "c_druh" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN c_druh INTEGER NOT NULL DEFAULT 0') if "druh" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN druh TEXT NOT NULL DEFAULT \'\'') if "spart" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN spart TEXT NOT NULL DEFAULT \'\'') if "prn_no" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN prn_no TEXT NOT NULL DEFAULT \'\'') cur.execute( f'CREATE INDEX IF NOT EXISTS idx_{table}_pokl ON "{table}" (pokl)' ) cur.execute( f'CREATE INDEX IF NOT EXISTS idx_{table}_card ON "{table}" (id_card)' ) cur.execute( f'CREATE INDEX IF NOT EXISTS idx_{table}_prn ON "{table}" (pokl, prn_no)' ) def init_cenik_texty_schema(prefix: str, cur) -> None: table = f"{prefix}_cenik_texty" cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if columns and ("pokl" in columns or "popis" in columns): _rebuild_cenik_texty_schema(prefix, cur) return cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_card INTEGER NOT NULL DEFAULT 0, jazyk TEXT NOT NULL DEFAULT 'sk', d_name TEXT NOT NULL DEFAULT '', ch_name TEXT NOT NULL DEFAULT '', dat_cas_zm TEXT NOT NULL DEFAULT '', data TEXT NOT NULL DEFAULT '{{}}' ) """) for column, col_def in ( ("id_card", "INTEGER NOT NULL DEFAULT 0"), ("jazyk", "TEXT NOT NULL DEFAULT 'sk'"), ("d_name", "TEXT NOT NULL DEFAULT ''"), ("ch_name", "TEXT NOT NULL DEFAULT ''"), ("dat_cas_zm", "TEXT NOT NULL DEFAULT ''"), ("data", "TEXT NOT NULL DEFAULT '{}'"), ): add_column_if_not_exists(cur.connection, f'"{table}"', column, col_def) cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang_uq" ON "{table}" (id_card, jazyk) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang" ON "{table}" (id_card, jazyk) """) def _rebuild_cenik_texty_schema(prefix: str, cur) -> None: table = f"{prefix}_cenik_texty" backup = f"{table}_old" cur.execute(f'DROP TABLE IF EXISTS "{backup}"') cur.execute(f'ALTER TABLE "{table}" RENAME TO "{backup}"') cur.execute(f'DROP INDEX IF EXISTS "{prefix}_cenik_texty_pokl_card_lang"') cur.execute(f'DROP INDEX IF EXISTS "{prefix}_cenik_texty_card_lang"') cur.execute(f'DROP INDEX IF EXISTS "{prefix}_cenik_texty_card_lang_uq"') cur.execute(f""" CREATE TABLE "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_card INTEGER NOT NULL DEFAULT 0, jazyk TEXT NOT NULL DEFAULT 'sk', d_name TEXT NOT NULL DEFAULT '', ch_name TEXT NOT NULL DEFAULT '', dat_cas_zm TEXT NOT NULL DEFAULT '', data TEXT NOT NULL DEFAULT '{{}}' ) """) cur.execute(f'PRAGMA table_info("{backup}")') old_columns = {row[1] for row in cur.fetchall()} select_columns = [ "id_card", "jazyk", "d_name", "ch_name", "dat_cas_zm", ] if all(column in old_columns for column in select_columns): cur.execute( f''' SELECT id_card, jazyk, d_name, ch_name, dat_cas_zm FROM "{backup}" ORDER BY id ''' ) rows = {} for id_card, jazyk, d_name, ch_name, dat_cas_zm in cur.fetchall(): item = data.CenikText( id_card=int(id_card or 0), jazyk=jazyk or "sk", d_name=d_name or "", ch_name=ch_name or "", dat_cas_zm=dat_cas_zm or "", ) rows[(item.id_card, item.jazyk)] = ( item.id_card, item.jazyk, item.d_name, item.ch_name, item.dat_cas_zm, item.model_dump_json(), ) cur.executemany( f''' INSERT OR REPLACE INTO "{table}" (id_card, jazyk, d_name, ch_name, dat_cas_zm, data) VALUES (?, ?, ?, ?, ?, ?) ''', list(rows.values()), ) cur.execute(f'DROP TABLE IF EXISTS "{backup}"') cur.execute(f""" CREATE UNIQUE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang_uq" ON "{table}" (id_card, jazyk) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_cenik_texty_card_lang" ON "{table}" (id_card, jazyk) """) def init_cook_items_schema(prefix: str, cur) -> None: table = f"{prefix}_cook_items" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY AUTOINCREMENT, id_kas TEXT NOT NULL, stul TEXT NOT NULL DEFAULT '', room_name TEXT NOT NULL DEFAULT '', pos_name TEXT NOT NULL DEFAULT '', waiter_name TEXT NOT NULL DEFAULT '', receipt_no TEXT, bon_no INTEGER NOT NULL DEFAULT 0, bon_date TEXT NOT NULL DEFAULT '', event_type TEXT NOT NULL DEFAULT 'bon', status TEXT NOT NULL DEFAULT 'new', id_card INTEGER NOT NULL DEFAULT 0, c_druh INTEGER NOT NULL DEFAULT 0, druh TEXT NOT NULL DEFAULT '', prn_no TEXT NOT NULL DEFAULT '', line_id TEXT NOT NULL DEFAULT '', group_id TEXT NOT NULL DEFAULT '', item_name TEXT NOT NULL DEFAULT '', quantity REAL NOT NULL DEFAULT 0, delitel INTEGER NOT NULL DEFAULT 1, messages TEXT NOT NULL DEFAULT '[]', ordered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, payload TEXT NOT NULL DEFAULT '{{}}' ) """) cur.execute(f'PRAGMA table_info("{table}")') columns = {row[1] for row in cur.fetchall()} if "bon_no" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN bon_no INTEGER NOT NULL DEFAULT 0') if "bon_date" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN bon_date TEXT NOT NULL DEFAULT \'\'') cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_cook_items_status" ON "{table}" (status, ordered_at) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_cook_items_kas_prn" ON "{table}" (id_kas, prn_no, status) """) cur.execute(f""" CREATE INDEX IF NOT EXISTS "{prefix}_cook_items_bon" ON "{table}" (prn_no, bon_date, bon_no) """) def default_payment_types() -> list[data.PaymentType]: return [ data.PaymentType( code="CASH", name="Hotově", unit="CZK", rate=1.,allow_partial=True, is_cash=True, handler=None,color=None, is_default=True, ), data.PaymentType( code="CARD", name="Karta", unit="CZK", poradie=20, rate=1.,allow_partial=True, is_cash=False, handler=None, #charge_kredit_c.py, color=None, is_default=False, is_bankterm=True, ), data.PaymentType( code="EURO", name="Euro", unit="EUR", poradie=30, rate=25.,allow_partial=True, is_cash=True, handler=None,color=None, is_default=False, ), ] def _payment_to_db_tuple(id_kas: str, payment: data.PaymentType) -> tuple: p_kopii = getattr(payment, "p_kopii", 1) return ( id_kas, payment.code, payment.name, payment.unit, payment.rate, payment.poradie, max(int(p_kopii if p_kopii is not None else 1), 0), int(getattr(payment, "round50", 0) or 0), int(bool(payment.allow_partial)), int(bool(payment.is_cash)), payment.handler, payment.color, int(bool(payment.is_default)), int(bool(payment.fiscal)), int(bool(getattr(payment, "is_bankterm", False))), int(getattr(payment, "odvod", 0) or 0), str(getattr(payment, "odovzdat", "") or ""), ) def _row_to_payment_type(row) -> data.PaymentType: ( code, name, unit, rate, poradie, p_kopii, round50, allow_partial, is_cash, handler, color, is_default, fiscal, is_bankterm, odvod, odovzdat, ) = row return data.PaymentType( code=code, name=name, unit=unit, rate=rate, poradie=poradie or 0, p_kopii=max(int(p_kopii or 0), 0), round50=int(round50 or 0), allow_partial=bool(allow_partial), is_cash=bool(is_cash), handler=handler or None, color=color or None, is_default=bool(is_default), fiscal=bool(fiscal), is_bankterm=bool(is_bankterm), odvod=int(odvod or 0), odovzdat=str(odovzdat or ""), ) def _insert_platby_cur( cur, table: str, id_kas: str, payments: list[data.PaymentType], replace_existing: bool = True, ) -> None: if replace_existing: cur.execute(f'DELETE FROM "{table}" WHERE id_kas=?', (id_kas,)) cur.executemany( f""" INSERT OR REPLACE INTO "{table}" ( id_kas, code, name, unit, rate, poradie, p_kopii, round50, allow_partial, is_cash, handler, color, is_default, fiscal, is_bankterm, odvod, odovzdat ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [_payment_to_db_tuple(id_kas, payment) for payment in payments], ) def init_platby_schema(prefix: str, cur) -> None: table = f"{prefix}_platby" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id_kas TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, unit TEXT NOT NULL, rate REAL NOT NULL DEFAULT 1, poradie INTEGER NOT NULL DEFAULT 0, p_kopii INTEGER NOT NULL DEFAULT 1, round50 INTEGER NOT NULL DEFAULT 0, allow_partial INTEGER NOT NULL DEFAULT 1, is_cash INTEGER NOT NULL DEFAULT 0, handler TEXT, color TEXT, is_default INTEGER NOT NULL DEFAULT 0, fiscal INTEGER NOT NULL DEFAULT 1, is_bankterm INTEGER NOT NULL DEFAULT 0, odvod INTEGER NOT NULL DEFAULT 0, odovzdat TEXT NOT NULL DEFAULT '', PRIMARY KEY (id_kas, code) ) """) cur.execute(f'PRAGMA table_info("{table}")') columns = {str(row[1]) for row in cur.fetchall()} if "p_kopii" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN p_kopii INTEGER NOT NULL DEFAULT 1') if "round50" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN round50 INTEGER NOT NULL DEFAULT 0') if "is_bankterm" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN is_bankterm INTEGER NOT NULL DEFAULT 0') if "odvod" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN odvod INTEGER NOT NULL DEFAULT 0') if "odovzdat" not in columns: cur.execute(f'ALTER TABLE "{table}" ADD COLUMN odovzdat TEXT NOT NULL DEFAULT ""') cur.execute(f'UPDATE "{table}" SET p_kopii=1 WHERE p_kopii IS NULL OR p_kopii < 0') cur.execute(f'UPDATE "{table}" SET round50=0 WHERE round50 IS NULL') cur.execute(f'UPDATE "{table}" SET odvod=0 WHERE odvod IS NULL') cur.execute(f'UPDATE "{table}" SET odovzdat="" WHERE odovzdat IS NULL') seed_platby_from_setup_if_empty(prefix, cur) def seed_platby_from_setup_if_empty(prefix: str, cur) -> None: setup_table = f"{prefix}_setup" platby_table = f"{prefix}_platby" cur.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (setup_table,), ) if not cur.fetchone(): return cur.execute(f'SELECT id_kas, data FROM "{setup_table}"') for id_kas, raw_json in cur.fetchall(): cur.execute( f'SELECT COUNT(*) FROM "{platby_table}" WHERE id_kas=?', (id_kas,), ) has_payments = cur.fetchone()[0] > 0 try: setup = data.PosSetup.model_validate_json(raw_json) except Exception as e: logger.error(f"Invalid setup JSON while migrating payments {prefix}/{id_kas}: {e}") setup = data.PosSetup(id_kas=id_kas) if not has_payments: payments = setup.platby or default_payment_types() _insert_platby_cur( cur, platby_table, id_kas, payments, replace_existing=True, ) if setup.platby: setup.platby = [] normalized_json = json.dumps( setup.model_dump(exclude_none=False), ensure_ascii=False, separators=(",", ":"), sort_keys=True, ) cur.execute( f'UPDATE "{setup_table}" SET data=? WHERE id_kas=?', (normalized_json, id_kas), ) def init_tab_setup(prefix: str, cur, zakazka: str) -> None: table = f"{prefix}_setup" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id_kas TEXT PRIMARY KEY, data TEXT NOT NULL ) """) setup = data.PosSetup( pos_name=f"POS {zakazka}", id_kas="01", pokladna="Hlavni", allow_price_edit=True, offline_allowed=True, platby=[], messages=[], ) normalized_json = json.dumps( setup.model_dump(exclude_none=False), ensure_ascii=False, separators=(",", ":"), sort_keys=False, ) cur.execute( f'INSERT OR IGNORE INTO "{table}" (id_kas, data) VALUES (?, ?)', (setup.id_kas, normalized_json), ) def init_setup_parameters_schema(prefix: str, cur) -> None: table = f"{prefix}_setup_parameters" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id_kas TEXT NOT NULL, var_name TEXT NOT NULL, var_value TEXT NOT NULL, var_type TEXT NOT NULL, PRIMARY KEY (id_kas, var_name) ) """) def init_postgres_connection_schema(prefix: str, cur) -> None: table = f"{prefix}_postgres_connection" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0, host TEXT NOT NULL DEFAULT '', port INTEGER NOT NULL DEFAULT 5432, database_name TEXT NOT NULL DEFAULT '', user_name TEXT NOT NULL DEFAULT '', password TEXT NOT NULL DEFAULT '', schema_name TEXT NOT NULL DEFAULT 'food600', sslmode TEXT NOT NULL DEFAULT 'prefer', connect_timeout INTEGER NOT NULL DEFAULT 5, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """) cur.execute(f""" INSERT OR IGNORE INTO "{table}" ( id, enabled, host, port, database_name, user_name, password, schema_name, sslmode, connect_timeout ) VALUES (1, 0, '', 5432, '', '', '', 'food600', 'prefer', 5) """) def table_exists(table_name: str) -> bool: with get_db() as conn: cur = conn.cursor() cur.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,) ) return cur.fetchone() is not None # ----------------------------------------------------- # FastApi Authentificacion # ----------------------------------------------------- def new_access_token() -> str: return secrets.token_urlsafe(32) def new_refresh_token() -> str: return secrets.token_urlsafe(48) def access_expiry(): return datetime.utcnow() + timedelta(minutes=ACCESS_MINUTES) def refresh_expiry(): return datetime.utcnow() + timedelta(days=REFRESH_DAYS) @contextmanager def get_db(): conn = sqlite3.connect(dbt) try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def auth_ctx( authorization: str = Header(None), x_client_id: str = Header(None) ) -> tuple[str, str, str]: return check_auth(authorization, x_client_id) def check_auth(auth: str, client_id: str) -> tuple[str, str, str]: if not auth or not auth.startswith("Bearer "): raise HTTPException(401, "Missing Bearer token") if not client_id: raise HTTPException(401, "Missing X-Client-ID") token = auth[7:] with get_db() as conn: cur = conn.cursor() cur.execute(""" SELECT prefix, user FROM tokens WHERE access_token=? AND client_id=? AND expires_at > CURRENT_TIMESTAMP """, (token, client_id)) row = cur.fetchone() if not row: raise HTTPException(401, "Invalid or expired token") cur.execute(""" UPDATE tokens SET last_seen=CURRENT_TIMESTAMP WHERE access_token=? AND client_id=? """, (token, client_id)) return row[0], row[1], client_id # prefix, user, client_id @app.post("/login/") def login(data: data.LoginData, x_client_id: str = Header(None)): if not x_client_id: raise HTTPException(400, "Missing X-Client-ID") with get_db() as conn: cur = conn.cursor() # Ověření zakázky cur.execute( "SELECT id, heslo, jmeno_zakazky FROM zakazky WHERE uzivatel=?", (data.username,) ) row = cur.fetchone() if not row or data.password != p.decode(row[1]): raise HTTPException(401, "Bad credentials") prefix = f"{row[0]:05d}" zakazka = row[2] # Inicializace schématu init_user_schema(cur, prefix, zakazka) # Cleanup mrtvých klientů (heartbeat) cleanup_dead_clients_cur(cur, prefix, data.id_kas) # Kontrola duplicitního aktivního klienta cur.execute(""" SELECT 1 FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND client_id=? """, (prefix, data.id_kas, x_client_id)) if cur.fetchone(): raise HTTPException( 409, "Tento terminál je již aktivní" ) # Registrace klienta do heartbeat now = time.time() cur.execute(""" INSERT INTO heartbeat_clients (prefix, id_kas, client_id, last_seen) VALUES (?, ?, ?, ?) """, (prefix, data.id_kas, x_client_id, now)) # Token acc = new_access_token() ref = new_refresh_token() cur.execute(""" INSERT INTO tokens (user, client_id, prefix, access_token, refresh_token, expires_at, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user, client_id) DO UPDATE SET access_token=excluded.access_token, refresh_token=excluded.refresh_token, expires_at=excluded.expires_at, refresh_expires_at=excluded.refresh_expires_at, last_seen=CURRENT_TIMESTAMP """, ( data.username, x_client_id, prefix, acc, ref, access_expiry(), refresh_expiry() )) conn.commit() return { "access_token": acc, "refresh_token": ref, "token_type": "Bearer", "version_API": version, "database_name": dbt, } @app.post("/refresh/") def refresh( req: data.RefreshRequest, x_client_id: str = Header(None)): if not x_client_id: raise HTTPException(400, "Missing X-Client-ID") if not req.refresh_token.startswith("Bearer "): raise HTTPException(401, "Missing Bearer prefix") raw = req.refresh_token[7:] with get_db() as conn: cur = conn.cursor() cur.execute(""" SELECT user FROM tokens WHERE refresh_token=? AND client_id=? AND refresh_expires_at > CURRENT_TIMESTAMP """, (raw, x_client_id)) row = cur.fetchone() if not row: raise HTTPException(401, "Invalid refresh token") new_acc = new_access_token() cur.execute(""" UPDATE tokens SET access_token=?, expires_at=?, last_seen=CURRENT_TIMESTAMP WHERE client_id=? """, (new_acc, access_expiry(), x_client_id)) return {"access_token": new_acc} @app.post("/logout/") def logout( id_kas: str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth heartbeat_remove(prefix, id_kas, client_id) token_remove(prefix, client_id) return {"ok": True} def token_remove(prefix: str, client_id: str): with get_db() as conn: cur = conn.cursor() cur.execute(""" DELETE FROM tokens WHERE prefix=? AND client_id=? """, (prefix, client_id)) conn.commit() #------------------------- # heartbeat #------------------------- @app.post("/heartbeat/") def heartbeat( id_kas: str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth return heartbeat_upsert(prefix, id_kas, client_id, user) def heartbeat_upsert( prefix: str, id_kas: str, client_id: str, user: str | None = None, ): now = time.time() with get_db() as conn: cur = conn.cursor() cur.execute(""" INSERT INTO heartbeat_clients (prefix, id_kas, client_id, user, last_seen) VALUES (?, ?, ?, ?, ?) ON CONFLICT(prefix, id_kas, client_id) DO UPDATE SET user=excluded.user, last_seen=excluded.last_seen """, ( prefix, id_kas, client_id, user, now )) conn.commit() return {"ok": True} def heartbeat_remove(prefix: str, id_kas: str, client_id: str): with get_db() as conn: cur = conn.cursor() # uvolni zablokované účty table = f"{prefix}_ucty" cur.execute(f""" UPDATE "{table}" SET blocked_by = NULL WHERE id_kas = ? AND blocked_by LIKE ? """, (id_kas, f"{client_id}|%")) # smaž heartbeat cur.execute(""" DELETE FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND client_id=? """, (prefix, id_kas, client_id)) conn.commit() def find_dead_clients( prefix: str, id_kas: str, *, now: float | None = None, ) -> list[tuple[str, str]]: now = now or time.time() with get_db() as conn: cur = conn.cursor() cur.execute( """ SELECT id_kas, client_id FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND (? - last_seen) > ? """, (prefix, id_kas, now, HEARTBEAT_TIMEOUT), ) return cur.fetchall() def unblock_accounts_of_dead_client(prefix: str, id_kas: str, client_id: str): table = f"{prefix}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute( f""" UPDATE "{table}" SET blocked_by = NULL WHERE id_kas = ? AND blocked_by LIKE ? """, (id_kas, f"{client_id}|%"), ) def remove_dead_client(prefix: str, id_kas: str, client_id: str): with get_db() as conn: cur = conn.cursor() cur.execute(""" DELETE FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND client_id=? """, (prefix, id_kas, client_id)) def cleanup_dead_clients(prefix: str, id_kas: str): dead = find_dead_clients(prefix, id_kas=id_kas) for _, client_id in dead: logger.warning( f"Heartbeat timeout → releasing client {client_id} ({id_kas})" ) unblock_accounts_of_dead_client(prefix, id_kas, client_id) remove_dead_client(prefix, id_kas, client_id) def find_dead_clients_cur( cur, prefix: str, id_kas: str, *, now: float, ) -> list[tuple[str, str]]: cur.execute( """ SELECT id_kas, client_id FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND (? - last_seen) > ? """, (prefix, id_kas, now, HEARTBEAT_TIMEOUT), ) return cur.fetchall() def unblock_accounts_of_dead_client_cur(cur, prefix: str, id_kas: str, client_id: str): table = f"{prefix}_ucty" cur.execute( f""" UPDATE "{table}" SET blocked_by = NULL WHERE id_kas = ? AND blocked_by LIKE ? """, (id_kas, f"{client_id}|%"), ) def remove_dead_client_cur(cur, prefix: str, id_kas: str, client_id: str): cur.execute( """ DELETE FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND client_id=? """, (prefix, id_kas, client_id), ) def cleanup_dead_clients_cur(cur, prefix: str, id_kas: str, *, now: float | None = None): now = now or time.time() dead = find_dead_clients_cur(cur, prefix, id_kas, now=now) for _, client_id in dead: logger.warning(f"Heartbeat timeout → releasing client {client_id} ({id_kas})") unblock_accounts_of_dead_client_cur(cur, prefix, id_kas, client_id) remove_dead_client_cur(cur, prefix, id_kas, client_id) return dead # volitelné (pro log / debug) # --------------------------------------------------------------------------------------- # L.L. (22.06.2026 Aktualizácia cenníka, nastavení pokladne a mapy stolov # na zaklade zmeny datumu a času poslednej zmeny v s_zmeny # --------------------------------------------------------------------------------------- @app.get("/poslednaZmenaDatFoodMan", response_model=data.FoodManDataChange) def posledna_zmena_dat_foodman( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET poslednaZmenaDatFoodMan: prefix={prefix}") with get_db() as conn: cur = conn.cursor() return ensure_foodman_data_change_cur(cur, prefix) # ------------------------------------- # --- users # ------------------------------------- @app.get("/users/raw/") def load_users_api( id_kas: str = "", auth: tuple[str, str, str] = Depends(auth_ctx), ): try: prefix, _, _ = auth with get_db() as conn: users = load_users_table(conn, prefix) return users except Exception as e: return {"ok": False, "error": str(e)} #Milan 15.04.26 def load_users_table(conn, prefix: str) -> list[dict]: table = f'"{prefix}_users"' cur = conn.cursor() cur.execute(f""" SELECT id, name, permits, user_id, payments, discounts, is_admin, levels, jazyk FROM {table} ORDER BY id """) rows = cur.fetchall() out = [] for r in rows: out.append({ "id": r[0], "name": r[1], "permits": json.loads(r[2] or "[]"), "user_id": r[3], "payments": json.loads(r[4] or "[]"), "discounts": json.loads(r[5] or "[]"), "is_admin": r[6], "levels": json.loads(r[7] or "[]"), "jazyk": r[8] or "sk", }) return out @app.post("/users/reset/") def reset_users_api(users: list[data.UserIn] = Body(...), id_kas: str = "", auth: tuple[str, str, str] = Depends(auth_ctx),): try: prefix, _, _ = auth if not users: raise ValueError("Seznam uživatelů je prázdný") with get_db() as conn: #try: # reset_users_table(conn, prefix, users) #finally: # conn.close() reset_users_table(conn, prefix, users) return {"ok": True} except Exception as e: return {"ok": False, "error": str(e)} #Milan 15.04.26 def reset_users_table(conn, prefix: str, users: list[data.UserIn]): table = f'"{prefix}_users"' cur = conn.cursor() try: # DROP cur.execute(f"DROP TABLE IF EXISTS {table}") except Exception as e: print("INSERT ERROR:", e) raise try: # CREATE cur.execute(f""" CREATE TABLE {table} ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL UNIQUE, jazyk TEXT NOT NULL DEFAULT 'sk', heslo TEXT NOT NULL, heslo_karta TEXT NOT NULL, is_admin BOOL NOT NULL DEFAULT False, permits TEXT NOT NULL, payments TEXT NOT NULL, discounts TEXT NOT NULL, levels TEXT NOT NULL ) """) except Exception as e: print("INSERT ERROR:", e) raise # INSERT for u in users: try: cur.execute(f""" INSERT INTO {table} (name, user_id, jazyk, heslo, heslo_karta, is_admin, permits, payments, discounts, levels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( u.name, u.user_id, u.jazyk, u.heslo, u.heslo_karta, u.is_admin, json.dumps([p.model_dump() for p in (u.permits or [])]), json.dumps([p.model_dump() for p in (u.payments or [])]), json.dumps([p.model_dump() for p in (u.discounts or [])]), json.dumps([p.model_dump() for p in (u.levels or [])]), )) except Exception as e: print("INSERT ERROR:", e) raise cur.execute(f"SELECT COUNT(*) FROM {table}") print("POCET VLOZENYCH USERU:", cur.fetchone()) conn.commit() def init_limit_locks_schema(prefix: str, cur) -> None: table = f"{prefix}_limit_locks" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( lock_key TEXT PRIMARY KEY, id_kas TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '', user TEXT NOT NULL DEFAULT '', id_limit INTEGER NOT NULL DEFAULT 0, id_den INTEGER NOT NULL DEFAULT 0, locked_at REAL NOT NULL DEFAULT 0 ) """) #Milan 15.04.26 - doplneny kas ako parameter a doplnene nacitanie permitions, payments, discounts a levels pre aktualnu kasu def login_user_db(cur: sqlite3.Cursor, prefix: str, heslo: str, kas: str, pl1:data.PaymentType) -> data.UserLoginOut | None: heslo = heslo.strip() table = f'"{prefix}_users"' #print(f"heslo {heslo}\ntable {table}\n") cur.execute( f""" SELECT id, name, user_id, jazyk, is_admin, permits, payments, discounts, levels FROM {table} WHERE heslo = ? or heslo_karta = ? """, (heslo,heslo)) row = cur.fetchone() if not row: return None id_, name_, user_id_, jazyk_, is_admin_, permits_, payments_, discounts_, levels_ = row permits_data = json.loads(permits_ or "[]") payments_data = json.loads(payments_ or "[]") discounts_data = json.loads(discounts_ or "[]") levels_data = json.loads(levels_ or "[]") permits=[ p for block in permits_data if block["id_kas"] == kas for p in block["permits"] ] payments=[ p for block in payments_data if block["id_kas"] == kas for p in block["payments"] ] platby= [ p for p in pl1 if p.code in payments ] if not platby: platby = [p for p in pl1 if p.is_default] discounts=[ d for block in discounts_data if block["id_kas"] == kas for d in block["discounts"] ] levels=[ l for block in levels_data if block["id_kas"] == kas for l in block["levels"] ] logger.info(f"User login: prefix={prefix} name={name_} permits={permits_}") return data.UserLoginOut( id=id_, name=name_, user_id=user_id_, jazyk=jazyk_ or "sk", is_admin=is_admin_, permits=permits, payments=platby, discounts=discounts, levels=levels) def all_permits() -> list[str]: return [p.code for p in data.Perm] def all_payments(prefix, id_kas: str) -> list[data.PaymentType]: data=get_setup_platby_from_db(prefix, id_kas) return data def all_discounts(prefix, id_kas: str) -> list[str]: zlavy=get_setup_discounts_from_db(prefix, id_kas) return zlavy #Milan 15.04.26 - nacitanie permitions aj s description podla jayzkovej mutacie ziadatela def get_permits(lang: str = "sk") -> list[dict[str, str]]: lang = normalize_lang(lang) vysledok = [] for p in data.Perm: match lang: case "sk": text = p.descriptionsk case "cs": text = p.descriptioncz case "it": text = p.descriptionit case "en": text = p.descriptionen case "pl": text = p.descriptionpl vysledok.append({ "code": p.code, "text": text }) return vysledok #Milan 15.04.26 - doplneny parameter, ktorym sa definuje pozadovana jazykova mutacia @app.get("/permits/", response_model=list[data.PermitOut]) def get_permits_endpoint(lang: str = Query("sk", enum=["sk", "cs", "cz", "it", "en", "pl"])): return JSONResponse(content=get_permits(lang),media_type="application/json; charset=utf-8") #return get_permits(lang) #Milan 15.04.26 - test slovenskeho pocitaneho hesla a doplnenie zoznamu povolenych platieb a zliav pre aktualnu kasu @app.post("/users/login/", response_model=data.UserLoginOut) def user_login( login_data: data.UserLoginIn, auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, _, _ = auth # ADMIN LOGIN (mimo DB) platby=all_payments(prefix,login_data.kas) discounts=all_discounts(prefix,login_data.kas) levels=[] if login_data.heslo == data.admheslo(): logger.info(f"Alto administrator LogIn {all_permits()}") return data.UserLoginOut( id=0, name="AltoAdmin", user_id="SUP", jazyk="sk", is_admin=True, permits=all_permits(), payments=platby, discounts=discounts, levels=levels ) if login_data.heslo == data.admskheslo(): logger.info(f"Alto administrator LogIn {all_permits()}") return data.UserLoginOut( id=0, name="AltoAdmin", user_id="SUP", jazyk="sk", is_admin=True, permits=all_permits(), payments=platby, discounts=discounts, levels=levels ) # NORMAL USER LOGIN with get_db() as conn: cur = conn.cursor() cisnik = login_user_db(cur, prefix, login_data.heslo, login_data.kas, platby) if not cisnik: raise HTTPException(401, "Neplatné heslo") return cisnik #Milan 15.04.26 def get_users_db(cur: sqlite3.Cursor, prefix: str) -> list[data.UserOut]: cur.execute(f""" SELECT id, name, user_id, jazyk, is_admin, permits, payments, discounts, levels FROM {prefix}_users ORDER BY id """) rows = cur.fetchall() return [ data.UserOut( id=id_, name=name, user_id=user_id, jazyk=jazyk or "sk", is_admin=is_admin, permits=json.loads(permits), payments=json.loads(payments), discounts=json.loads(discounts), levels=json.loads(levels) ) for id_, name, user_id, jazyk, is_admin, permits, payments, discounts, levels in rows ] #Milan 15.04.26 def create_user_db(cur: sqlite3.Cursor, prefix: str, user: data.UserIn) -> int: cur.execute( f""" INSERT INTO {prefix}_users (name, user_id, jazyk, heslo, heslo_karta, permits, payments, discounts, is_admin, levels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user.name, user.user_id, user.jazyk, user.heslo, user.heslo_karta, json.dumps([p.model_dump() for p in (user.permits or [])]), json.dumps([p.model_dump() for p in (user.payments or [])]), json.dumps([p.model_dump() for p in (user.discounts or [])]), user.is_admin, json.dumps([p.model_dump() for p in (user.levels or [])]) ) ) return cur.lastrowid def delete_user_db(cur: sqlite3.Cursor, prefix: str, user_id: int) -> bool: cur.execute( f"DELETE FROM {prefix}_users WHERE id = ?", (user_id,) ) return cur.rowcount > 0 @app.get("/users/", response_model=list[data.UserOut]) def get_users( auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, _, _ = auth with get_db() as conn: cur = conn.cursor() return get_users_db(cur, prefix) #Milan 15.04.26 @app.post("/users/", response_model=data.UserOut) def create_user( user: data.UserIn, auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, _, _ = auth with get_db() as conn: cur = conn.cursor() try: userid = create_user_db(cur, prefix, user) conn.commit() except sqlite3.IntegrityError: raise HTTPException(409, "Uživatel s tímto jménem již existuje") return data.UserOut( id=userid, name=user.name, user_id=user.user_id, jazyk=user.jazyk, is_admin=user.is_admin, permits=user.permits, payments=user.payments, discounts=user.discounts, levels=user.levels) @app.delete("/users/{user_id}") def delete_user( user_id: int, auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, _, _ = auth with get_db() as conn: cur = conn.cursor() ok = delete_user_db(cur, prefix, user_id) conn.commit() if not ok: raise HTTPException(404, "Uživatel nenalezen") return {"status": "deleted", "id": user_id} # ------------------------ # setup # ----------------------- PROTECTED_SETUP_PARAMETER_NAMES = { "platby", } SETUP_PARAMETER_ALIASES = { "def_cenhla": "default_price_level", } def ensure_min_payment_types(setup: data.PosSetup) -> bool: changed = False existing = {p.code for p in setup.platby} for p in default_payment_types(): if p.code not in existing: setup.platby.append(p) changed = True # zajistit jen jeden default defaults = [p for p in setup.platby if p.is_default] if len(defaults) > 1: for p in defaults[1:]: p.is_default = False changed = True return changed def _import_parameters_path() -> Path: return Path(__file__).with_name("import_parameters.json") def _clean_import_parameter(item: dict) -> dict: cleaned = dict(item) var_type = cleaned.get("var_type") or cleaned.get("var_typ") or "C" raw_value = ( cleaned["parsed_value"] if "parsed_value" in cleaned else cleaned.get("value") ) normalized = data.normalize_setup_parameter_value(raw_value, var_type) cleaned["value"] = normalized cleaned["parsed_value"] = normalized return cleaned def load_import_parameter_definitions() -> list[dict]: path = _import_parameters_path() if not path.exists(): logger.warning(f"Import parameter file missing: {path}") return [] with path.open("r", encoding="utf-8-sig") as f: raw = json.load(f) return [ _clean_import_parameter(item) for item in raw if isinstance(item, dict) and (item.get("key") or item.get("var_name")) ] def get_default_setup_parameters() -> dict[str, data.SetupParameterValue]: params = {} for item in load_import_parameter_definitions(): param = data.SetupParameterValue.model_validate(item) params[param.var_name] = param return params def _encode_setup_parameter_value(value, var_type: str) -> str: normalized = data.normalize_setup_parameter_value(value, var_type) typ = (var_type or "").strip().upper() if typ == "L": return "1" if normalized else "0" return "" if normalized is None else str(normalized) def _decode_setup_parameter_value(value, var_type: str): return data.normalize_setup_parameter_value(value, var_type) def get_setup_parameters_from_db(prefix: str, id_kas: str) -> dict[str, data.SetupParameterValue]: table = f"{prefix}_setup_parameters" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT var_name, var_value, var_type FROM "{table}" WHERE id_kas=?', (id_kas,), ) rows = cur.fetchall() return { var_name: data.SetupParameterValue( var_name=var_name, var_value=_decode_setup_parameter_value(var_value, var_type), var_type=var_type, ) for var_name, var_value, var_type in rows } def get_effective_setup_parameters(prefix: str, id_kas: str) -> list[data.SetupParameterValue]: params = get_default_setup_parameters() for name in PROTECTED_SETUP_PARAMETER_NAMES: params.pop(name, None) for var_name, param in get_setup_parameters_from_db(prefix, id_kas).items(): if var_name in params: params[var_name] = param return list(params.values()) def apply_setup_parameters(prefix: str, id_kas: str, setup: data.PosSetup) -> data.PosSetup: for param in get_effective_setup_parameters(prefix, id_kas): setattr(setup, param.var_name, param.var_value) alias = SETUP_PARAMETER_ALIASES.get(param.var_name) if alias: setattr(setup, alias, param.var_value) return setup def save_setup_parameters_db( prefix: str, id_kas: str, parameters: list[data.SetupParameterValue], ) -> dict: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_setup_parameters" allowed_names = set(get_default_setup_parameters()) normalized = [] for raw in parameters: param = data.SetupParameterValue.model_validate(raw) if param.var_name in PROTECTED_SETUP_PARAMETER_NAMES: continue if param.var_name and (not allowed_names or param.var_name in allowed_names): normalized.append(param) with get_db() as conn: cur = conn.cursor() cur.executemany( f""" INSERT OR REPLACE INTO "{table}" (id_kas, var_name, var_value, var_type) VALUES (?, ?, ?, ?) """, [ ( id_kas, p.var_name, _encode_setup_parameter_value(p.var_value, p.var_type), p.var_type, ) for p in normalized ], ) conn.commit() return {"ok": True, "count": len(normalized)} @app.get("/setup/", response_model=data.PosSetup) def get_setup(id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth logger.info(f"GET setup: prefix={prefix} pokladna={id_kas}") result = get_setup_from_db(prefix, id_kas) return result #return get_setup_from_db(prefix, id_kas) @app.get("/import_parameters/") @app.get("/setup/parameters/import/") def get_import_parameters( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET import parameters: prefix={prefix} user={user}") return JSONResponse(content=load_import_parameter_definitions(),media_type="application/json; charset=utf-8") #return load_import_parameter_definitions() @app.get("/setup/parameters/", response_model=list[data.SetupParameterValue]) def get_setup_parameters( id_kas: str, include_defaults: bool = True, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET setup parameters: prefix={prefix} pokladna={id_kas}") if include_defaults: return get_effective_setup_parameters(prefix, id_kas) return list(get_setup_parameters_from_db(prefix, id_kas).values()) @app.post("/setup/parameters/") def update_setup_parameters( id_kas: str, parameters: list[data.SetupParameterValue], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( f"POST setup parameters: prefix={prefix} pokladna={id_kas} " f"count={len(parameters)}" ) return save_setup_parameters_db(prefix, id_kas, parameters) def _encode_secret(value: str) -> str: value = str(value or "") return p.code(value) if value else "" def _decode_secret(value: str) -> str: value = str(value or "") if not value: return "" try: return p.decode(value) except Exception: return "" def _bool_setup_value(value) -> bool: if isinstance(value, bool): return value return str(value or "").strip().lower() in {"1", "true", "t", "yes", "y", "ano", "a"} def get_postgres_connection_db(prefix: str, include_password: bool = True) -> data.PostgresConnection: table = f"{prefix}_postgres_connection" with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT enabled, host, port, database_name, user_name, password, schema_name, sslmode, connect_timeout FROM "{table}" WHERE id=1 """ ) row = cur.fetchone() if not row: return data.PostgresConnection() return data.PostgresConnection( enabled=bool(row[0]), host=row[1], port=row[2], database=row[3], user=row[4], password=row[5], schema=row[6], sslmode=row[7], connect_timeout=row[8], ) def get_postgres_connection_public(prefix: str) -> data.PostgresConnectionOut: conn = get_postgres_connection_db(prefix, include_password=False) table = f"{prefix}_postgres_connection" with get_db() as db: cur = db.cursor() cur.execute(f'SELECT password FROM "{table}" WHERE id=1') row = cur.fetchone() payload = conn.model_dump(by_alias=True) payload["password"] = "" payload["password_set"] = bool(row and row[0]) return data.PostgresConnectionOut(**payload) def save_postgres_connection_db(prefix: str, incoming: data.PostgresConnection) -> data.PostgresConnectionOut: table = f"{prefix}_postgres_connection" incoming = data.PostgresConnection.model_validate(incoming) with get_db() as conn: cur = conn.cursor() cur.execute(f'SELECT password FROM "{table}" WHERE id=1') row = cur.fetchone() stored_password = row[0] if row else "" cur.execute( f""" INSERT INTO "{table}" ( id, enabled, host, port, database_name, user_name, password, schema_name, sslmode, connect_timeout, updated_at ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET enabled=excluded.enabled, host=excluded.host, port=excluded.port, database_name=excluded.database_name, user_name=excluded.user_name, password=excluded.password, schema_name=excluded.schema_name, sslmode=excluded.sslmode, connect_timeout=excluded.connect_timeout, updated_at=CURRENT_TIMESTAMP """, ( 1 if incoming.enabled else 0, incoming.host, incoming.port, incoming.database, incoming.user, incoming.password, incoming.schema_, incoming.sslmode, incoming.connect_timeout ), ) conn.commit() return get_postgres_connection_public(prefix) def postgres_cashier_enabled(prefix: str, id_kas: str) -> bool: for param in get_effective_setup_parameters(prefix, id_kas): if param.var_name == "postgres_enabled": return _bool_setup_value(param.var_value) return False def get_postgres_status_db(prefix: str, id_kas: str, test_connection: bool = True) -> data.PostgresStatus: cashier_enabled = postgres_cashier_enabled(prefix, id_kas) conn = get_postgres_connection_db(prefix, include_password=True) installation_enabled = bool(conn.enabled) configured = postgres_service.is_configured(conn) status = data.PostgresStatus( cashier_enabled=cashier_enabled, installation_enabled=installation_enabled, connection_configured=configured, ) if not cashier_enabled: status.message = "PostgreSQL nie je povoleny pre tuto kasu." return status if not installation_enabled: status.message = "PostgreSQL pripojenie nie je povolene pre instalaciu." return status if not configured: status.message = "PostgreSQL pripojenie nie je vyplnene." return status if not test_connection: status.available = True status.message = "PostgreSQL pripojenie je nakonfigurovane." return status try: postgres_service.test_connection(conn) except postgres_service.PostgresServiceError as e: status.message = str(e) return status status.connection_ok = True status.available = True status.message = "PostgreSQL pripojenie je dostupne." return status @app.get("/postgres/connection/", response_model=data.PostgresConnectionOut) def get_postgres_connection( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET postgres connection: prefix={prefix} user={user}") return get_postgres_connection_public(prefix) @app.post("/postgres/connection/", response_model=data.PostgresConnectionOut) def update_postgres_connection( connection: data.PostgresConnection, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"POST postgres connection: prefix={prefix} user={user}") return save_postgres_connection_db(prefix, connection) @app.get("/postgres/status/", response_model=data.PostgresStatus) def get_postgres_status( id_kas: str, test_connection: bool = True, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET postgres status: prefix={prefix} pokladna={id_kas}") return get_postgres_status_db(prefix, id_kas, test_connection=test_connection) @app.get("/limity/", response_model=list[data.LimitTable]) def get_limity( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET limity: prefix={prefix} pokladna={id_kas} user={user}") return load_limit_tables_from_postgres(prefix, id_kas) @app.get("/limity/ucet/", response_model=data.Ucet) def get_limit_ucet( id_kas: str, id_limit: int, id_den: int, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( f"GET limit ucet: prefix={prefix} pokladna={id_kas} " f"limit={id_limit} den={id_den} client={client_id}" ) lock = acquire_limit_semafor(prefix, id_kas, id_limit, id_den, client_id, user) if not lock.ok: raise HTTPException(409, lock.message or "Limit je zamknuty.") try: return build_limit_ucet_from_postgres(prefix, id_kas, id_limit, id_den, user) except Exception: release_limit_semafor(prefix, id_kas, id_limit, client_id) raise @app.post("/limity/release/", response_model=data.LimitLockResult) def release_limit( id_kas: str, id_limit: int, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( f"POST limit release: prefix={prefix} pokladna={id_kas} " f"limit={id_limit} client={client_id}" ) return release_limit_semafor(prefix, id_kas, id_limit, client_id) @app.post("/limity/ucet/save/", response_model=data.Ucet) def save_limit_ucet( ucet: data.Ucet, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( "POST limit ucet save: prefix=%s pokladna=%s limit=%s den=%s client=%s", prefix, ucet.id_kas, getattr(ucet, "limit_id", None), getattr(ucet, "limit_den_id", None), client_id, ) save_limit_items_to_postgres(prefix, ucet.id_kas, ucet, client_id) return ucet @app.post("/limity/ucet/finish/", response_model=data.Ucet) def finish_limit_ucet( ucet: data.Ucet, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( "POST limit ucet finish: prefix=%s pokladna=%s limit=%s den=%s client=%s", prefix, ucet.id_kas, getattr(ucet, "limit_id", None), getattr(ucet, "limit_den_id", None), client_id, ) return insert_limit_closed_receipt_db(prefix, ucet, client_id) @app.post("/limity/ucet/clear/", response_model=data.LimitLockResult) def clear_limit_ucet( ucet: data.Ucet, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( "POST limit ucet clear: prefix=%s pokladna=%s ucislo=%s limit=%s client=%s", prefix, ucet.id_kas, getattr(ucet, "ucislo", ""), getattr(ucet, "limit_id", None), client_id, ) clear_limit_payment_in_postgres(prefix, ucet.id_kas, ucet) return data.LimitLockResult( ok=True, id_limit=int(getattr(ucet, "limit_id", 0) or 0), id_den=int(getattr(ucet, "limit_den_id", 0) or 0), table_id=limit_table_id(getattr(ucet, "limit_id", 0), getattr(ucet, "limit_den_id", 0)), ) def get_setup_from_db(cur_pref: str, id_kas: str) -> data.PosSetup: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_setup" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT data FROM "{table}" WHERE id_kas=?', (id_kas,), ) row = cur.fetchone() if not row: raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen") raw_json = row[0] # validace + doplnění nových polí setup = data.PosSetup.model_validate_json(raw_json) setup.platby = [] # NORMALIZACE (dict → json) normalized_json = json.dumps( setup.model_dump(exclude_none=False), ensure_ascii=False, separators=(",", ":"), sort_keys=True, ) # pokud se struktura změnila → UPDATE if normalized_json != raw_json: logger.info(f"SETUP schema updated for {id_kas}") cur.execute( f'UPDATE "{table}" SET data=? WHERE id_kas=?', (normalized_json, id_kas), ) apply_setup_parameters(cur_pref, id_kas, setup) setup.platby = get_setup_platby_from_db(cur_pref, id_kas) return setup @app.get("/platby/", response_model=List[data.PaymentType]) def get_platby(id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth logger.info(f"GET platby: prefix={prefix} pokladna={id_kas}") result = get_setup_platby_from_db(prefix, id_kas) return result def get_setup_platby_from_db(cur_pref: str, id_kas: str) -> List[data.PaymentType]: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_platby" with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT code, name, unit, rate, poradie, p_kopii, round50, allow_partial, is_cash, handler, color, is_default, fiscal, is_bankterm, odvod, odovzdat FROM "{table}" WHERE id_kas=? ORDER BY poradie, name, code """, (id_kas,), ) rows = cur.fetchall() if rows: return [_row_to_payment_type(row) for row in rows] payments = default_payment_types() _insert_platby_cur(cur, table, id_kas, payments, replace_existing=True) return sorted( payments, key=lambda p: (getattr(p, "poradie", 0) or 0, str(getattr(p, "name", ""))), ) def get_setup_discounts_from_db(cur_pref: str, id_kas: str) -> List[data.PaymentType]: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_zlavy" with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT id_zlavy_hlav FROM "{table}" WHERE id_kas=? ORDER BY id_zlavy_hlav """, (id_kas,), ) rows = cur.fetchall() if rows: return [str(row[0]) for row in rows] else: return [] @app.post("/platby/") @app.post("/platby/setup/") def update_platby( id_kas: str, platby: list[data.PaymentType], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_platby_db(prefix, id_kas, platby) def update_platby_db(prefix: str, id_kas: str, new_platby: list[data.PaymentType]): if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_platby" payments = list(new_platby or []) with get_db() as conn: cur = conn.cursor() # načítaj aktuálny setup _insert_platby_cur( cur, table, id_kas, payments, replace_existing=True, ) # pôvodné platby ako dict podľa code # nové platby ako dict podľa code # 🔥 merge: # - update existujúcich # - pridanie nových # update existujúceho (prepíše všetko) # nová platba # výsledný zoznam (len to čo prišlo → staré sa zahodia) # uloženie späť conn.commit() return {"ok": True, "count": len(payments)} @app.get("/spravy/", response_model=List) def get_spravy(id_kas: str, auth: tuple[str] = Depends(auth_ctx)): prefix, user, client_id = auth logger.info(f"GET setup: prefix={prefix} pokladna={id_kas}") result = get_setup_spravy_from_db(prefix, id_kas) return result def get_setup_spravy_from_db(cur_pref: str, id_kas: str) -> List[str]: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_setup" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT data FROM "{table}" WHERE id_kas=?', (id_kas,), ) row = cur.fetchone() if not row: raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen") raw_json = row[0] setup = data.PosSetup.model_validate_json(raw_json) return setup.messages @app.post("/spravy/setup/") def update_spravy( id_kas: str, spravy: list[str], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_spravy_db(prefix, id_kas, spravy) def update_spravy_db(prefix: str, id_kas: str, new_spravy: list[str]): if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_setup" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT data FROM "{table}" WHERE id_kas=?', (id_kas,), ) row = cur.fetchone() if not row: raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen") setup = json.loads(row[0]) # 🔥 normalizácia (odstráni None, duplicitné, trimne) cleaned = [] seen = set() for m in new_spravy: if not m: continue m = m.strip() if not m: continue if m in seen: continue seen.add(m) cleaned.append(m) # 🔥 uloženie setup["messages"] = cleaned normalized_json = json.dumps( setup, ensure_ascii=False, separators=(",", ":"), sort_keys=True, ) cur.execute( f'UPDATE "{table}" SET data=? WHERE id_kas=?', (normalized_json, id_kas), ) conn.commit() return {"ok": True, "count": len(cleaned)} @app.post("/fstmenu/") def update_fstmenu( data: list[data.FstMenu], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_fstmenu_db(prefix, data) def update_fstmenu_db(prefix: str, fst: list[data.FstMenu]): table = f"{prefix}_fstmenu" with get_db() as conn: cur = conn.cursor() # 🔹 1. načítaj existujúce z DB cur.execute(f'SELECT id_kas, c_karty FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 2. incoming IDs incoming_ids = {(item.id_kas, item.c_karty) for item in fst} # 🔹 3. DELETE (čo už nie je v requeste) to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE id_kas=? AND c_karty=?', list(to_delete) ) # 🔹 4. INSERT / UPDATE for item in fst: polozky_json = json.dumps( [p.model_dump() for p in item.polozky], ensure_ascii=False ) cur.execute(f""" INSERT INTO "{table}" (id_kas, c_karty, polozky) VALUES (?, ?, ?) ON CONFLICT(id_kas, c_karty) DO UPDATE SET polozky = excluded.polozky """, (item.id_kas, item.c_karty, polozky_json)) conn.commit() return {"ok": True} @app.get("/fooddat/", response_model=list[data.FoodDat]) def get_fooddat( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return get_fooddat_db(prefix) def get_fooddat_db(prefix: str) -> list[data.FoodDat]: table = f"{prefix}_fooddat" with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT id, c_stredisk, id_zkratka, pgm FROM "{table}" ORDER BY id ''' ) return [ data.FoodDat( id=row[0] or "", c_stredisk=int(row[1] or 0), id_zkratka=row[2] or "", pgm=row[3] or "", ) for row in cur.fetchall() ] def _fooddat_id_candidates(value) -> list[str]: text = _strip_value(value) candidates = [] if text: candidates.append(text) if text.isdigit(): candidates.append(str(int(text))) candidates.append(f"{int(text):02d}") return list(dict.fromkeys(candidates)) def get_fooddat_stredisk_map(prefix: str) -> dict[str, int]: table = f"{prefix}_fooddat" try: with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT id, c_stredisk FROM "{table}" ''' ) result: dict[str, int] = {} for row in cur.fetchall(): stredisk = int(row[1] or 0) for candidate in _fooddat_id_candidates(row[0]): result.setdefault(candidate, stredisk) return result except Exception as exc: logger.warning(f"Fooddat strediska sa nepodarilo nacitat: {exc}") return {} def fooddat_stredisk_for_sklad(fooddat_map: dict[str, int], sklad) -> int: for candidate in _fooddat_id_candidates(sklad): if candidate in fooddat_map: return int(fooddat_map[candidate] or 0) return 0 @app.post("/fooddat/") def update_fooddat( items: list[data.FoodDat], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_fooddat_db(prefix, items) def update_fooddat_db(prefix: str, items: list[data.FoodDat]) -> dict: table = f"{prefix}_fooddat" rows = [data.FoodDat.model_validate(item) for item in (items or [])] with get_db() as conn: cur = conn.cursor() cur.execute(f'DELETE FROM "{table}"') cur.executemany( f''' INSERT INTO "{table}" (id, c_stredisk, id_zkratka, pgm) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET c_stredisk = excluded.c_stredisk, id_zkratka = excluded.id_zkratka, pgm = excluded.pgm ''', [ ( item.id, int(item.c_stredisk or 0), item.id_zkratka, item.pgm, ) for item in rows if item.id ], ) conn.commit() return {"ok": True, "count": len([item for item in rows if item.id])} @app.get("/prndefkasa/") def get_prndef( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return get_prndef_for_kasa(prefix, id_kas) @app.get("/prndef/", response_model=list[data.PrnDefShort]) def get_all_prndef( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return get_all_prndef_short(prefix) def get_all_prndef_short(prefix: str) -> list[data.PrnDefShort]: table_prndef = f'"{prefix}_prndef"' with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT prn_no, prn_name, id_term, data FROM {table_prndef} ORDER BY prn_no """ ) rows = cur.fetchall() out: list[data.PrnDefShort] = [] for index, row in enumerate(rows, start=1): try: prn_data = json.loads(row[3] or "{}") except Exception: prn_data = {} out.append( data.PrnDefShort( prn_no=row[0], prn_name=row[1], poradie=index, id_term=row[2] or "", cmd32_on=str((prn_data or {}).get("cmd32_on") or ""), ) ) return out def get_prndef_for_kasa( prefix: str, id_kas: str ) -> list[data.PrnDefShort]: table_prndef = f'"{prefix}_prndef"' table_kasaucp = f'"{prefix}_kasaucp"' with get_db() as conn: cur = conn.cursor() # načítanie povolených tlačiarní pre kasu cur.execute( f''' SELECT printers FROM {table_kasaucp} WHERE id_kas=? ''', (id_kas,) ) row = cur.fetchone() if not row: return [] raw_json = row[0] allowed = TypeAdapter( list[data.KasaUcpPrinters] ).validate_json(raw_json) if not allowed: return [] prn_map = { x.prn_no: x.poradie for x in allowed } placeholders = ",".join("?" for _ in prn_map) cur.execute( f""" SELECT prn_no, prn_name, id_term, data FROM {table_prndef} WHERE prn_no IN ({placeholders}) ORDER BY prn_no """, tuple(prn_map.keys()) ) rows = cur.fetchall() out = [] for r in rows: try: prn_data = json.loads(r[3] or "{}") except Exception: prn_data = {} out.append( data.PrnDefShort( prn_no=r[0], prn_name=r[1], poradie=prn_map.get(r[0], 0), id_term=r[2] or "", cmd32_on=str((prn_data or {}).get("cmd32_on") or ""), ) ) out.sort(key=lambda x: (x.poradie, x.prn_no)) return out @app.get( "/pricelevels/", response_model=list[data.HladinyRiadky] ) def get_pricelevels( id_kas: str, auth: tuple[str] = Depends(auth_ctx) ): prefix, user, client_id = auth logger.info( f"GET pricelevels: prefix={prefix} pokladna={id_kas}" ) return get_pricelevels_from_db(prefix, id_kas) def get_pricelevels_from_db(cur_pref: str, id_kas: str) -> List[str]: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_hladiny" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT riadky FROM "{table}" WHERE id_kas=?', (id_kas,), ) row = cur.fetchone() if not row: raise HTTPException(404, f"Setup pro pokladnu {id_kas} nenalezen") raw_json = row[0] return TypeAdapter(list[data.HladinyRiadky]).validate_json(raw_json) @app.get( "/clientsettings/", response_model=data.ClientSettings ) def get_clientsettings( id_kas: str, auth: tuple[str] = Depends(auth_ctx) ): prefix, user, client_id = auth logger.info( f"GET clientsettings: prefix={prefix} pokladna={id_kas}" ) return get_clientsettings_from_db(prefix, id_kas, client_id) def get_clientsettings_from_db( cur_pref: str, id_kas: str, client_id: str ) -> data.ClientSettings: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_clients" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT prn_no, room_name FROM "{table}" WHERE id_kas=? AND client_id=?', (id_kas, client_id), ) row = cur.fetchone() if not row: cur.execute( f''' INSERT INTO "{table}" (id_kas, client_id, prn_no, room_name) VALUES (?, ?, '', '') ON CONFLICT(id_kas, client_id) DO UPDATE SET prn_no = excluded.prn_no, room_name = excluded.room_name ''', (id_kas, client_id), ) raw_data = { "prn_no": "", "room_name": "", } else: raw_data = { "prn_no": row[0], "room_name": row[1], } return TypeAdapter(data.ClientSettings).validate_python(raw_data) @app.post( "/clientsettings/", response_model=data.ClientSettings, ) def set_clientsettings( prn_no: str, room_name: str, id_kas: str, auth: tuple[str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( f"SET clientsettings: prefix={prefix} pokladna={id_kas} client_id={client_id} prn_no={prn_no} room_name={room_name}" ) return save_clientsettings_to_db( prefix, id_kas, client_id, prn_no, room_name, ) def save_clientsettings_to_db( cur_pref: str, id_kas: str, client_id: str, prn_no: str, room_name: str, ) -> data.ClientSettings: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_clients" with get_db() as conn: cur = conn.cursor() cur.execute( f""" INSERT INTO "{table}" (id_kas, client_id, prn_no, room_name) VALUES (?, ?, ?, ?) ON CONFLICT(id_kas, client_id) DO UPDATE SET prn_no = excluded.prn_no, room_name = excluded.room_name """, (id_kas, client_id, prn_no, room_name), ) conn.commit() return data.ClientSettings( prn_no=prn_no, room_name=room_name, ) @app.get( "/kasutxt/{id_kas}", response_model=data.KasUtxtRiadky ) def get_kasutxt( id_kas: str, auth: tuple[str] = Depends(auth_ctx) ): prefix, user, client_id = auth logger.info( f"GET kasutxt: prefix={prefix} pokladna={id_kas}" ) return get_kasutxt_from_db(prefix, id_kas) def get_kasutxt_from_db(cur_pref: str, id_kas: str) -> data.KasUtxtRiadky: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{cur_pref}_kasutxt" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT riadky FROM "{table}" WHERE id_kas=?', (id_kas,), ) row = cur.fetchone() if not row: raise HTTPException(404, f"Hlavicky uctov pro pokladnu {id_kas} nenalezen") raw_json = row[0] logger.info(f"GET kasutxt: raw_json={raw_json}") return TypeAdapter(data.KasUtxtRiadky).validate_json(raw_json) @app.post("/prndef/") def update_prndef( prn: list[data.PrnDef], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_prndef_db(prefix, prn) def update_prndef_db(prefix: str, prn: list[data.PrnDef]): table = f"{prefix}_prndef" with get_db() as conn: cur = conn.cursor() # 🔹 1. načítaj existujúce z DB cur.execute(f'SELECT prn_no FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 2. incoming IDs incoming_ids = {(item.prn_no) for item in prn} # 🔹 3. DELETE (čo už nie je v requeste) to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE prn_no=?', [(x,) for x in to_delete] ) for item in prn: prn_json = json.dumps(item.data.model_dump()) cur.execute(f""" INSERT INTO "{table}" (prn_no, prn_name, data, id_term) VALUES (?, ?, ?, ?) ON CONFLICT(prn_no) DO UPDATE SET data = excluded.data, prn_name=excluded.prn_name, id_term=excluded.id_term """, (item.prn_no, item.prn_name, prn_json, item.id_term)) conn.commit() return {"ok": True} @app.get("/bankterm/", response_model=list[data.BankTerm]) def get_bankterm( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return get_bankterm_db(prefix) def get_bankterm_db(prefix: str) -> list[data.BankTerm]: table = f"{prefix}_bankterm" with get_db() as conn: cur = conn.cursor() cur.execute(f'SELECT id_term, term_name, term_data FROM "{table}" ORDER BY id_term') result: list[data.BankTerm] = [] for id_term, term_name, raw_data in cur.fetchall(): try: raw = json.loads(raw_data or "{}") term_data = data.BankTermData.model_validate(raw) except Exception: logger.exception("Bank terminal data is invalid id_term=%s", id_term) continue result.append( data.BankTerm( id_term=id_term or "", term_name=term_name or "", term_data=term_data, ) ) return result @app.post("/bankterm/") def update_bankterm( bt: list[data.BankTerm], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_bankterm_db(prefix, bt) def update_bankterm_db(prefix: str, bt: list[data.BankTerm]): table = f"{prefix}_bankterm" with get_db() as conn: cur = conn.cursor() # 🔹 1. načítaj existujúce z DB cur.execute(f'SELECT id_term FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 2. incoming IDs incoming_ids = {(item.id_term) for item in bt} # 🔹 3. DELETE (čo už nie je v requeste) to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE id_term=?', [(x,) for x in to_delete] ) for item in bt: prn_json = json.dumps(item.term_data.model_dump()) cur.execute(f""" INSERT INTO "{table}" (id_term, term_name, term_data) VALUES (?, ?, ?) ON CONFLICT(id_term) DO UPDATE SET term_data = excluded.term_data, term_name=excluded.term_name """, (item.id_term, item.term_name, prn_json)) conn.commit() return {"ok": True} PRINT_JOB_ACTIVE_STATUSES = ("queued", "retry_pending") PRINT_JOB_VISIBLE_STATUSES = { "queued", "claimed", "printing", "printed", "failed", "retry_pending", "failed_final", "cancelled", } def _json_obj(raw: str | None) -> dict: if not raw: return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {} except Exception: return {} def _json_dump_obj(value: dict | None) -> str: return json.dumps(value or {}, ensure_ascii=False, separators=(",", ":")) def _print_job_select_columns() -> str: return """ id, id_kas, printer_no, agent_id, job_type, document_type, receipt_no, required, status, priority, attempts, max_attempts, payload, result, error, created_at, claimed_at, started_at, finished_at, updated_at """ def _print_job_from_row(row) -> data.PrintJob: return data.PrintJob( id=int(row[0]), id_kas=row[1], printer_no=row[2] or "", agent_id=row[3], job_type=row[4] or "other", document_type=row[5] or "", receipt_no=row[6], required=bool(row[7]), status=row[8] or "queued", priority=int(row[9] or 100), attempts=int(row[10] or 0), max_attempts=int(row[11] or 3), payload=_json_obj(row[12]), result=_json_obj(row[13]), error=row[14] or "", created_at=row[15] or "", claimed_at=row[16], started_at=row[17], finished_at=row[18], updated_at=row[19] or "", ) def _load_print_job_cur(cur, table: str, job_id: int) -> data.PrintJob: cur.execute( f'SELECT {_print_job_select_columns()} FROM "{table}" WHERE id=?', (job_id,), ) row = cur.fetchone() if not row: raise HTTPException(404, f"Print job {job_id} not found") return _print_job_from_row(row) @app.post("/print/jobs/", response_model=data.PrintJob) def create_print_job( job: data.PrintJobCreate, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return create_print_job_db(prefix, job) def create_print_job_db(prefix: str, job: data.PrintJobCreate) -> data.PrintJob: if len(job.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_print_jobs" with get_db() as conn: cur = conn.cursor() cur.execute( f""" INSERT INTO "{table}" ( id_kas, printer_no, job_type, document_type, receipt_no, required, status, priority, max_attempts, payload, result, error ) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, '{{}}', '') """, ( job.id_kas, job.printer_no or "", job.job_type or "other", job.document_type or "", job.receipt_no, int(bool(job.required)), int(job.priority or 100), max(int(job.max_attempts or 1), 1), _json_dump_obj(job.payload), ), ) conn.commit() created = _load_print_job_cur(cur, table, int(cur.lastrowid)) logger.info( "Print job created: prefix=%s id=%s id_kas=%s printer=%s type=%s document=%s receipt=%s", prefix, created.id, created.id_kas, created.printer_no, created.job_type, created.document_type, created.receipt_no, ) return created def _truthy_flag(value) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return value != 0 if value is None: return False return str(value).strip().lower() in {"1", "true", "yes", "ano", "áno"} def _load_prndef_map_cur(cur, prefix: str) -> dict[str, dict]: table_prndef = f"{prefix}_prndef" cur.execute(f'SELECT prn_no, prn_name, id_term, data FROM "{table_prndef}" ORDER BY prn_no') result: dict[str, dict] = {} for prn_no, prn_name, id_term, raw_data in cur.fetchall(): prn_key = str(prn_no) result[prn_key] = { "prn_no": prn_key, "prn_name": prn_name or "", "id_term": id_term or "", "data": _json_obj(raw_data), } return result def _printer_target_numbers(prn_no: str, prndef_map: dict[str, dict]) -> list[str]: printer = prndef_map.get(str(prn_no)) if not printer: return [] cmd32_on = str((printer.get("data") or {}).get("cmd32_on") or "").strip() if cmd32_on.startswith("***"): return [target.strip() for target in re.findall(r"\{([^{}]+)\}", cmd32_on) if target.strip()] return [str(prn_no)] def _printer_route(printer: dict | None) -> dict: if not printer: return {} pdata = printer.get("data") or {} cmd32_on = str(pdata.get("cmd32_on") or "").strip() cmd_upper = cmd32_on.upper() if cmd_upper == "FISKAL": route_type = "fiskal" elif cmd_upper == "CUPS": route_type = "cups" else: route_type = "raw" return { "prn_no": printer.get("prn_no") or "", "prn_name": printer.get("prn_name") or "", "id_term": printer.get("id_term") or "", "route_type": route_type, "cmd32_on": cmd32_on, "ip": str(pdata.get("ip") or ""), "port": str(pdata.get("port") or ""), "cupsname": str(pdata.get("cupsname") or ""), "printer_type": str(pdata.get("printer_type") or ""), "convert_charset": str(pdata.get("convert_charset") or ""), "p_width": str(pdata.get("p_width") or ""), "p_reset": str(pdata.get("p_reset") or ""), "p_wideon": str(pdata.get("p_wideon") or ""), "p_wideoff": str(pdata.get("p_wideoff") or ""), "p_crlf": str(pdata.get("p_crlf") or ""), "p_fullcut": str(pdata.get("p_fullcut") or ""), "template_bon": str(pdata.get("template_bon") or ""), "template_ucet": str(pdata.get("template_ucet") or ""), } def load_current_printer_route(prefix: str, printer_no: str) -> dict: printer_key = str(printer_no or "").strip() if not printer_key: return {} with get_db() as conn: cur = conn.cursor() return _printer_route(_load_prndef_map_cur(cur, prefix).get(printer_key)) def next_print_bon_number_db(prefix: str, prn_no: str, bon_date: str | None = None) -> tuple[int, str]: prn_key = str(prn_no or "").strip() if not prn_key: raise HTTPException(422, "Invalid bon printer number") bon_day = bon_date or datetime.now().date().isoformat() try: ref_day = datetime.fromisoformat(bon_day).date() except Exception: ref_day = datetime.now().date() retention_days = max(1, _env_int("POKLADNA_BON_COUNTER_RETENTION_DAYS", 35)) cleanup_before = (ref_day - timedelta(days=retention_days)).isoformat() table = f"{prefix}_print_bon_counters" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}" WHERE bon_date < ?', (cleanup_before,)) cur.execute( f'SELECT last_no FROM "{table}" WHERE prn_no=? AND bon_date=?', (prn_key, bon_day), ) row = cur.fetchone() next_no = int(row[0] or 0) + 1 if row else 1 cur.execute( f""" INSERT INTO "{table}" (prn_no, bon_date, last_no, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(prn_no, bon_date) DO UPDATE SET last_no=excluded.last_no, updated_at=CURRENT_TIMESTAMP """, (prn_key, bon_day, next_no), ) return next_no, bon_day def resolve_table_info_from_map(prefix: str, id_kas: str, stul: str | None) -> tuple[str, str]: table_id = str(stul or "").strip() if not table_id: return "", "" try: mapa = get_mapa_stolu_from_db(prefix, id_kas) except Exception: return table_id, "" for room in getattr(mapa, "rooms", []) or []: room_name = str(getattr(room, "room_name", "") or "").strip() for table in getattr(room, "stoly", []) or []: current_id = str(getattr(table, "id", "") or "").strip() if current_id == table_id or current_id.split("|")[-1] == table_id: table_name = str(getattr(table, "name", "") or current_id or table_id) return table_name, room_name return table_id, "" def resolve_table_name_from_map(prefix: str, id_kas: str, stul: str | None) -> str: return resolve_table_info_from_map(prefix, id_kas, stul)[0] def ensure_ucet_room_name(prefix: str, ucet: data.Ucet) -> data.Ucet: _, room_name = resolve_table_info_from_map(prefix, getattr(ucet, "id_kas", ""), getattr(ucet, "stul", "")) if room_name: ucet.room_name = room_name elif getattr(ucet, "room_name", None) is None: ucet.room_name = "" return ucet def _clone_ucet_for_print(ucet: data.Ucet, poloz: list) -> data.Ucet: clone = ucet.model_copy(deep=True) clone.poloz = [pol.model_copy(deep=True) for pol in poloz] return clone def _apply_cenik_print_defaults(pol, cenik_map: dict[int, dict]) -> None: defaults = cenik_map.get(_int_value(getattr(pol, "id_card", 0), 0), {}) if not getattr(pol, "c_druh", 0): pol.c_druh = _int_value(defaults.get("c_druh"), 0) if not getattr(pol, "druh", ""): pol.druh = _strip_value(defaults.get("druh")) if not getattr(pol, "spart", ""): pol.spart = _strip_value(defaults.get("spart")) if not getattr(pol, "prn_no", ""): pol.prn_no = _strip_value(defaults.get("prn_no")) def _insert_cook_items_db( prefix: str, req: data.KitchenPrintRequest, poloz: list, *, bon_no: int = 0, bon_date: str = "", ) -> None: if not poloz: return table = f"{prefix}_cook_items" now_text = datetime.now().isoformat(sep=" ", timespec="seconds") rows = [] for pol in poloz: rows.append(( req.id_kas, getattr(req.ucet, "stul", "") or "", req.room_name or "", req.pos_name or "", getattr(req.ucet, "autor", "") or "", getattr(req.ucet, "ucislo", None), int(bon_no or 0), bon_date or "", req.kind or "bon", "new", _int_value(getattr(pol, "id_card", 0), 0), _int_value(getattr(pol, "c_druh", 0), 0), getattr(pol, "druh", "") or "", getattr(pol, "prn_no", "") or "", getattr(pol, "line_id", "") or "", getattr(pol, "group_id", "") or "", getattr(pol, "nazev", "") or "", _float_value(getattr(pol, "pocet", 0), 0.0), _int_value(getattr(pol, "delitel", 1), 1), json.dumps(getattr(pol, "zpravy", []) or [], ensure_ascii=False), now_text, now_text, pol.model_dump_json(), )) with get_db() as conn: cur = conn.cursor() cur.executemany( f""" INSERT INTO "{table}" ( id_kas, stul, room_name, pos_name, waiter_name, receipt_no, bon_no, bon_date, event_type, status, id_card, c_druh, druh, prn_no, line_id, group_id, item_name, quantity, delitel, messages, ordered_at, updated_at, payload ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, rows, ) @app.post("/print/kitchen/", response_model=list[data.PrintJob]) def create_kitchen_print_jobs( req: data.KitchenPrintRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return create_kitchen_print_jobs_db(prefix, req) def create_kitchen_print_jobs_db( prefix: str, req: data.KitchenPrintRequest, ) -> list[data.PrintJob]: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") kind = (req.kind or "bon").strip().lower() if not kind: kind = "bon" source_count = len(list(getattr(req.ucet, "poloz", []) or [])) logger.info( "Kitchen print request: prefix=%s id_kas=%s kind=%s items=%s receipt=%s table=%s", prefix, req.id_kas, kind, source_count, getattr(req.ucet, "ucislo", None), getattr(req.ucet, "stul", ""), ) with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) logger.info( "Kitchen print prndef loaded: prefix=%s printers=%s", prefix, ",".join(sorted(prndef_map.keys())) or "-", ) cenik_map = load_cenik_print_map(prefix, req.id_kas) source_lines = list(getattr(req.ucet, "poloz", []) or []) for pol in source_lines: _apply_cenik_print_defaults(pol, cenik_map) menu_headers = { _strip_value(getattr(pol, "group_id", "")): pol for pol in source_lines if _int_value(getattr(pol, "typ_menu", 0), 0) == 1 and _strip_value(getattr(pol, "group_id", "")) } lines_by_printer: dict[str, list] = defaultdict(list) added_menu_headers: set[tuple[str, str]] = set() cook_lines_by_printer: dict[str, list] = defaultdict(list) def add_line_to_printer(target_prn: str, source_pol, target_printer: dict, *, for_cook: bool = True) -> None: target_line = source_pol.model_copy(deep=True) target_line.prn_no = target_prn lines_by_printer[target_prn].append(target_line) if for_cook and _truthy_flag((target_printer.get("data") or {}).get("is_cook")): cook_lines_by_printer[target_prn].append(target_line) for pol in source_lines: typ_menu = _int_value(getattr(pol, "typ_menu", 0), 0) if typ_menu == 1: continue source_prn = _strip_value(getattr(pol, "prn_no", "")) if not source_prn: logger.warning( "Kitchen print item skipped without prn_no: prefix=%s id_kas=%s item=%s id_card=%s", prefix, req.id_kas, getattr(pol, "nazev", ""), getattr(pol, "id_card", ""), ) continue target_numbers = _printer_target_numbers(source_prn, prndef_map) if not target_numbers: logger.warning(f"Print requested for unknown printer {source_prn}: prefix={prefix} id_kas={req.id_kas}") continue for target_prn in target_numbers: target_printer = prndef_map.get(target_prn) if not target_printer: logger.warning(f"Print group {source_prn} points to unknown printer {target_prn}: prefix={prefix}") continue if typ_menu == 2: group_id = _strip_value(getattr(pol, "group_id", "")) header = menu_headers.get(group_id) header_key = (target_prn, group_id) if header and group_id and header_key not in added_menu_headers: add_line_to_printer(target_prn, header, target_printer, for_cook=True) added_menu_headers.add(header_key) add_line_to_printer(target_prn, pol, target_printer, for_cook=True) if not lines_by_printer: logger.warning(f"Kitchen print requested but no item has usable prn_no: prefix={prefix} id_kas={req.id_kas}") return [] document_type = "kitchen_storno" if "storno" in kind else "kitchen_bon" jobs: list[data.PrintJob] = [] table_name = resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", "")) for prn_no, poloz in lines_by_printer.items(): printer = prndef_map.get(prn_no) bon_no, bon_date = next_print_bon_number_db(prefix, prn_no) if cook_lines_by_printer.get(prn_no): try: _insert_cook_items_db( prefix, req, cook_lines_by_printer[prn_no], bon_no=bon_no, bon_date=bon_date, ) except Exception: logger.exception( "Cook item insert failed, continuing with print job: prefix=%s id_kas=%s printer=%s bon=%s", prefix, req.id_kas, prn_no, bon_no, ) ucet_for_printer = _clone_ucet_for_print(req.ucet, poloz) payload = { "kind": kind, "bon_no": bon_no, "bon_date": bon_date, "pager_no": "", "table_name": table_name, "room_name": req.room_name or "", "pos_name": req.pos_name or "", "route": _printer_route(printer), "ucet": ucet_for_printer.model_dump(mode="json"), } jobs.append( create_print_job_db( prefix, data.PrintJobCreate( id_kas=req.id_kas, printer_no=prn_no, job_type=kind, document_type=document_type, receipt_no=req.ucet.ucislo, required=req.required, priority=req.priority, payload=payload, ), ) ) return jobs def _ucet_has_fiscal_payment(ucet: data.Ucet) -> bool: return any(bool(getattr(payment, "fiscal", False)) for payment in (getattr(ucet, "platby", []) or [])) def _receipt_fiscal_text_allowed(kind: str) -> bool: kind_l = str(kind or "").strip().lower() return kind_l in {"copy", "kopia", "reprint"} @app.post("/print/receipt/", response_model=list[data.PrintJob]) def create_receipt_print_jobs( req: data.ReceiptPrintRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return create_receipt_print_jobs_db(prefix, req) def create_receipt_print_jobs_db( prefix: str, req: data.ReceiptPrintRequest, ) -> list[data.PrintJob]: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") kind = str(req.kind or "receipt").strip().lower() or "receipt" if _ucet_has_fiscal_payment(req.ucet) and not _receipt_fiscal_text_allowed(kind): raise HTTPException( 409, "Fiskalny ucet sa netlaci cez textovu frontu. Pouzi fiskalny paragon/send.", ) printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip() if not printer_no: raise HTTPException(422, "Receipt printer is not set") copies = max(1, min(int(req.copies or 1), 20)) with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) target_numbers = _printer_target_numbers(printer_no, prndef_map) if not target_numbers: raise HTTPException(404, f"Receipt printer {printer_no} not found") table_name = resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", "")) jobs: list[data.PrintJob] = [] for target_prn in target_numbers: printer = prndef_map.get(target_prn) if not printer: logger.warning("Receipt printer group %s points to unknown printer %s", printer_no, target_prn) continue payload = { "kind": kind, "title": req.title or "", "table_name": table_name, "pos_name": req.pos_name or "", "headers": [str(line) for line in (req.headers or []) if str(line).strip()], "footers": [str(line) for line in (req.footers or []) if str(line).strip()], "route": _printer_route(printer), "ucet": req.ucet.model_dump(mode="json"), } for _ in range(copies): jobs.append( create_print_job_db( prefix, data.PrintJobCreate( id_kas=req.id_kas, printer_no=target_prn, job_type="receipt", document_type=f"receipt_{kind}", receipt_no=req.ucet.ucislo, required=req.required, priority=req.priority, payload=payload, ), ) ) return jobs @app.post("/print/closure/", response_model=list[data.PrintJob]) def create_closure_print_jobs( req: data.ClosurePrintRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return create_closure_print_jobs_db(prefix, req) def create_closure_print_jobs_db( prefix: str, req: data.ClosurePrintRequest, ) -> list[data.PrintJob]: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") printer_no = str(req.printer_no or "").strip() if not printer_no: raise HTTPException(422, "Closure printer is not set") text = str(req.text or "") if not text.strip(): raise HTTPException(422, "Closure text is empty") copies = max(1, min(int(req.copies or 1), 20)) with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) target_numbers = _printer_target_numbers(printer_no, prndef_map) if not target_numbers: raise HTTPException(404, f"Closure printer {printer_no} not found") kind = str(req.kind or "closure").strip().lower() or "closure" jobs: list[data.PrintJob] = [] for target_prn in target_numbers: printer = prndef_map.get(target_prn) if not printer: logger.warning("Closure printer group %s points to unknown printer %s", printer_no, target_prn) continue payload = { "kind": kind, "title": req.title or "Uzavierka", "clsrep_no": req.clsrep_no or "", "route": _printer_route(printer), "text": text if text.endswith("\n") else text + "\n", } for _ in range(copies): jobs.append( create_print_job_db( prefix, data.PrintJobCreate( id_kas=req.id_kas, printer_no=target_prn, job_type="closure", document_type=f"closure_{kind}", receipt_no=req.clsrep_no, required=req.required, priority=req.priority, payload=payload, ), ) ) return jobs @app.post("/print/receipt/preview/", response_model=data.ReceiptPrintPreviewOut) def render_receipt_print_preview( req: data.ReceiptPrintRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return render_receipt_print_preview_db(prefix, req) def _receipt_print_payload(prefix: str, req: data.ReceiptPrintRequest, printer_no: str, printer: dict | None) -> dict: return { "kind": str(req.kind or "receipt").strip().lower() or "receipt", "title": req.title or "", "table_name": resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", "")), "pos_name": req.pos_name or "", "headers": [str(line) for line in (req.headers or []) if str(line).strip()], "footers": [str(line) for line in (req.footers or []) if str(line).strip()], "route": _printer_route(printer), "ucet": req.ucet.model_dump(mode="json"), } def render_receipt_print_preview_db( prefix: str, req: data.ReceiptPrintRequest, ) -> data.ReceiptPrintPreviewOut: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip() if not printer_no: raise HTTPException(422, "Receipt printer is not set") with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) printer = prndef_map.get(printer_no) if not printer: raise HTTPException(404, f"Receipt printer {printer_no} not found") route = _printer_route(printer) job = data.PrintJob( id=0, id_kas=req.id_kas, printer_no=printer_no, job_type="receipt", document_type=f"receipt_{str(req.kind or 'receipt').strip().lower() or 'receipt'}", receipt_no=req.ucet.ucislo, payload=_receipt_print_payload(prefix, req, printer_no, printer), ) text, meta = render_print_job_text_with_meta(job, route=route) meta["route"] = route meta["charset"] = _print_charset(route) return data.ReceiptPrintPreviewOut(text=text, meta=meta) def _fiscal_dph_value(rate) -> int | None: raw = str(rate or "").strip() if not raw: return None try: value = float(raw) except Exception: return None if value == -1: return None if value >= 1 and value <= 3: pct = (value - 1) * 100 elif value > 3: pct = value else: pct = value * 100 return int(round(pct)) FISCAL_ITEM_TYPE_SALE = "F" FISCAL_ITEM_TYPE_RECEIVABLE = "P" def _ucet_is_pohladavka(ucet: data.Ucet) -> bool: return bool(_int_value(getattr(ucet, "pohladavka", 0), 0)) def _fiscal_receipt_document_type(ucet: data.Ucet) -> str: # AFS/eKasa accepts a regular fiscal document type here. Receivables are # carried by item metadata and by the saved account flag. return FISCAL_ITEM_TYPE_SALE def _fiscal_receipt_item_type(ucet: data.Ucet) -> str: return FISCAL_ITEM_TYPE_RECEIVABLE if _ucet_is_pohladavka(ucet) else FISCAL_ITEM_TYPE_SALE def _fiscal_receipt_type(ucet: data.Ucet) -> str: return _fiscal_receipt_item_type(ucet) def _dict_with_1_based_keys(rows: list[dict]) -> dict: return {str(idx): row for idx, row in enumerate(rows, start=1)} def _fiscal_receipt_items(ucet: data.Ucet) -> dict: rows: list[dict] = [] fiscal_type = _fiscal_receipt_item_type(ucet) for idx, pol in enumerate(getattr(ucet, "poloz", []) or [], start=1): delitel = max(_int_value(getattr(pol, "delitel", 1), 1), 1) qty = _float_value(getattr(pol, "pocet", 0), 0.0) / delitel price = _float_value(getattr(pol, "cena", 0), 0.0) dph = _fiscal_dph_value(getattr(pol, "dph", "")) identifier = None if fiscal_type == FISCAL_ITEM_TYPE_RECEIVABLE: for msg in getattr(pol, "zpravy", []) or []: identifier = _strip_value(msg) if identifier: break rows.append({ "nazov": _strip_value(getattr(pol, "nazev", "")), "cena": abs(price), "mnozstvo": abs(qty), "dil_porce": delitel, "mj": "por", "dph": dph, "typ_fiskal": fiscal_type, "okp": "", "uid": "", "identifikator": identifier, }) return _dict_with_1_based_keys(rows) def _payment_fiscal_index(payment) -> int: for attr in ("fiskal_pro_payment_index", "typ", "payment_type", "fiscal_type"): value = getattr(payment, attr, None) if value not in (None, ""): return _int_value(value, 16) code = _strip_value(getattr(payment, "code", "")).upper() if "CARD" in code or "KARTA" in code: return 17 return 16 def _ucet_has_bankterm_payment(ucet: data.Ucet) -> bool: return any( bool(getattr(payment, "is_bankterm", False)) and abs(_float_value(getattr(payment, "suma", 0), 0.0)) >= 0.005 for payment in (getattr(ucet, "platby", []) or []) ) def _route_has_bankterm(route: dict) -> bool: return bool(_strip_value((route or {}).get("id_term", ""))) def _use_afs_bankterm(route: dict, ucet: data.Ucet) -> bool: return _route_has_bankterm(route) and _ucet_has_bankterm_payment(ucet) def _fiscal_receipt_payments(ucet: data.Ucet, use_bankterm: bool = False) -> dict: rows: list[dict] = [] for idx, payment in enumerate(getattr(ucet, "platby", []) or [], start=1): amount = abs(_float_value(getattr(payment, "suma", 0), 0.0)) amount_base = abs(_float_value(getattr(payment, "suma_czk", amount), amount)) fiscal_index = _payment_fiscal_index(payment) payment_code = _strip_value(getattr(payment, "code", "")).upper() payment_uses_bankterm = use_bankterm and bool(getattr(payment, "is_bankterm", False)) rows.append({ "cislo": idx, "kompez": 0, "typpl": "", "text": _strip_value(getattr(payment, "nazev", "") or getattr(payment, "code", "")), "mena": _strip_value(getattr(payment, "unit", "")), "kurz": f"{_float_value(getattr(payment, 'rate', 1), 1.0):.3f}", "suma": amount_base, "suma_mena": amount, "typ": fiscal_index, "nazev_platby": payment_code, "prg_dotaz": "", "obsluzne": abs(_float_value(getattr(payment, "tip", 0), 0.0)), "overpay": 0.0, "karta": payment_uses_bankterm, "loyalman_zlava": 0.0, "fiskal_pro_payment_index": fiscal_index, }) return _dict_with_1_based_keys(rows) def _fiscal_receipt_text_dict(lines: list[str]) -> dict: return _dict_with_1_based_keys([_strip_value(line) for line in lines if _strip_value(line)]) def _parse_receipt_datetime(value) -> datetime | None: raw = str(value or "").strip() if not raw: return None for fmt in ("%y%m%d %H:%M:%S", "%Y%m%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(raw[:19], fmt) except Exception: pass return None def _receipt_time_text(value) -> str: raw = str(value or "").strip() if not raw: return "" dt = _parse_receipt_datetime(raw) if dt: return dt.strftime("%H:%M") return raw[:5] def _receipt_date_text(value) -> str: dt = _parse_receipt_datetime(value) if dt: return dt.strftime("%d.%m.%Y") return datetime.now().strftime("%d.%m.%Y") def _fiscal_receipt_text_top(ucet: data.Ucet) -> dict[str, str]: open_time = _receipt_time_text(getattr(ucet, "open_at", "")) closed_value = getattr(ucet, "closed_at", "") or getattr(ucet, "datetime", "") closed_time = _receipt_time_text(closed_value) date_text = _receipt_date_text(closed_value) external_parts = [] if open_time or closed_time: external_parts.append(f"{open_time}-{closed_time}".strip("-")) external_parts.append(date_text) if getattr(ucet, "ucislo", None): external_parts.append(str(ucet.ucislo)) return { "1": " ".join(part for part in external_parts if part), } def _has_tip_on_receipt(ucet: data.Ucet) -> bool: return any(abs(_float_value(getattr(payment, "tip", 0), 0.0)) >= 0.005 for payment in (getattr(ucet, "platby", []) or [])) def _fiscal_receipt_text_bottom(ucet: data.Ucet, table_name: str = "") -> list[str]: lines: list[str] = [] table_name = _strip_value(table_name or getattr(ucet, "stul", "")) receipt_no = _strip_value(getattr(ucet, "ucislo", "")) if receipt_no and table_name: lines.append(f"{receipt_no}/ stôl: {table_name}") elif receipt_no: lines.append(receipt_no) elif table_name: lines.append(f"stôl: {table_name}") author = _strip_value(getattr(ucet, "autor", "")) if author: lines.append(f"Obsluha: {author}") if not _has_tip_on_receipt(ucet): lines.append("TIP: ..............") return lines def _fiscal_receipt_vat(ucet: data.Ucet) -> list[dict]: if not getattr(ucet, "dane", None): try: ucet = ucet.model_copy(deep=True) ucet.sumdph() except Exception: pass rows = [] for vat in getattr(ucet, "dane", []) or []: hladina = _fiscal_dph_value(getattr(vat, "rate", "")) zaklad = round(_float_value(getattr(vat, "zaklad", 0), 0.0), 2) dan = round(zaklad * (hladina or 0) / 100, 2) rows.append({ "hladina": hladina, "zaklad": zaklad, "dan": dan, }) return rows def _fiscal_receipt_bill_id(ucet: data.Ucet) -> str: fiscal_result = getattr(ucet, "fiscal_result", {}) or {} if not isinstance(fiscal_result, dict): return "" candidates = [ fiscal_result.get("bill_id"), fiscal_result.get("BILL_ID"), ] ret = fiscal_result.get("return") if isinstance(ret, dict): candidates.extend([ret.get("bill_id"), ret.get("BILL_ID")]) response = fiscal_result.get("response") if isinstance(response, dict): response_ret = response.get("return") if isinstance(response_ret, dict): candidates.extend([response_ret.get("bill_id"), response_ret.get("BILL_ID")]) else: candidates.append(response_ret) for candidate in candidates: value = _strip_value(candidate) if value: return value return "" def _build_fiscal_receipt_payload( req: data.FiscalReceiptPrintRequest, route: dict, bill_id: str = "", table_name: str = "", ) -> dict: ucet = req.ucet is_storno = bool(getattr(ucet, "is_storno", None)) resolved_table_name = _strip_value(table_name or getattr(ucet, "stul", "")) use_bankterm = _use_afs_bankterm(route, ucet) fiscal_type = _fiscal_receipt_document_type(ucet) return { "ucet": _strip_value(getattr(ucet, "ucislo", "")), "typ_poloziek": fiscal_type, "storno": is_storno, "preducet": False, "email": _strip_value(getattr(ucet, "receipt_email", "")) if getattr(ucet, "send_receipt_email", False) else "", "polozky": _fiscal_receipt_items(ucet), "platby": _fiscal_receipt_payments(ucet, use_bankterm=use_bankterm), "text_top": _fiscal_receipt_text_top(ucet), "text_bottom": _fiscal_receipt_text_dict(_fiscal_receipt_text_bottom(ucet, resolved_table_name)), "printer_name": _strip_value(route.get("prn_name", "")), "printer_no": _strip_value(route.get("prn_no", "")), "transaction_result": {}, "client_id": str(uuid.uuid4()), "bill_id": bill_id or "", "zaokruhlenie": round(_float_value(getattr(ucet, "round50", 0), 0.0), 2), "cancel_payment_on_fail": False, "discount_text": None, "employee_name": _strip_value(getattr(ucet, "autor", "")), "strih": _decode_printer_command(route.get("p_fullcut")), "dph": _fiscal_receipt_vat(ucet), "table_name": resolved_table_name, } def _fiskal_next_bill_id(route: dict) -> str: url = f"{_fiskal_base_url(route)}/next_bill_id" logger.info("Fiscal next_bill_id request: url=%s", url) try: response = requests.get(url, timeout=30, verify=False) response.raise_for_status() data_obj = response.json() except requests.RequestException as exc: raise RuntimeError(f"Nepodarilo sa ziskat next_bill_id z fiskalneho servera ({url}): {exc}") from exc except Exception as exc: raise RuntimeError(f"Fiskalny server vratil necitatelne next_bill_id ({url}): {exc}") from exc logger.info("Fiscal next_bill_id response: %s", json.dumps(data_obj, ensure_ascii=False)[:1000]) if isinstance(data_obj, dict) and data_obj.get("code", 0) not in (0, "0", None): raise RuntimeError(f"{data_obj.get('code')}: {data_obj.get('code_text') or data_obj}") bill_id = _strip_value((data_obj or {}).get("return") if isinstance(data_obj, dict) else "") if not bill_id: raise RuntimeError("Fiskalny server nevratil next_bill_id") return bill_id def _fiskal_receipt_result(route: dict, bill_id: str, timeout: float = 30.0) -> dict: bill_id = _strip_value(bill_id) if not bill_id: raise RuntimeError("Chyba bill_id pre overenie fiskalneho dokladu") url = f"{_fiskal_base_url(route)}/paragon/{bill_id}/result" logger.info("Fiscal receipt result request: url=%s", url) try: response = requests.get(url, timeout=timeout, verify=False) response.raise_for_status() except requests.RequestException as exc: body = "" resp = getattr(exc, "response", None) if resp is not None: body = str(getattr(resp, "text", "") or "")[:1000] raise RuntimeError(f"Nepodarilo sa overit fiskalny doklad ({url}): {exc}; {body}") from exc try: response_data = response.json() except Exception as exc: logger.error("Fiscal receipt result invalid JSON response: %s", str(response.text or "")[:1000]) raise RuntimeError(f"Fiskalny server vratil necitatelny vysledok dokladu: {str(response.text or '')[:500]}") from exc logger.info("Fiscal receipt result response: %s", json.dumps(response_data, ensure_ascii=False)[:2000]) if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None): raise RuntimeError( f"{response_data.get('code')}: " f"{response_data.get('code_text') or response_data.get('message') or response_data}" ) return response_data def _send_fiscal_receipt_payload(route: dict, payload: dict, timeout: float = 300.0) -> dict: url = f"{_fiskal_base_url(route)}/paragon/send" logger.info( "Fiscal receipt request: url=%s ucet=%s items=%s payments=%s", url, payload.get("ucet") or payload.get("receipt_no") or "", len(payload.get("polozky") or {}), len(payload.get("platby") or {}), ) logger.info("Fiscal receipt payload: %s", json.dumps(payload, ensure_ascii=False)[:4000]) try: response = requests.post( url, json=payload, timeout=timeout, verify=False, ) response.raise_for_status() except requests.exceptions.ReadTimeout as exc: bill_id = _strip_value(payload.get("bill_id")) if bill_id: logger.warning( "Fiscal receipt send timed out; checking already assigned bill_id=%s", bill_id, ) try: return _fiskal_receipt_result(route, bill_id, timeout=30.0) except Exception as result_exc: logger.exception( "Fiscal receipt result check failed after send timeout: bill_id=%s", bill_id, ) raise RuntimeError( f"Fiskalny server neodpovedal na tlac a nepodarilo sa overit doklad {bill_id}: {result_exc}" ) from exc raise RuntimeError(f"Fiskalny server neodpovedal na tlac ({url}): {exc}") from exc except requests.RequestException as exc: body = "" resp = getattr(exc, "response", None) if resp is not None: body = str(getattr(resp, "text", "") or "")[:1000] raise RuntimeError(f"Komunikacia s fiskalnym serverom zlyhala ({url}): {exc}; {body}") from exc try: response_data = response.json() except Exception: response_data = {"raw": response.text} logger.error("Fiscal receipt invalid JSON response: %s", str(response.text or "")[:1000]) raise RuntimeError(f"Fiskalny server vratil necitatelnu odpoved: {str(response.text or '')[:500]}") logger.info("Fiscal receipt response: %s", json.dumps(response_data, ensure_ascii=False)[:2000]) if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None): raise RuntimeError( f"{response_data.get('code')}: " f"{response_data.get('code_text') or response_data.get('message') or response_data}" ) return response_data def _send_fiscal_receipt_copy(route: dict, bill_id: str, timeout: float = 300.0) -> dict: bill_id = _strip_value(bill_id) if not bill_id: raise RuntimeError("Chyba bill_id pre tlac fiskalnej kopie") url = f"{_fiskal_base_url(route)}/paragon/copy/{quote(bill_id, safe='')}" logger.info("Fiscal receipt copy request: url=%s bill_id=%s", url, bill_id) try: response = requests.get(url, timeout=timeout, verify=False) response.raise_for_status() except requests.RequestException as exc: body = "" resp = getattr(exc, "response", None) if resp is not None: body = str(getattr(resp, "text", "") or "")[:1000] raise RuntimeError(f"Komunikacia s fiskalnym serverom pri tlaci kopie zlyhala ({url}): {exc}; {body}") from exc try: response_data = response.json() except Exception: logger.error("Fiscal receipt copy invalid JSON response: %s", str(response.text or "")[:1000]) raise RuntimeError(f"Fiskalny server vratil necitatelnu odpoved pri tlaci kopie: {str(response.text or '')[:500]}") logger.info("Fiscal receipt copy response: %s", json.dumps(response_data, ensure_ascii=False)[:2000]) if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None): raise RuntimeError( f"{response_data.get('code')}: " f"{response_data.get('code_text') or response_data.get('message') or response_data}" ) return response_data def _send_fiscal_cash_operation( route: dict, payment_id: str, payment_name: str, amount: float, timeout: float = 120.0, ) -> dict: payment_id = quote(_strip_value(payment_id), safe="") payment_name = quote(_strip_value(payment_name), safe="") amount_text = quote(f"{float(amount):.2f}", safe="") url = f"{_fiskal_base_url(route)}/depozite/{payment_id}/{payment_name}/{amount_text}" logger.info("Fiscal cash operation request: url=%s", url) try: response = requests.get(url, timeout=timeout, verify=False) response.raise_for_status() except requests.RequestException as exc: body = "" resp = getattr(exc, "response", None) if resp is not None: body = str(getattr(resp, "text", "") or "")[:1000] raise RuntimeError(f"Fiskalny vklad/vyber sa nepodarilo vykonat ({url}): {exc}; {body}") from exc try: response_data = response.json() except Exception: logger.error("Fiscal cash operation invalid JSON response: %s", str(response.text or "")[:1000]) raise RuntimeError(f"Fiskalny server vratil necitatelnu odpoved pri vklade/vybere: {str(response.text or '')[:500]}") logger.info("Fiscal cash operation response: %s", json.dumps(response_data, ensure_ascii=False)[:2000]) if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None): raise RuntimeError( f"{response_data.get('code')}: " f"{response_data.get('code_text') or response_data.get('message') or response_data}" ) return response_data def _fiscal_result_from_response(route: dict, response: dict) -> dict: ret = response.get("return") if isinstance(response, dict) else None result = { "printer_no": _strip_value(route.get("prn_no", "")), "printer_name": _strip_value(route.get("prn_name", "")), "id_term": _strip_value(route.get("id_term", "")), "printed_at": datetime.now().isoformat(sep=" ", timespec="seconds"), } if isinstance(response, dict): result["code"] = response.get("code", 0) if response.get("code_text") or response.get("message"): result["message"] = response.get("code_text") or response.get("message") if isinstance(ret, dict): result["okp"] = ret.get("OKP") or ret.get("okp") or "" result["uid"] = ret.get("UID") or ret.get("uid") or "" result["bill_id"] = ret.get("BILL_ID") or ret.get("bill_id") or "" if ret.get("transaction_result"): result["transaction_result"] = ret.get("transaction_result") elif ret not in (None, 0, False): result["bill_id"] = str(ret) return result def _store_ucet_fiscal_result(prefix: str, ucet: data.Ucet) -> data.Ucet: if not getattr(ucet, "ucislo", None): return ucet table = f"{prefix}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT data FROM "{table}" WHERE ucislo=? AND id_kas=?', (ucet.ucislo, ucet.id_kas), ) row = cur.fetchone() if row: try: stored = data.Ucet.model_validate_json(row[0]) except Exception: stored = ucet stored.fiscal_result = dict(getattr(ucet, "fiscal_result", {}) or {}) payload = stored.model_dump_json() cur.execute( f'UPDATE "{table}" SET data=? WHERE ucislo=? AND id_kas=?', (payload, ucet.ucislo, ucet.id_kas), ) conn.commit() return stored return ucet @app.post("/print/fiscal/receipt/", response_model=data.FiscalReceiptPrintOut) def print_fiscal_receipt( req: data.FiscalReceiptPrintRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return print_fiscal_receipt_db(prefix, req) def print_fiscal_receipt_db( prefix: str, req: data.FiscalReceiptPrintRequest, ) -> data.FiscalReceiptPrintOut: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") if not getattr(req.ucet, "ucislo", None): raise HTTPException(422, "Fiscal receipt must have ucislo") if not _ucet_has_fiscal_payment(req.ucet): raise HTTPException(409, "Ucet nema fiskalnu platbu") printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip() if not printer_no: raise HTTPException(422, "Receipt printer is not set") with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) target_numbers = _printer_target_numbers(printer_no, prndef_map) if len(target_numbers) != 1: raise HTTPException(409, "Fiskalny original musi mat prave jednu cielovu tlaciaren") target_no = target_numbers[0] route = _printer_route(prndef_map.get(target_no)) if route.get("route_type") != "fiskal": raise HTTPException(409, f"Tlaciaren {target_no} nie je nastavena ako FISKAL") try: bill_id = _fiskal_next_bill_id(route) except Exception as exc: logger.exception( "Fiscal next_bill_id failed: prefix=%s id_kas=%s ucet=%s printer=%s", prefix, req.id_kas, getattr(req.ucet, "ucislo", ""), target_no, ) raise HTTPException(502, f"Fiskalny doklad nebol vytlaceny: {exc}") from exc table_name = resolve_table_name_from_map(prefix, req.id_kas, getattr(req.ucet, "stul", "")) payload = _build_fiscal_receipt_payload(req, route, bill_id=bill_id, table_name=table_name) logger.info( "Fiscal receipt send: prefix=%s id_kas=%s ucet=%s printer=%s payments=%s", prefix, req.id_kas, getattr(req.ucet, "ucislo", ""), target_no, len(getattr(req.ucet, "platby", []) or []), ) try: response = _send_fiscal_receipt_payload(route, payload) except HTTPException: raise except Exception as exc: logger.exception( "Fiscal receipt failed: prefix=%s id_kas=%s ucet=%s printer=%s", prefix, req.id_kas, getattr(req.ucet, "ucislo", ""), target_no, ) raise HTTPException(502, f"Fiskalny doklad nebol vytlaceny: {exc}") from exc fiscal_result = _fiscal_result_from_response(route, response) result_ucet = req.ucet.model_copy(deep=True) result_ucet.fiscal_result = fiscal_result result_ucet = _store_ucet_fiscal_result(prefix, result_ucet) return data.FiscalReceiptPrintOut( ok=True, ucet=result_ucet, response=response if isinstance(response, dict) else {"raw": response}, fiscal_result=fiscal_result, ) @app.post("/print/fiscal/receipt/copy/", response_model=data.FiscalReceiptPrintOut) def print_fiscal_receipt_copy( req: data.FiscalReceiptCopyRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return print_fiscal_receipt_copy_db(prefix, req) def print_fiscal_receipt_copy_db( prefix: str, req: data.FiscalReceiptCopyRequest, ) -> data.FiscalReceiptPrintOut: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") if not getattr(req.ucet, "ucislo", None): raise HTTPException(422, "Fiscal receipt copy must have ucislo") if not _ucet_has_fiscal_payment(req.ucet): raise HTTPException(409, "Ucet nema fiskalnu platbu") bill_id = _strip_value(req.bill_id) or _fiscal_receipt_bill_id(req.ucet) if not bill_id: raise HTTPException(422, "Ucet nema ulozeny bill_id pre tlac fiskalnej kopie") printer_no = str(req.printer_no or getattr(req.ucet, "bill_printer", "") or "").strip() if not printer_no: raise HTTPException(422, "Receipt printer is not set") with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) target_numbers = _printer_target_numbers(printer_no, prndef_map) if len(target_numbers) != 1: raise HTTPException(409, "Fiskalna kopia musi mat prave jednu cielovu tlaciaren") target_no = target_numbers[0] route = _printer_route(prndef_map.get(target_no)) if route.get("route_type") != "fiskal": raise HTTPException(409, f"Tlaciaren {target_no} nie je nastavena ako FISKAL") try: response = _send_fiscal_receipt_copy(route, bill_id) except Exception as exc: logger.exception( "Fiscal receipt copy failed: prefix=%s id_kas=%s ucet=%s printer=%s bill_id=%s", prefix, req.id_kas, getattr(req.ucet, "ucislo", ""), target_no, bill_id, ) raise HTTPException(502, f"Fiskalna kopia dokladu nebola vytlacena: {exc}") from exc fiscal_result = _fiscal_result_from_response(route, response) fiscal_result.update({ "copy": True, "bill_id": bill_id, }) return data.FiscalReceiptPrintOut( ok=True, ucet=req.ucet, response=response if isinstance(response, dict) else {"raw": response}, fiscal_result=fiscal_result, ) @app.post("/print/fiscal/cash-operation/", response_model=data.FiscalCashOperationOut) def print_fiscal_cash_operation( req: data.FiscalCashOperationRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, client_id = auth return print_fiscal_cash_operation_db(prefix, req, client_id) def _cash_operation_label(operation: str) -> str: op = str(operation or "").strip().lower() if op in {"manual_deposit", "deposit", "vklad"}: return "Vklad" if op in {"manual_withdrawal", "withdrawal", "withdraw", "vyber", "výber"}: return "Vyber" raise HTTPException(422, "Invalid cash operation") def _cash_operation_kind(operation: str) -> tuple[str, str]: op = str(operation or "").strip().lower() if op in {"manual_deposit", "deposit", "vklad"}: return "manual_deposit", "Vklad" if op == "auto_deposit": return "auto_deposit", "Vklad" if op in {"manual_withdrawal", "withdrawal", "withdraw", "vyber", "vyber"}: return "manual_withdrawal", "Vyber" if op == "auto_withdrawal": return "auto_withdrawal", "Vyber" raise HTTPException(422, "Invalid cash operation") def _cash_operation_origin(operation_code: str, operation_label: str) -> str: if str(operation_code or "").startswith("auto_"): return f"Auto {operation_label.lower()}" return operation_label def insert_fiscal_cash_operation_ucet( cur_pref: str, uct: data.Ucet, ) -> dict: table = f"{cur_pref}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) if not uct.ucislo: uct.ucislo = generate_ucislo(cur, table, uct.id_kas) if not uct.closed_at: uct.closed_at = data.stime_str() if not uct.datetime: uct.datetime = uct.closed_at if not uct.open_at: uct.open_at = uct.closed_at uct.stul = _strip_value(getattr(uct, "stul", "")) uct.table_name = _strip_value(getattr(uct, "table_name", "")) uct.room_name = _strip_value(getattr(uct, "room_name", "")) uct.blocked_by = "" uct.checksum_val = uct.checksum() payload = uct.model_dump_json() cur.execute(f""" INSERT INTO "{table}" (ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( uct.ucislo, uct.id_kas, uct.stul, uct.room_name, "", uct.closed_at, uct.c_uzaverka, payload, )) return ucet_save_response({ "operation": "insert-cash-operation", "ucislo": uct.ucislo, }, uct) def print_fiscal_cash_operation_db( prefix: str, req: data.FiscalCashOperationRequest, client_id: str, c_uzaverka: int | None = None, ) -> data.FiscalCashOperationOut: if len(req.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") amount = round(abs(_float_value(req.amount, 0.0)), 2) if amount <= 0: raise HTTPException(422, "Suma musi byt vacsia ako nula") operation, operation_label = _cash_operation_kind(req.operation) signed_amount = amount if operation.endswith("deposit") else -amount payment = req.payment if not bool(getattr(payment, "fiscal", False)): raise HTTPException(409, "Pre vklad/vyber je mozne pouzit iba fiskalnu platbu") printer_no = _strip_value(req.printer_no) if not printer_no: raise HTTPException(422, "Nie je nastavena fiskalna tlaciaren") with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) target_numbers = _printer_target_numbers(printer_no, prndef_map) if len(target_numbers) != 1: raise HTTPException(409, "Vklad/vyber musi mat prave jednu cielovu fiskalnu tlaciaren") target_no = target_numbers[0] route = _printer_route(prndef_map.get(target_no)) if route.get("route_type") != "fiskal": raise HTTPException(409, f"Tlaciaren {target_no} nie je nastavena ako FISKAL") payment_id = str(_payment_fiscal_index(payment)) payment_name = _strip_value(getattr(payment, "name", "") or getattr(payment, "code", "")) try: response = _send_fiscal_cash_operation(route, payment_id, payment_name, signed_amount) except HTTPException: raise except Exception as exc: logger.exception( "Fiscal cash operation failed: prefix=%s id_kas=%s operation=%s printer=%s", prefix, req.id_kas, operation, target_no, ) raise HTTPException(502, f"Fiskalny {operation_label.lower()} nebol vykonany: {exc}") from exc fiscal_result = _fiscal_result_from_response(route, response) fiscal_result.update({ "cash_operation": operation, "amount": signed_amount, "payment_code": _strip_value(getattr(payment, "code", "")), "payment_name": payment_name, }) now_text = data.stime_str() ucet = data.Ucet( id_kas=req.id_kas, ucislo=None, stul="", table_name="", room_name="", autor=_strip_value(req.author), open_at=now_text, closed_at=now_text, datetime=now_text, origin=_cash_operation_origin(operation, operation_label), cash_operation=operation, c_uzaverka=c_uzaverka, bill_printer=target_no, total_base_currency=signed_amount, fiscal_result=fiscal_result, platby=[ data.Platba( code=_strip_value(getattr(payment, "code", "")), nazev=payment_name, suma=signed_amount, unit=_strip_value(getattr(payment, "unit", "")), rate=_float_value(getattr(payment, "rate", 1.0), 1.0), suma_czk=signed_amount, fiscal=True, is_bankterm=False, p_kopii=int(getattr(payment, "p_kopii", 1) or 1), ) ], poloz=[], dane=[], ) try: insert_fiscal_cash_operation_ucet(prefix, ucet) except Exception as exc: logger.exception( "Fiscal cash operation was printed but account save failed: prefix=%s id_kas=%s operation=%s", prefix, req.id_kas, operation, ) raise HTTPException(500, f"Fiskalny {operation_label.lower()} bol vytlaceny, ale doklad sa nepodarilo ulozit: {exc}") from exc return data.FiscalCashOperationOut( ok=True, ucet=ucet, response=response if isinstance(response, dict) else {"raw": response}, fiscal_result=fiscal_result, ) def _print_template_out(path: Path, kind: str = "bon") -> data.PrintTemplateOut: stem = path.stem scope = "custom" printer_no = "" template_kind = str(kind or "bon").strip().lower() if template_kind in {"closure", "clsrep", "uzavierka"}: prefix_name = "TP-closure" output_kind = "closure" elif template_kind in {"ucet", "receipt", "bill"}: prefix_name = "TP-ucet" output_kind = "ucet" else: prefix_name = "TP-bon" output_kind = "bon" if stem == f"{prefix_name}_default": scope = "default" else: match = re.fullmatch(rf"{re.escape(prefix_name)}_(.+)", stem) if match: suffix = match.group(1) if suffix.isdigit(): scope = "printer" printer_no = suffix else: scope = "named" try: stat = path.stat() size = int(stat.st_size) modified_at = datetime.fromtimestamp(stat.st_mtime).isoformat(sep=" ", timespec="seconds") except Exception: size = 0 modified_at = "" return data.PrintTemplateOut( name=path.name, kind=output_kind, template_bon=path.name if output_kind == "bon" else "", template_ucet=path.name if output_kind == "ucet" else "", template_closure=path.name if output_kind == "closure" else "", scope=scope, printer_no=printer_no, size=size, modified_at=modified_at, ) def list_print_templates_db(kind: str = "bon") -> list[data.PrintTemplateOut]: template_kind = str(kind or "bon").strip().lower() if template_kind not in {"bon", "kitchen_bon", "ucet", "receipt", "bill", "closure", "clsrep", "uzavierka"}: raise HTTPException(422, f"Unsupported print template kind {kind}") if template_kind in {"closure", "clsrep", "uzavierka"}: template_prefix = "TP-closure" elif template_kind in {"ucet", "receipt", "bill"}: template_prefix = "TP-ucet" else: template_prefix = "TP-bon" templates_dir = _print_templates_dir() base_dir = Path(__file__).resolve().parent if not templates_dir.exists() and not base_dir.exists(): return [] files: list[Path] = [] for suffix in PRINT_TEMPLATE_EXTENSIONS: if templates_dir.exists(): files.extend(templates_dir.glob(f"{template_prefix}*{suffix}")) if template_prefix == "TP-closure": files.extend(base_dir.glob(f"{template_prefix}*{suffix}")) unique = {path.name: path for path in files if path.is_file()} result = [_print_template_out(path, kind=template_kind) for path in unique.values()] scope_order = {"default": 0, "printer": 1, "named": 2, "custom": 3} return sorted(result, key=lambda item: (scope_order.get(item.scope, 9), item.printer_no or "", item.name.lower())) @app.get("/print/templates/", response_model=list[data.PrintTemplateOut]) def list_print_templates( kind: str = Query(default="bon"), auth: tuple[str, str, str] = Depends(auth_ctx), ): return list_print_templates_db(kind) @app.get("/print/jobs/", response_model=list[data.PrintJob]) def list_print_jobs( id_kas: str, status: str | None = Query(default=None), printer_no: str | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_print_jobs" sql = f'SELECT {_print_job_select_columns()} FROM "{table}" WHERE id_kas=?' params: list = [id_kas] if status: sql += " AND status=?" params.append(status) if printer_no: sql += " AND printer_no=?" params.append(printer_no) sql += " ORDER BY id DESC LIMIT ?" params.append(int(limit)) with get_db() as conn: cur = conn.cursor() cur.execute(sql, tuple(params)) return [_print_job_from_row(row) for row in cur.fetchall()] @app.post("/print/jobs/claim/", response_model=list[data.PrintJob]) def claim_print_jobs( req: data.PrintJobClaimRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return claim_print_jobs_db(prefix, req) def claim_print_jobs_db(prefix: str, req: data.PrintJobClaimRequest) -> list[data.PrintJob]: if not req.agent_id.strip(): raise HTTPException(422, "Invalid agent_id") table = f"{prefix}_print_jobs" limit = max(1, min(int(req.limit or 10), 100)) printers = [p for p in (req.printers or []) if str(p).strip()] placeholders = ",".join("?" for _ in PRINT_JOB_ACTIVE_STATUSES) params: list = [*PRINT_JOB_ACTIVE_STATUSES] sql = f""" SELECT id FROM "{table}" WHERE status IN ({placeholders}) """ if printers: prn_placeholders = ",".join("?" for _ in printers) sql += f" AND (printer_no='' OR printer_no IN ({prn_placeholders}))" params.extend(printers) sql += " ORDER BY priority, id LIMIT ?" params.append(limit) with get_db() as conn: cur = conn.cursor() prndef_map = _load_prndef_map_cur(cur, prefix) cur.execute(sql, tuple(params)) ids = [int(row[0]) for row in cur.fetchall()] claimed: list[data.PrintJob] = [] for job_id in ids: cur.execute( f""" UPDATE "{table}" SET status='claimed', agent_id=?, attempts=attempts+1, claimed_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=? AND status IN ({placeholders}) """, (req.agent_id, job_id, *PRINT_JOB_ACTIVE_STATUSES), ) if cur.rowcount: job = _load_print_job_cur(cur, table, job_id) route = _printer_route(prndef_map.get(str(job.printer_no or ""))) if route: payload = dict(job.payload or {}) payload["route"] = route cur.execute( f'UPDATE "{table}" SET payload=?, updated_at=CURRENT_TIMESTAMP WHERE id=?', (_json_dump_obj(payload), job_id), ) job = _load_print_job_cur(cur, table, job_id) claimed.append(job) conn.commit() return claimed @app.post("/print/jobs/{job_id}/status", response_model=data.PrintJob) def update_print_job_status( job_id: int, update: data.PrintJobStatusUpdate, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_print_job_status_db(prefix, job_id, update) def update_print_job_status_db( prefix: str, job_id: int, update: data.PrintJobStatusUpdate, ) -> data.PrintJob: status = (update.status or "").strip() if status not in PRINT_JOB_VISIBLE_STATUSES: raise HTTPException(422, f"Invalid print job status {status}") table = f"{prefix}_print_jobs" set_parts = [ "status=?", "result=?", "error=?", "updated_at=CURRENT_TIMESTAMP", ] params: list = [ status, _json_dump_obj(update.result), update.error or "", ] if status == "printing": set_parts.append("started_at=COALESCE(started_at, CURRENT_TIMESTAMP)") if status in {"printed", "failed", "failed_final", "cancelled"}: set_parts.append("finished_at=CURRENT_TIMESTAMP") with get_db() as conn: cur = conn.cursor() cur.execute( f'UPDATE "{table}" SET {", ".join(set_parts)} WHERE id=?', (*params, job_id), ) if cur.rowcount == 0: raise HTTPException(404, f"Print job {job_id} not found") conn.commit() return _load_print_job_cur(cur, table, job_id) @app.post("/print/jobs/{job_id}/retry", response_model=data.PrintJob) def retry_print_job( job_id: int, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return retry_print_job_db(prefix, job_id) def retry_print_job_db(prefix: str, job_id: int) -> data.PrintJob: table = f"{prefix}_print_jobs" with get_db() as conn: cur = conn.cursor() cur.execute( f""" UPDATE "{table}" SET status='queued', agent_id=NULL, attempts=0, result='{{}}', error='', claimed_at=NULL, started_at=NULL, finished_at=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=? AND status != 'printed' """, (job_id,), ) if cur.rowcount == 0: raise HTTPException(404, f"Print job {job_id} not found or already printed") conn.commit() return _load_print_job_cur(cur, table, job_id) def update_print_job_payload_db(prefix: str, job_id: int, payload: dict) -> data.PrintJob: table = f"{prefix}_print_jobs" with get_db() as conn: cur = conn.cursor() cur.execute( f'UPDATE "{table}" SET payload=?, updated_at=CURRENT_TIMESTAMP WHERE id=?', (_json_dump_obj(payload), job_id), ) if cur.rowcount == 0: raise HTTPException(404, f"Print job {job_id} not found") conn.commit() return _load_print_job_cur(cur, table, job_id) def _route_int(route: dict, key: str, default: int) -> int: try: value = int(str(route.get(key) or "").strip()) return value if value > 0 else default except Exception: return default def _require_route_value(route: dict, key: str) -> str: value = str(route.get(key) or "").strip() if not value: raise RuntimeError(f"Printer route missing {key}") return value DEFAULT_KITCHEN_BON_TEMPLATE = """{{ printer.reset }} {{ printer.bigfont_on }}Stol: {{ table_name|truncate(printer.max_characters - 6) }}{{ printer.bigfont_off }} {{ cashier_name|truncate(printer.max_characters) }} {{ created_at.strftime('%d.%m.%Y,%H:%M') }} => {{ printer_name|truncate(printer.max_characters - 21) }} {{ '-'|repeat(printer.max_characters) }} {% if is_storno %} {% if is_bill_cancel %} {{ "STORNO STAREHO UCTU"|box('*', printer.max_characters) }} {% else %} {{ "S T O R N O"|box('*', printer.max_characters) }} {% endif %} {% endif %} {% for item in items %} {% if item.print_course_header %} {% set chod = '******** Chod: ' + item.course_name|string|trim + ' ********' %} {{ chod|center(printer.max_characters) }} {% endif %} {% if item.print_guest_header %} {% set host = '---- Host: ' + item.guest_name|string|trim + ' ----' %} {{ host|center(printer.max_characters) }} {% endif %} {{ item.description }} {% for line in item.order_lines %} {{ line }} {% endfor %} {% endfor %} {{ printer.bigfont_on }} Stol: {{ table_name|truncate(printer.max_characters - 6) }}{{ printer.bigfont_off }} Casnik: {{ user|truncate(printer.max_characters - 8) }} {% if locator_number %} {{ '*'|repeat(printer.max_characters) }} Cislo objednavky: {{ locator_number }} {{ '*'|repeat(printer.max_characters) }} {% endif %} Bon: {{ bon_count }} {{ cashier_name|truncate(printer.max_characters) }} {{ printer.crlf }} {{ printer.fullcut }} """ DEFAULT_RECEIPT_TEMPLATE = """{{ printer.reset }}{{ separator }} {{ header_lines|join('\n') }} {{ separator }} {{ item_lines|join('\n') }} {{ separator }} {{ summary_lines|join('\n') }}{% if payment_lines %}{{ '\n' }}{{ separator }} {{ payment_lines|join('\n') }}{% endif %}{% if tax_lines %}{{ '\n' }}{{ separator }} {{ tax_lines|join('\n') }}{% endif %}{% if footer_lines %}{{ '\n' }}{{ separator }} {{ footer_lines|join('\n') }}{% endif %}{{ '\n' }}{{ separator }} {{ printer.crlf }} {{ printer.fullcut }} """ def _format_print_quantity(pol, *, absolute: bool = False) -> str: pocet = _float_value(getattr(pol, "pocet", 0), 0.0) if absolute: pocet = abs(pocet) delitel = _int_value(getattr(pol, "delitel", 1), 1) or 1 if delitel != 1: left = int(pocet) if float(pocet).is_integer() else pocet return f"{left}/{delitel}" return str(int(pocet)) if float(pocet).is_integer() else str(pocet) def _printer_width(route: dict | None, default: int = 40) -> int: route = route or {} try: width = int(str(route.get("p_width") or "").strip()) except Exception: width = default return max(32, min(width or default, 80)) def _decode_printer_command(value) -> str: if value is None: return "" if isinstance(value, bytes): return value.decode("latin-1", errors="ignore") text = str(value) if not text: return "" stripped = text.strip() if (stripped.startswith("b'") or stripped.startswith('b"')) and stripped[-1:] in {"'", '"'}: try: parsed = ast.literal_eval(stripped) if isinstance(parsed, bytes): return parsed.decode("latin-1", errors="ignore") except Exception: pass def chr_repl(match): try: return chr(int(match.group(1))) except Exception: return match.group(0) had_chr_syntax = bool(re.search(r"(?i)chr\(\s*\d{1,3}\s*\)", text)) text = re.sub(r"(?i)chr\(\s*(\d{1,3})\s*\)", chr_repl, text) if had_chr_syntax: text = re.sub(r"\s*\+\s*", "", text) def brace_repl(match): try: return chr(int(match.group(1))) except Exception: return match.group(0) text = re.sub(r"\{(\d{1,3})\}", brace_repl, text) def hex_repl(match): try: return chr(int(match.group(1), 16)) except Exception: return match.group(0) text = re.sub(r"\\x([0-9a-fA-F]{2})", hex_repl, text) if "\\" in text: text = ( text .replace("\\r", "\r") .replace("\\n", "\n") .replace("\\t", "\t") ) return text PRINT_TEMPLATE_EXTENSIONS = (".jinja2", ".jinja") def _print_templates_dir() -> Path: return Path(__file__).with_name("templates") def _template_name_candidates(name: str) -> list[Path]: raw_name = str(name or "").strip() if not raw_name: return [] path = Path(raw_name) if path.is_absolute(): base = path else: base = _print_templates_dir() / path.name candidates = [base] if base.suffix.lower() not in PRINT_TEMPLATE_EXTENSIONS: candidates.extend(Path(f"{base}{suffix}") for suffix in PRINT_TEMPLATE_EXTENSIONS) unique: list[Path] = [] seen: set[str] = set() for candidate in candidates: key = str(candidate) if key not in seen: unique.append(candidate) seen.add(key) return unique def _kitchen_bon_template_candidates(route: dict | None = None) -> list[Path]: route = route or {} candidates: list[Path] = [] candidates.extend(_template_name_candidates(route.get("template_bon") or "")) prn_no = str(route.get("prn_no") or "").strip() if prn_no: candidates.extend(_template_name_candidates(f"TP-bon_{prn_no}")) candidates.extend(_template_name_candidates("TP-bon_default")) unique: list[Path] = [] seen: set[str] = set() for candidate in candidates: key = str(candidate) if key not in seen: unique.append(candidate) seen.add(key) return unique def _receipt_template_candidates(route: dict | None = None) -> list[Path]: route = route or {} candidates: list[Path] = [] candidates.extend(_template_name_candidates(route.get("template_ucet") or "")) prn_no = str(route.get("prn_no") or "").strip() if prn_no: candidates.extend(_template_name_candidates(f"TP-ucet_{prn_no}")) candidates.extend(_template_name_candidates("TP-ucet_default")) unique: list[Path] = [] seen: set[str] = set() for candidate in candidates: key = str(candidate) if key not in seen: unique.append(candidate) seen.add(key) return unique def _resolve_kitchen_bon_template(route: dict | None = None) -> tuple[str, str]: candidates = _kitchen_bon_template_candidates(route) for path in candidates: try: if path.exists(): return path.read_text(encoding="utf-8"), path.name except Exception as exc: logger.warning(f"Cannot load kitchen bon template {path}: {exc}") return DEFAULT_KITCHEN_BON_TEMPLATE, "internal_fallback" def _resolve_receipt_template(route: dict | None = None) -> tuple[str, str]: candidates = _receipt_template_candidates(route) for path in candidates: try: if path.exists(): return path.read_text(encoding="utf-8"), path.name except Exception as exc: logger.warning(f"Cannot load receipt template {path}: {exc}") return DEFAULT_RECEIPT_TEMPLATE, "internal_fallback" def _load_kitchen_bon_template(route: dict | None = None) -> str: return _resolve_kitchen_bon_template(route)[0] def _kitchen_bon_template_source(route: dict | None = None) -> str: return _resolve_kitchen_bon_template(route)[1] def _box_filter(value, char="*", width=40, label="") -> str: try: width = int(width) except Exception: width = 40 width = max(10, width) text = str(value or "") if label: text = f"{label} {text}".strip() return "\n".join([ str(char or "*")[:1] * width, text.center(width), str(char or "*")[:1] * width, ]) def _repeat_filter(value, count=1) -> str: try: count = int(count) except Exception: count = 1 return str(value or "") * max(0, count) def _guest_course_map(items: list, default_start: int) -> dict[str, int]: result: dict[str, int] = {} for idx, item in enumerate(items or [], start=default_start): if isinstance(item, dict): item_id = item.get("id") else: item_id = getattr(item, "id", None) if item_id: result[str(item_id)] = idx return result def _guest_course_name_map(items: list, default_start: int) -> dict[str, str]: result: dict[str, str] = {} for idx, item in enumerate(items or [], start=default_start): if isinstance(item, dict): item_id = item.get("id") item_name = item.get("name") else: item_id = getattr(item, "id", None) item_name = getattr(item, "name", None) if item_id: result[str(item_id)] = str(item_name or idx) return result def _created_at_for_print(job: data.PrintJob): raw = str(job.created_at or "").strip() for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(raw[:19], fmt) except Exception: pass return datetime.now() def _fit_print_text(value, width: int) -> str: text = str(value or "").replace("\r", " ").replace("\n", " ").strip() return text[:max(0, int(width or 0))] def _money_text(value, currency: str = "") -> str: try: amount = float(value or 0) except Exception: amount = 0.0 suffix = str(currency or "").strip() text = f"{amount:.2f}" return f"{text} {suffix}" if suffix else text def _two_col_text(left, right, width: int) -> str: width = max(10, int(width or 40)) left_s = str(left or "").replace("\r", " ").replace("\n", " ").strip() right_s = str(right or "").replace("\r", " ").replace("\n", " ").strip() if len(right_s) >= width: return right_s[-width:] left_width = max(1, width - len(right_s) - 1) return f"{left_s[:left_width]:<{left_width}} {right_s}" def _fixed_col_text(value, width: int, *, align: str = "left", precision: int | None = None) -> str: if precision is not None: try: text = f"{float(value or 0):.{precision}f}" except Exception: text = str(value or "") else: text = str(value or "") text = text.replace("\r", " ").replace("\n", " ").strip() if len(text) > width: text = text[:width] if align == "left" else text[-width:] if align == "right": return text.rjust(width) if align == "center": return text.center(width) return text.ljust(width) def _receipt_table_widths(width: int) -> tuple[int, int, int, int]: dph_w = 5 if width >= 42 else 4 qty_w = 9 total_w = 11 if width >= 42 else 10 name_w = max(10, width - qty_w - total_w - dph_w) return name_w, qty_w, total_w, dph_w def _receipt_dph_percent(rate) -> str: raw = str(rate or "").strip() try: value = float(raw) if value == -1: return "bez" pct = (value - 1) * 100 if value > 1 else value * 100 return f"{pct:g}" except Exception: return raw.replace("%", "") def _receipt_item_table_lines(ucet: data.Ucet, width: int, currency: str) -> list[str]: name_w, qty_w, total_w, dph_w = _receipt_table_widths(width) lines = [ ( _fixed_col_text("Nazov tovaru", name_w) + _fixed_col_text("Mnozstvo", qty_w, align="right") + _fixed_col_text("Celkom", total_w, align="right") + _fixed_col_text("%DPH", dph_w, align="right") ), "-" * width, ] for pol in getattr(ucet, "poloz", []) or []: delitel = _int_value(getattr(pol, "delitel", 1), 1) or 1 qty_value = _float_value(getattr(pol, "pocet", 0), 0.0) / delitel price = _float_value(getattr(pol, "cena", 0), 0.0) line_total = round(qty_value * price, 2) name = str(getattr(pol, "nazev", "") or "") dph_text = _receipt_dph_percent(getattr(pol, "dph", "")) if len(name) <= name_w: lines.append( _fixed_col_text(name, name_w) + _fixed_col_text(qty_value, qty_w, align="right", precision=3) + _fixed_col_text(line_total, total_w, align="right", precision=2) + _fixed_col_text(dph_text, dph_w, align="right") ) else: lines.append(_fit_print_text(name, width)) lines.append( _fixed_col_text(_money_text(price, currency), name_w) + _fixed_col_text(qty_value, qty_w, align="right", precision=3) + _fixed_col_text(line_total, total_w, align="right", precision=2) + _fixed_col_text(dph_text, dph_w, align="right") ) if getattr(pol, "cenhlad", "") and str(getattr(pol, "cenhlad", "")).strip() != "0": lines.append(_fit_print_text(f" Hladina: {getattr(pol, 'cenhlad', '')}", width)) for msg in getattr(pol, "zpravy", []) or []: lines.append(_fit_print_text(f" {msg}", width)) return lines def _receipt_tax_table_lines(ucet: data.Ucet, width: int, currency: str) -> list[str]: rate_w = 6 base_w = 12 tax_w = 12 turnover_w = max(8, width - rate_w - base_w - tax_w) lines = [ _fixed_col_text("Sadzba", rate_w) + _fixed_col_text("Zaklad", base_w, align="right") + _fixed_col_text("Dan", tax_w, align="right") + _fixed_col_text("Obrat", turnover_w, align="right") ] total_turnover = 0.0 for dph in getattr(ucet, "dane", []) or []: rate = _float_value(getattr(dph, "rate", 0), 0.0) base = _float_value(getattr(dph, "zaklad", 0), 0.0) tax = 0.0 if rate == -1 else (base * (rate - 1) if rate > 1 else base * rate) turnover = base + tax total_turnover += turnover rate_text = _receipt_dph_percent(getattr(dph, "rate", "")) rate_label = rate_text if rate_text == "bez" else f"{rate_text}%" lines.append( _fixed_col_text(rate_label, rate_w) + _fixed_col_text(base, base_w, align="right", precision=2) + _fixed_col_text(tax, tax_w, align="right", precision=2) + _fixed_col_text(turnover, turnover_w, align="right", precision=2) ) lines.append(_two_col_text("SPOLU", _money_text(total_turnover, currency), width)) return lines def _dph_label(rate) -> str: raw = str(rate or "").strip() try: value = float(raw) if value == -1: return "bez DPH" if value > 1: return f"{round((value - 1) * 100, 2):g}%" return f"{round(value * 100, 2):g}%" except Exception: return raw def _center_print_lines(lines: list[str], width: int) -> list[str]: return [ _fit_print_text(line, width).center(width) for line in (lines or []) if str(line or "").strip() ] def _receipt_note_fields(note: str, marker: str, fields: list[str]) -> list[tuple[str, str]]: if not note or marker not in note: return [] text = str(note).split(marker, 1)[1].strip() result = [] for idx, field in enumerate(fields): start = text.find(field) if start == -1: continue value_start = start + len(field) next_positions = [ text.find(next_field, value_start) for next_field in fields[idx + 1:] if text.find(next_field, value_start) != -1 ] value_end = min(next_positions) if next_positions else len(text) value = text[value_start:value_end].strip(" ,") if value: result.append((field, value)) return result def _receipt_object_lines(title: str, values: list[tuple[str, object]], width: int) -> list[str]: lines = [] clean_values = [ (label, str(value or "").strip()) for label, value in values if str(value or "").strip() ] if not clean_values: return lines lines.append(_fit_print_text(title, width)) for label, value in clean_values: lines.append(_two_col_text(f" {label}", value, width)) return lines def _receipt_uver_lines(uver, width: int) -> list[str]: if not uver: return [] return _receipt_object_lines( "Uverovy zaznam", [ ("Firma", getattr(uver, "hjmeno", "")), ("Akcia", getattr(uver, "akcia", "")), ("Adresa", getattr(uver, "adresa1", "")), ("", getattr(uver, "adresa2", "")), ("", getattr(uver, "adresa3", "")), ("ICO", getattr(uver, "ico", "")), ("DIC", getattr(uver, "dic", "")), ("IC DPH", getattr(uver, "icdph", "")), ("Schvalil", getattr(uver, "schvalil", "")), ], width, ) def _receipt_hotel_lines(target, width: int) -> list[str]: if not target: return [] if str(getattr(target, "target_type", "") or "") == "group": values = [ ("Recepcia", getattr(target, "reception_name", "")), ("Skupina", getattr(target, "group_name", "")), ("Ucet", getattr(target, "account_id", "")), ] else: values = [ ("Recepcia", getattr(target, "reception_name", "")), ("Izba", getattr(target, "room_code", "") or getattr(target, "room_id", "")), ("Host", getattr(target, "guest_name", "")), ("Ucet", getattr(target, "account_id", "")), ] return _receipt_object_lines("Hotelovy ucet", values, width) def _receipt_note_extra_lines(note: str, width: int) -> list[str]: lines = [] st_fields = _receipt_note_fields( note, "dotaz_st:", ["akcia", "hjmeno", "adresa1", "adresa2", "adresa3", "ico", "dic", "icdph", "schvalil"], ) if st_fields: labels = { "akcia": "Akcia", "hjmeno": "Firma", "adresa1": "Adresa", "adresa2": "", "adresa3": "", "ico": "ICO", "dic": "DIC", "icdph": "IC DPH", "schvalil": "Schvalil", } lines.extend(_receipt_object_lines("Uverovy zaznam", [(labels.get(k, k), v) for k, v in st_fields], width)) ho_fields = _receipt_note_fields( note, "dotaz_ho:", ["izba", "host", "skupina", "recepcia"], ) if ho_fields: labels = { "izba": "Izba", "host": "Host", "skupina": "Skupina", "recepcia": "Recepcia", } lines.extend(_receipt_object_lines("Hotelovy ucet", [(labels.get(k, k), v) for k, v in ho_fields], width)) return lines def _receipt_title(kind: str, title: str = "") -> str: if str(title or "").strip(): return str(title).strip() kind_l = str(kind or "").strip().lower() if "storno" in kind_l: return "STORNO UCTU" if "copy" in kind_l or "kop" in kind_l: return "KOPIA UCTU" if "prebill" in kind_l or "preducet" in kind_l: return "PREDUCET" if "payment_change" in kind_l or "zmena" in kind_l: return "ZMENA PLATBY" return "UCET" def _build_receipt_template_context(job: data.PrintJob, route: dict | None = None) -> dict: payload = job.payload or {} route = route or payload.get("route") or {} ucet = data.Ucet.model_validate(payload.get("ucet") or {}) kind = str(payload.get("kind") or job.job_type or "").lower() width = _printer_width(route) separator = "-" * width currency = "" for pol in getattr(ucet, "poloz", []) or []: if getattr(pol, "mena", ""): currency = str(pol.mena) break if not currency: for payment in getattr(ucet, "platby", []) or []: if getattr(payment, "unit", ""): currency = str(payment.unit) break total = float(getattr(ucet, "total_base_currency", 0) or 0) if not total: total = float(ucet.total_czk()) discount = float(ucet.total_slev() or 0) title = _receipt_title(kind, str(payload.get("title") or "")) header_lines = _center_print_lines([str(line) for line in (payload.get("headers") or [])], width) header_lines.extend([ _fit_print_text(title, width).center(width), ]) if getattr(ucet, "ucislo", None): header_lines.append(_two_col_text("Ucet", ucet.ucislo, width)) if getattr(ucet, "stul", None): table_text = str(payload.get("table_name") or getattr(ucet, "stul", "") or "") header_lines.append(_two_col_text("Stol", table_text, width)) if payload.get("pos_name"): header_lines.append(_two_col_text("Kasa", payload.get("pos_name"), width)) if getattr(ucet, "autor", ""): header_lines.append(_two_col_text("Obsluha", getattr(ucet, "autor", ""), width)) closed_at = str(getattr(ucet, "closed_at", "") or getattr(ucet, "datetime", "") or job.created_at or "") if closed_at: header_lines.append(_two_col_text("Datum", closed_at, width)) item_lines = _receipt_item_table_lines(ucet, width, currency) summary_lines = [] if discount: subtotal = total + discount summary_lines.append(_two_col_text("Medzisucet", _money_text(subtotal, currency), width)) if discount > 0: summary_lines.append(_two_col_text("Zlava", _money_text(-discount, currency), width)) else: summary_lines.append(_two_col_text("Prirazka", _money_text(abs(discount), currency), width)) summary_lines.append(_two_col_text("Spolu", _money_text(total, currency), width)) payment_lines = [] tip_total = 0.0 payments = list(getattr(ucet, "platby", []) or []) uver_printed = False for idx, payment in enumerate(payments): unit = str(getattr(payment, "unit", "") or currency) pay_name = getattr(payment, "nazev", "") or getattr(payment, "code", "") if unit != currency and _float_value(getattr(payment, "rate", 1), 1.0) != 1: left = f"{pay_name} {_money_text(getattr(payment, 'suma', 0), unit)}" right = _money_text(getattr(payment, "suma_czk", 0), currency) else: left = pay_name right = _money_text(getattr(payment, "suma", 0), unit) payment_lines.append(_two_col_text(left, right, width)) tip = _float_value(getattr(payment, "tip", 0), 0.0) if tip: tip_total += tip payment_lines.append(_two_col_text(" TIP", _money_text(tip, unit), width)) note = str(getattr(payment, "poznamka", "") or "").strip() target = getattr(payment, "hotel_charge", None) if not target and len(payments) == 1: target = getattr(ucet, "hotel_charge", None) if target: payment_lines.extend(_receipt_hotel_lines(target, width)) elif note and "dotaz_ho:" in note: payment_lines.extend(_receipt_note_extra_lines(note, width)) if not uver_printed and getattr(ucet, "uver", None): payment_lines.extend(_receipt_uver_lines(getattr(ucet, "uver", None), width)) uver_printed = True elif not uver_printed and note and "dotaz_st:" in note: payment_lines.extend(_receipt_note_extra_lines(note, width)) uver_printed = True if note and "dotaz_st:" not in note and "dotaz_ho:" not in note: payment_lines.append(_fit_print_text(f" {note}", width)) rounding_total = round(_float_value(getattr(ucet, "round50", 0), 0.0), 2) if rounding_total: payment_lines.append(_two_col_text("Zaokruhlenie", _money_text(rounding_total, currency), width)) if tip_total and not payment_lines: payment_lines.append(_two_col_text("TIP", _money_text(tip_total, currency), width)) tax_lines = _receipt_tax_table_lines(ucet, width, currency) if getattr(ucet, "dane", None) else [] return { "printer": SimpleNamespace( max_characters=width, reset=_decode_printer_command(route.get("p_reset")), crlf=_decode_printer_command(route.get("p_crlf")) or "\n", fullcut=_decode_printer_command(route.get("p_fullcut")), ), "kind": kind, "title": title, "separator": separator, "header_lines": header_lines, "item_lines": item_lines, "summary_lines": summary_lines, "payment_lines": payment_lines, "tax_lines": tax_lines, "footer_lines": _center_print_lines([str(line) for line in (payload.get("footers") or [])], width), "ucet": ucet, "route": route, "currency": currency, } def _kitchen_storno_flags(kind: str, ucet: data.Ucet) -> tuple[bool, bool]: kind_l = str(kind or "").strip().lower() is_storno = "storno" in kind_l or bool(getattr(ucet, "is_storno", None)) is_bill_cancel = ( "ucet" in kind_l or "bill" in kind_l or str(getattr(ucet, "origin", "") or "").lower() in {"storno", "storno_ucet"} ) return is_storno, is_bill_cancel def _build_kitchen_template_context(job: data.PrintJob, route: dict | None = None) -> dict: payload = job.payload or {} route = route or payload.get("route") or {} ucet = data.Ucet.model_validate(payload.get("ucet") or {}) kind = str(payload.get("kind") or job.job_type or "").lower() is_storno, is_bill_cancel = _kitchen_storno_flags(kind, ucet) width = _printer_width(route) guest_map = _guest_course_map(getattr(ucet, "guests", []) or [], 1) course_map = _guest_course_map(getattr(ucet, "courses", []) or [], 1) guest_name_map = _guest_course_name_map(getattr(ucet, "guests", []) or [], 1) course_name_map = _guest_course_name_map(getattr(ucet, "courses", []) or [], 1) courses_count = max(_int_value(getattr(ucet, "course_count", 1), 1), len(course_map) or 1) guests_count = max(_int_value(getattr(ucet, "guest_count", 1), 1), len(guest_map) or 1) items = [] last_course_key = None last_guest_key = None for pol in ucet.poloz: qty = _format_print_quantity(pol, absolute=is_storno) name = str(getattr(pol, "nazev", "") or "") order_lines = [str(line) for line in (getattr(pol, "zpravy", []) or []) if str(line).strip()] course_id = str(getattr(pol, "course_id", "") or "") guest_id = str(getattr(pol, "guest_id", "") or "") course_no = course_map.get(course_id, 0) guest_no = guest_map.get(guest_id, 1) course_label = course_name_map.get(course_id, str(course_no if course_no else "")) guest_label = guest_name_map.get(guest_id, str(guest_no if guest_no else "")) show_course = bool(course_label) and (courses_count > 1 or course_no > 0) show_guest = bool(guest_label) and (guests_count > 1 or guest_no > 1) print_course_header = bool(show_course and course_id != last_course_key) if print_course_header: last_course_key = course_id last_guest_key = None print_guest_header = bool(show_guest and guest_id != last_guest_key) if print_guest_header: last_guest_key = guest_id items.append(SimpleNamespace( description=f"{qty:>5} {name}", order_text="\n".join(order_lines), order_lines=order_lines, course=course_no, course_name=course_label, guest=guest_no, guest_name=guest_label, print_course_header=print_course_header, print_guest_header=print_guest_header, )) return { "printer": SimpleNamespace( max_characters=width, reset=_decode_printer_command(route.get("p_reset")), bigfont_on=_decode_printer_command(route.get("p_wideon")), bigfont_off=_decode_printer_command(route.get("p_wideoff")), crlf=_decode_printer_command(route.get("p_crlf")) or "\n", fullcut=_decode_printer_command(route.get("p_fullcut")), ), "pager": str(payload.get("pager_no") or ""), "order_note": "", "table_name": str(payload.get("table_name") or getattr(ucet, "stul", "") or ""), "cashier_name": str(payload.get("pos_name") or getattr(ucet, "id_kas", "") or ""), "created_at": _created_at_for_print(job), "printer_name": str(route.get("prn_name") or ""), "is_storno": is_storno, "is_bill_cancel": is_bill_cancel, "items": items, "courses_count": courses_count, "guests_count": guests_count, "user": str(getattr(ucet, "autor", "") or ""), "locator_number": "", "bon_count": _int_value(payload.get("bon_no"), 0), } def _render_kitchen_print_jinja(job: data.PrintJob, route: dict | None = None) -> tuple[str, str] | None: try: from jinja2 import Environment except Exception as exc: logger.warning(f"Jinja2 is not available, kitchen print will use internal fallback: {exc}") return None env = Environment(autoescape=False, trim_blocks=True, lstrip_blocks=True) env.filters["box"] = _box_filter env.filters["repeat"] = _repeat_filter template_text, template_source = _resolve_kitchen_bon_template(route) template = env.from_string(template_text) rendered = template.render(**_build_kitchen_template_context(job, route=route)).strip("\n") + "\n" return rendered, template_source def _render_kitchen_print_text(job: data.PrintJob, width: int = 40, route: dict | None = None) -> str: text, _ = _render_kitchen_print_text_with_meta(job, width=width, route=route) return text def _render_kitchen_print_text_with_meta( job: data.PrintJob, width: int = 40, route: dict | None = None, ) -> tuple[str, dict]: payload = job.payload or {} rendered = _render_kitchen_print_jinja(job, route=route) if rendered is not None: rendered_text, template_source = rendered return rendered_text, { "renderer": "jinja2", "template_bon": template_source, } ucet = data.Ucet.model_validate(payload.get("ucet") or {}) kind = str(payload.get("kind") or job.job_type or "").lower() is_storno, is_bill_cancel = _kitchen_storno_flags(kind, ucet) width = _printer_width(route or payload.get("route") or {}, width) title = "STORNO STAREHO UCTU" if is_bill_cancel else ("STORNO - KUCHYNA" if is_storno else "KUCHYNA") lines = [ "=" * width, title.center(width), ] bon_no = _int_value(payload.get("bon_no"), 0) bon_date = str(payload.get("bon_date") or "") if bon_no: bon_label = f"BON: {bon_no}" if bon_date: bon_label = f"{bon_label} / {bon_date}" lines.append(bon_label[:width]) lines.append("-" * width) if getattr(ucet, "stul", None): lines.append(f"STOL: {ucet.stul}"[:width]) if payload.get("room_name"): lines.append(f"MIESTNOST: {payload.get('room_name')}"[:width]) if payload.get("pos_name"): lines.append(f"KASA: {payload.get('pos_name')}"[:width]) if getattr(ucet, "autor", ""): lines.append(f"CISNIK: {ucet.autor}"[:width]) if getattr(ucet, "ucislo", None): lines.append(f"UCET: {ucet.ucislo}"[:width]) lines.append("-" * width) for pol in ucet.poloz: qty = _format_print_quantity(pol, absolute=is_storno) name = getattr(pol, "nazev", "") or "" line = f"{qty:>5} {name}" lines.append(line[:width]) if getattr(pol, "chod", ""): lines.append(f" [CHOD {pol.chod}]"[:width]) for msg in getattr(pol, "zpravy", []) or []: lines.append(f" - {msg}"[:width]) lines.append("-" * width) lines.append(datetime.now().strftime("%d.%m.%Y %H:%M:%S").center(width)) lines.append("=" * width) return "\n".join(lines) + "\n", { "renderer": "internal_fallback", "template_bon": "internal_fallback", } def _render_receipt_print_jinja(job: data.PrintJob, route: dict | None = None) -> tuple[str, str] | None: try: from jinja2 import Environment except Exception as exc: logger.warning(f"Jinja2 is not available, receipt print will use internal fallback: {exc}") return None env = Environment(autoescape=False, trim_blocks=True, lstrip_blocks=True) env.filters["repeat"] = _repeat_filter env.filters["money"] = _money_text template_text, template_source = _resolve_receipt_template(route) template = env.from_string(template_text) rendered = template.render(**_build_receipt_template_context(job, route=route)).strip("\n") + "\n" return rendered, template_source def _render_receipt_print_text_with_meta( job: data.PrintJob, width: int = 40, route: dict | None = None, ) -> tuple[str, dict]: payload = job.payload or {} rendered = _render_receipt_print_jinja(job, route=route) if rendered is not None: rendered_text, template_source = rendered return rendered_text, { "renderer": "jinja2", "template_ucet": template_source, } context = _build_receipt_template_context(job, route=route or payload.get("route") or {}) lines = [] lines.extend(context["header_lines"]) lines.append(context["separator"]) lines.extend(context["item_lines"]) lines.append(context["separator"]) lines.extend(context["summary_lines"]) if context["payment_lines"]: lines.append(context["separator"]) lines.extend(context["payment_lines"]) if context["tax_lines"]: lines.append(context["separator"]) lines.extend(context["tax_lines"]) if context["footer_lines"]: lines.append(context["separator"]) lines.extend(context["footer_lines"]) return "\n".join(lines) + "\n", { "renderer": "internal_fallback", "template_ucet": "internal_fallback", } def render_print_job_text(job: data.PrintJob, width: int = 40, route: dict | None = None) -> str: text, _ = render_print_job_text_with_meta(job, width=width, route=route) return text def render_print_job_text_with_meta( job: data.PrintJob, width: int = 40, route: dict | None = None, ) -> tuple[str, dict]: payload = job.payload or {} if isinstance(payload.get("text"), str): text = payload.get("text") or "" return (text if text.endswith("\n") else text + "\n"), { "renderer": "payload_text", } if str(job.document_type or "").startswith("receipt_"): return _render_receipt_print_text_with_meta(job, width=width, route=route) if str(job.document_type or "").startswith("kitchen_") or "ucet" in payload: return _render_kitchen_print_text_with_meta(job, width=width, route=route) raise RuntimeError(f"Print job {job.id} has no printable payload") def _print_charset(route: dict) -> str: charset = str(route.get("convert_charset") or "").strip() if not charset or charset.lower() == "auto": return "utf-8" return charset def _render_escpos_bytes(text: str, route: dict) -> bytes: body = text.encode(_print_charset(route), errors="replace") has_control = any(ch in text for ch in ("\x1b", "\x1d", "\x1c", "\x10", "\x14")) if has_control: return body return b"\x1b@" + body + b"\n\x1dV\x00" def _fiskal_base_url(route: dict) -> str: host = _require_route_value(route, "ip") if host.startswith("http://") or host.startswith("https://"): base = host.rstrip("/") if route.get("port") and ":" not in base.rsplit("/", 1)[-1]: base = f"{base}:{_route_int(route, 'port', 80)}" return base return f"http://{host}:{_route_int(route, 'port', 80)}" def _fiscal_probe_get(base_url: str, endpoint: str, timeout: float) -> dict: url = f"{base_url}{endpoint}" response = requests.get(url, timeout=timeout, verify=False) response.raise_for_status() try: loaded = response.json() except Exception: loaded = {"raw": response.text} return loaded if isinstance(loaded, dict) else {"return": loaded} def _fiscal_probe_message(details: dict, error: str = "") -> tuple[str, str, bool]: if error: return "error", error, False fiskal_type = details.get("fiskal_type") or {} failed_bills = details.get("failed_bills") or {} warnings = [] if details.get("failed_bills_error"): warnings.append(f"failed_bills: {details.get('failed_bills_error')}") for name, payload in (("fiskal_type", fiskal_type), ("failed_bills", failed_bills)): code = payload.get("code") if isinstance(payload, dict) else None if code not in (None, 0, "0"): warnings.append(f"{name}: {payload.get('code_text') or payload.get('message') or code}") failed_return = failed_bills.get("return") if isinstance(failed_bills, dict) else None if failed_return: warnings.append(f"Neodoslané doklady: {failed_return}") if warnings: return "warning", "; ".join(str(item) for item in warnings), True version = "" ret = fiskal_type.get("return") if isinstance(fiskal_type, dict) else None if isinstance(ret, dict): version = str(ret.get("version") or ret.get("verzia") or ret.get("fiskal_type") or "") elif ret not in (None, ""): version = str(ret) message = f"Fiskálny server odpovedal{(': ' + version) if version else ''}." return "online", message, True @app.get("/print/fiscal/status/", response_model=data.PrinterStatusOut) def get_fiscal_printer_status( id_kas: str, printer_no: str, timeout: float = Query(default=10.0, ge=1.0, le=60.0), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return get_fiscal_printer_status_db(prefix, id_kas, printer_no, timeout=timeout) def get_fiscal_printer_status_db( prefix: str, id_kas: str, printer_no: str, timeout: float = 10.0, ) -> data.PrinterStatusOut: route = load_current_printer_route(prefix, printer_no) if not route: raise HTTPException(404, f"Tlačiareň {printer_no} nebola nájdená.") if str(route.get("route_type") or "").lower() != "fiskal": raise HTTPException(422, f"Tlačiareň {printer_no} nie je fiskálna.") details: dict = { "route": { "ip": route.get("ip", ""), "port": route.get("port", ""), "prn_no": route.get("prn_no", ""), "prn_name": route.get("prn_name", ""), } } error = "" try: base_url = _fiskal_base_url(route) details["fiskal_type"] = _fiscal_probe_get(base_url, "/params/fiskal_type", timeout) try: details["failed_bills"] = _fiscal_probe_get(base_url, "/params/failed_bills", timeout) except Exception as exc: details["failed_bills_error"] = str(exc) except Exception as exc: error = str(exc) logger.exception("Fiscal printer status failed: prefix=%s id_kas=%s printer=%s", prefix, id_kas, printer_no) status_text, message, online = _fiscal_probe_message(details, error=error) return upsert_printer_status_db( prefix, data.PrinterStatusIn( id_kas=id_kas, prn_no=str(printer_no or "").strip(), agent_id="server", online=online, status=status_text, printer_type=str(route.get("printer_type") or "fiskal"), cmd32_on=str(route.get("cmd32_on") or "FISKAL"), message=message, details=details, ), ) def _text_to_fiskal_paragon(text: str) -> dict: return {100 + idx + 1: line for idx, line in enumerate(text.splitlines())} def _send_fiskal_print_job(job: data.PrintJob, route: dict, text: str, timeout: float) -> dict: payload = { "client_id": "", "platby": None, "text": _text_to_fiskal_paragon(text), "typ_poloziek": "TEXT", "ucet": None, } response = requests.post( f"{_fiskal_base_url(route)}/paragon/send", json=payload, timeout=timeout, verify=False, ) response.raise_for_status() try: response_data = response.json() except Exception: response_data = {"raw": response.text} if isinstance(response_data, dict) and response_data.get("code", 0) not in (0, "0", None): raise RuntimeError(str(response_data.get("code_text") or response_data)) return { "route_type": "fiskal", "response": response_data, } def _send_raw_print_job(job: data.PrintJob, route: dict, text: str, timeout: float) -> dict: host = _require_route_value(route, "ip") port = _route_int(route, "port", 9100) content = _render_escpos_bytes(text, route) with socket.create_connection((host, port), timeout=timeout) as sock: sock.sendall(content) return { "route_type": "raw", "bytes": len(content), } def _ipp_attr(value_tag: int, name: str, value: str) -> bytes: name_b = name.encode("utf-8") value_b = value.encode("utf-8") return ( bytes([value_tag]) + len(name_b).to_bytes(2, "big") + name_b + len(value_b).to_bytes(2, "big") + value_b ) def _send_cups_print_job(job: data.PrintJob, route: dict, text: str, timeout: float) -> dict: host = _require_route_value(route, "ip") port = _route_int(route, "port", 631) queue = _require_route_value(route, "cupsname") if port in {9100, 9101}: raise RuntimeError( "Tlaciaren je nastavena ako CUPS, ale ma port " f"{port}. Port 9100/9101 je RAW port tlaciarne. " "Pre RAW tlac nech cmd32_on nie je CUPS, alebo pri CUPS nastav IP/port CUPS servera " "(typicky port 631) a platny cupsname." ) printer_uri = f"ipp://{host}:{port}/printers/{queue}" document = _render_escpos_bytes(text, route) request_id = max(1, int(job.id or 1)) ipp_body = ( b"\x02\x00" + (0x0002).to_bytes(2, "big") + request_id.to_bytes(4, "big", signed=False) + b"\x01" + _ipp_attr(0x47, "attributes-charset", "utf-8") + _ipp_attr(0x48, "attributes-natural-language", "sk") + _ipp_attr(0x45, "printer-uri", printer_uri) + _ipp_attr(0x42, "requesting-user-name", "pokladna") + _ipp_attr(0x42, "job-name", f"pokladna-{job.id}") + _ipp_attr(0x49, "document-format", "application/octet-stream") + b"\x03" + document ) response = requests.post( f"http://{host}:{port}/printers/{queue}", data=ipp_body, headers={"Content-Type": "application/ipp"}, timeout=timeout, ) response.raise_for_status() ipp_status = None if len(response.content) >= 4: ipp_status = int.from_bytes(response.content[2:4], "big") if ipp_status >= 0x0300: raise RuntimeError(f"CUPS IPP error 0x{ipp_status:04x}") return { "route_type": "cups", "bytes": len(document), "ipp_status": ipp_status, } def process_print_job( job: data.PrintJob, timeout: float = 10.0, current_route: dict | None = None, ) -> dict: payload = job.payload or {} route = current_route or payload.get("route") or {} if not isinstance(route, dict) or not route: raise RuntimeError(f"Print job {job.id} has no printer route") text, render_meta = render_print_job_text_with_meta(job, route=route) route_type = str(route.get("route_type") or "raw").strip().lower() if route_type == "fiskal": result = _send_fiskal_print_job(job, route, text, timeout) elif route_type == "cups": result = _send_cups_print_job(job, route, text, timeout) else: result = _send_raw_print_job(job, route, text, timeout) result.update(render_meta) result["route"] = route result["route_source"] = "current_prndef" if current_route else "job_payload" result["charset"] = _print_charset(route) return result def ensure_kitchen_print_job_bon_number(prefix: str, job: data.PrintJob) -> data.PrintJob: payload = dict(job.payload or {}) if not str(job.document_type or "").startswith("kitchen_"): return job changed = False if not _int_value(payload.get("bon_no"), 0) and str(job.printer_no or "").strip(): bon_no, bon_date = next_print_bon_number_db(prefix, job.printer_no) payload["bon_no"] = bon_no payload["bon_date"] = bon_date logger.info( "Assigned missing bon number to existing print job: job=%s printer=%s bon=%s date=%s", job.id, job.printer_no, bon_no, bon_date, ) changed = True if "pager_no" not in payload: payload["pager_no"] = "" changed = True if not str(payload.get("table_name") or "").strip(): try: ucet = data.Ucet.model_validate(payload.get("ucet") or {}) table_name = resolve_table_name_from_map(prefix, job.id_kas, getattr(ucet, "stul", "")) if table_name: payload["table_name"] = table_name changed = True except Exception: pass if not changed: return job return update_print_job_payload_db(prefix, job.id, payload) @app.post("/print/jobs/process-local/", response_model=list[data.PrintJob]) def process_local_print_jobs( req: data.PrintJobClaimRequest, timeout: float = Query(default=10.0, ge=1.0, le=120.0), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return process_local_print_jobs_db(prefix, req, timeout=timeout) def process_local_print_jobs_db( prefix: str, req: data.PrintJobClaimRequest, timeout: float = 10.0, ) -> list[data.PrintJob]: claimed = claim_print_jobs_db(prefix, req) processed: list[data.PrintJob] = [] for job in claimed: try: update_print_job_status_db( prefix, job.id, data.PrintJobStatusUpdate(status="printing"), ) job = ensure_kitchen_print_job_bon_number(prefix, job) current_route = load_current_printer_route(prefix, job.printer_no) if not current_route: logger.warning( "Print job %s printer %s is not in current prndef; using job payload route", job.id, job.printer_no, ) result = process_print_job( job, timeout=timeout, current_route=current_route or None, ) processed.append( update_print_job_status_db( prefix, job.id, data.PrintJobStatusUpdate(status="printed", result=result), ) ) except Exception as exc: message = f"Print job {job.id} failed: {exc}" if _env_truthy("POKLADNA_PRINT_DEBUG", default=False): logger.exception(message) else: logger.warning(message) next_status = "failed_final" if job.attempts >= job.max_attempts else "retry_pending" processed.append( update_print_job_status_db( prefix, job.id, data.PrintJobStatusUpdate(status=next_status, error=str(exc)), ) ) return processed def _env_truthy(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "ano"} def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) except Exception: return default def _env_int(name: str, default: int) -> int: try: return int(os.getenv(name, str(default))) except Exception: return default def _env_csv(name: str) -> list[str]: raw = os.getenv(name, "") return [item.strip() for item in raw.split(",") if item.strip()] def _print_worker_prefixes() -> list[str]: configured = _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES") if configured: return configured with get_db() as conn: cur = conn.cursor() cur.execute("SELECT id FROM zakazky WHERE heslo IS NOT NULL AND heslo <> '' ORDER BY id") return [f"{int(row[0]):05d}" for row in cur.fetchall()] def _active_print_job_cashiers(prefix: str, printers: list[str] | None = None) -> list[str]: table = f"{prefix}_print_jobs" placeholders = ",".join("?" for _ in PRINT_JOB_ACTIVE_STATUSES) params: list = [*PRINT_JOB_ACTIVE_STATUSES] sql = f""" SELECT DISTINCT id_kas FROM "{table}" WHERE status IN ({placeholders}) """ if printers: prn_placeholders = ",".join("?" for _ in printers) sql += f" AND (printer_no='' OR printer_no IN ({prn_placeholders}))" params.extend(printers) sql += " ORDER BY id_kas" try: with get_db() as conn: cur = conn.cursor() cur.execute(sql, tuple(params)) return [row[0] for row in cur.fetchall() if row[0]] except sqlite3.OperationalError as exc: logger.warning(f"Print worker cannot read queue for prefix {prefix}: {exc}") return [] def process_local_print_worker_cycle( *, prefixes: list[str] | None = None, printers: list[str] | None = None, agent_id: str = LOCAL_PRINT_AGENT_ID, limit: int = 10, timeout: float = 10.0, ) -> list[data.PrintJob]: processed: list[data.PrintJob] = [] active_prefixes = prefixes if prefixes is not None else _print_worker_prefixes() for prefix in active_prefixes: batch = process_local_print_jobs_db( prefix, data.PrintJobClaimRequest( agent_id=agent_id, printers=printers or [], limit=limit, ), timeout=timeout, ) processed.extend(batch) return processed def _local_print_worker_loop() -> None: interval = max(0.2, _env_float("POKLADNA_LOCAL_PRINT_INTERVAL", 2.0)) limit = max(1, min(_env_int("POKLADNA_LOCAL_PRINT_LIMIT", 10), 100)) timeout = max(1.0, _env_float("POKLADNA_LOCAL_PRINT_TIMEOUT", 10.0)) printers = _env_csv("POKLADNA_LOCAL_PRINT_PRINTERS") prefixes = _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES") or None agent_id = os.getenv("POKLADNA_LOCAL_PRINT_AGENT_ID", LOCAL_PRINT_AGENT_ID) logger.info( "Local print worker started: agent=%s interval=%s limit=%s timeout=%s printers=%s prefixes=%s", agent_id, interval, limit, timeout, printers or "*", prefixes or "*", ) _local_print_worker_state.update({ "started_at": datetime.now().isoformat(sep=" ", timespec="seconds"), "stopped_at": "", "last_error": "", "printers": printers, "prefixes": prefixes or [], }) while not _local_print_worker_stop.is_set(): try: processed = process_local_print_worker_cycle( prefixes=prefixes, printers=printers, agent_id=agent_id, limit=limit, timeout=timeout, ) _local_print_worker_state.update({ "last_cycle_at": datetime.now().isoformat(sep=" ", timespec="seconds"), "last_processed_count": len(processed), "last_processed_ids": [job.id for job in processed], "last_error": "", }) if processed: logger.info("Local print worker processed %s job(s)", len(processed)) except Exception as exc: _local_print_worker_state.update({ "last_cycle_at": datetime.now().isoformat(sep=" ", timespec="seconds"), "last_error": str(exc), }) logger.exception("Local print worker cycle failed") _local_print_worker_stop.wait(interval) _local_print_worker_state.update({ "stopped_at": datetime.now().isoformat(sep=" ", timespec="seconds"), }) logger.info("Local print worker stopped") def start_local_print_worker_if_enabled() -> None: global _local_print_worker_thread if not _env_truthy(LOCAL_PRINT_WORKER_ENV, default=False): logger.info( "Local print worker disabled. Set %s=1 to process print jobs on this server.", LOCAL_PRINT_WORKER_ENV, ) return if _local_print_worker_thread and _local_print_worker_thread.is_alive(): return _local_print_worker_stop.clear() _local_print_worker_thread = threading.Thread( target=_local_print_worker_loop, name="pokladna-local-print-worker", daemon=True, ) _local_print_worker_thread.start() def stop_local_print_worker() -> None: if _local_print_worker_thread and _local_print_worker_thread.is_alive(): _local_print_worker_stop.set() _local_print_worker_thread.join(timeout=5) def print_worker_diagnostics_db( prefix: str, id_kas: str | None = None, limit: int = 50, ) -> dict: table = f"{prefix}_print_jobs" where = [] params: list = [] if id_kas: where.append("id_kas=?") params.append(id_kas) where_sql = f"WHERE {' AND '.join(where)}" if where else "" status_counts: dict[str, int] = {} active_jobs: list[dict] = [] try: with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT status, COUNT(*) FROM "{table}" {where_sql} GROUP BY status ORDER BY status """, tuple(params), ) status_counts = {row[0] or "": int(row[1]) for row in cur.fetchall()} current_prndef_map = _load_prndef_map_cur(cur, prefix) visible_statuses = tuple(dict.fromkeys(( *PRINT_JOB_ACTIVE_STATUSES, "claimed", "printing", "failed", "failed_final", ))) active_placeholders = ",".join("?" for _ in visible_statuses) active_params = [*params, *visible_statuses, max(1, min(limit, 200))] active_where = [*where, f"status IN ({active_placeholders})"] cur.execute( f""" SELECT {_print_job_select_columns()} FROM "{table}" WHERE {" AND ".join(active_where)} ORDER BY priority, id LIMIT ? """, tuple(active_params), ) for row in cur.fetchall(): job = _print_job_from_row(row) route = (job.payload or {}).get("route") or {} current_route = _printer_route(current_prndef_map.get(str(job.printer_no or ""))) active_jobs.append({ "id": job.id, "id_kas": job.id_kas, "printer_no": job.printer_no, "status": job.status, "priority": job.priority, "attempts": job.attempts, "max_attempts": job.max_attempts, "agent_id": job.agent_id or "", "job_type": job.job_type, "document_type": job.document_type, "receipt_no": job.receipt_no, "bon_no": _int_value((job.payload or {}).get("bon_no"), 0), "bon_date": str((job.payload or {}).get("bon_date") or ""), "created_at": job.created_at, "updated_at": job.updated_at, "error": job.error, "route": { "route_type": route.get("route_type", ""), "prn_no": route.get("prn_no", ""), "prn_name": route.get("prn_name", ""), "cmd32_on": route.get("cmd32_on", ""), "ip": route.get("ip", ""), "port": route.get("port", ""), "cupsname": route.get("cupsname", ""), }, "current_route": { "route_type": current_route.get("route_type", ""), "prn_no": current_route.get("prn_no", ""), "prn_name": current_route.get("prn_name", ""), "cmd32_on": current_route.get("cmd32_on", ""), "ip": current_route.get("ip", ""), "port": current_route.get("port", ""), "cupsname": current_route.get("cupsname", ""), }, "route_will_refresh_from_prndef": bool(current_route), }) except sqlite3.OperationalError as exc: return { "ok": False, "error": str(exc), "hint": f"Tabulka {table} pravdepodobne este neexistuje alebo nie je inicializovana.", } enabled = _env_truthy(LOCAL_PRINT_WORKER_ENV, default=False) running = bool(_local_print_worker_thread and _local_print_worker_thread.is_alive()) configured_printers = _env_csv("POKLADNA_LOCAL_PRINT_PRINTERS") outside_filter = [] if configured_printers: allowed = set(configured_printers) outside_filter = [ job for job in active_jobs if job["printer_no"] and job["printer_no"] not in allowed ] cups_on_raw_port = [] for job in active_jobs: effective_route = ( job.get("current_route") if job.get("route_will_refresh_from_prndef") else job.get("route") ) or {} if ( str(effective_route.get("route_type") or "").lower() == "cups" and str(effective_route.get("port") or "").strip() in {"9100", "9101"} ): cups_on_raw_port.append(job) hints = [] if status_counts.get("queued", 0) or status_counts.get("retry_pending", 0): if not running: hints.append("Lokalnu tlac ma obsluhovat samostatny proces local_print_agent.py na pocitaci/tablete v sieti zakaznika.") if outside_filter: hints.append("Niektore cakajuce joby maju printer_no mimo filtra tlaciarni lokalneho agenta.") if running and not _local_print_worker_state.get("last_cycle_at"): hints.append("Worker bezi, ale este nema zaznamenany ziaden cyklus.") if cups_on_raw_port: hints.append("Niektore joby maju route_type=CUPS, ale port 9100/9101. To je RAW port; pre CUPS pouzi typicky port 631, alebo zmen cmd32_on na RAW sposob tlace.") return { "ok": True, "prefix": prefix, "id_kas": id_kas or "", "worker": { "enabled": enabled, "running": running, "state": dict(_local_print_worker_state), "configured_printers": configured_printers, "configured_prefixes": _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES"), }, "status_counts": status_counts, "active_jobs": active_jobs, "unprinted_jobs": active_jobs, "jobs_outside_printer_filter": outside_filter, "cups_jobs_on_raw_port": cups_on_raw_port, "hints": hints, } @app.get("/print/worker/status/") def local_print_worker_status( auth: tuple[str, str, str] = Depends(auth_ctx), ): return { "enabled": _env_truthy(LOCAL_PRINT_WORKER_ENV, default=False), "running": bool(_local_print_worker_thread and _local_print_worker_thread.is_alive()), "agent_id": os.getenv("POKLADNA_LOCAL_PRINT_AGENT_ID", LOCAL_PRINT_AGENT_ID), "interval": _env_float("POKLADNA_LOCAL_PRINT_INTERVAL", 2.0), "limit": _env_int("POKLADNA_LOCAL_PRINT_LIMIT", 10), "timeout": _env_float("POKLADNA_LOCAL_PRINT_TIMEOUT", 10.0), "printers": _env_csv("POKLADNA_LOCAL_PRINT_PRINTERS"), "prefixes": _env_csv("POKLADNA_LOCAL_PRINT_PREFIXES"), "state": dict(_local_print_worker_state), } @app.get("/print/worker/diagnostics/") def local_print_worker_diagnostics( id_kas: str | None = Query(default=None), limit: int = Query(default=50, ge=1, le=200), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return print_worker_diagnostics_db(prefix, id_kas=id_kas, limit=limit) def _printer_status_from_values( id_kas: str, prn_no: str, prn_name: str, id_term: str, prn_data: dict, status_row, queue_size: int, failed_jobs: int, ) -> data.PrinterStatusOut: printer_type = str(prn_data.get("printer_type") or "") cmd32_on = str(prn_data.get("cmd32_on") or "") if status_row: return data.PrinterStatusOut( id_kas=id_kas, prn_no=prn_no, prn_name=prn_name, id_term=id_term, agent_id=status_row[0] or "", online=bool(status_row[1]), status=status_row[2] or "unknown", printer_type=status_row[3] or printer_type, cmd32_on=status_row[4] or cmd32_on, message=status_row[5] or "", queue_size=queue_size, failed_jobs=failed_jobs, details=_json_obj(status_row[8]), checked_at=status_row[6] or "", updated_at=status_row[7] or "", ) return data.PrinterStatusOut( id_kas=id_kas, prn_no=prn_no, prn_name=prn_name, id_term=id_term, online=False, status="unknown", printer_type=printer_type, cmd32_on=cmd32_on, queue_size=queue_size, failed_jobs=failed_jobs, ) @app.get("/print/printers/status/", response_model=list[data.PrinterStatusOut]) def list_printer_status( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return list_printer_status_db(prefix, id_kas) def list_printer_status_db(prefix: str, id_kas: str) -> list[data.PrinterStatusOut]: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table_prndef = f"{prefix}_prndef" table_kasaucp = f"{prefix}_kasaucp" table_status = f"{prefix}_printer_status" table_jobs = f"{prefix}_print_jobs" with get_db() as conn: cur = conn.cursor() cur.execute(f'SELECT printers FROM "{table_kasaucp}" WHERE id_kas=?', (id_kas,)) row = cur.fetchone() allowed: list[str] = [] if row and row[0]: try: allowed = [ item.prn_no for item in TypeAdapter(list[data.KasaUcpPrinters]).validate_json(row[0]) ] except Exception: allowed = [] if allowed: placeholders = ",".join("?" for _ in allowed) cur.execute( f'SELECT prn_no, prn_name, id_term, data FROM "{table_prndef}" WHERE prn_no IN ({placeholders}) ORDER BY prn_no', tuple(allowed), ) else: cur.execute(f'SELECT prn_no, prn_name, id_term, data FROM "{table_prndef}" ORDER BY prn_no') printers = { row[0]: { "name": row[1] or "", "id_term": row[2] or "", "data": _json_obj(row[3]), } for row in cur.fetchall() } cur.execute( f'SELECT prn_no, agent_id, online, status, printer_type, cmd32_on, message, checked_at, updated_at, details FROM "{table_status}" WHERE id_kas=?', (id_kas,), ) statuses = {row[0]: row[1:] for row in cur.fetchall()} for prn_no in statuses: printers.setdefault(prn_no, {"name": "", "id_term": "", "data": {}}) cur.execute( f""" SELECT printer_no, COUNT(*) FROM "{table_jobs}" WHERE id_kas=? AND status IN ('queued', 'retry_pending', 'claimed', 'printing') GROUP BY printer_no """, (id_kas,), ) queued_counts = {row[0] or "": int(row[1]) for row in cur.fetchall()} cur.execute( f""" SELECT printer_no, COUNT(*) FROM "{table_jobs}" WHERE id_kas=? AND status IN ('failed', 'failed_final') GROUP BY printer_no """, (id_kas,), ) failed_counts = {row[0] or "": int(row[1]) for row in cur.fetchall()} out = [] for prn_no, prn in sorted(printers.items()): out.append( _printer_status_from_values( id_kas=id_kas, prn_no=prn_no, prn_name=prn.get("name") or "", id_term=prn.get("id_term") or "", prn_data=prn.get("data") or {}, status_row=statuses.get(prn_no), queue_size=queued_counts.get(prn_no, 0), failed_jobs=failed_counts.get(prn_no, 0), ) ) return out @app.post("/print/printers/status/", response_model=data.PrinterStatusOut) def upsert_printer_status( status: data.PrinterStatusIn, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return upsert_printer_status_db(prefix, status) def upsert_printer_status_db( prefix: str, status: data.PrinterStatusIn, ) -> data.PrinterStatusOut: if len(status.id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") if not status.prn_no.strip(): raise HTTPException(422, "Invalid prn_no") table = f"{prefix}_printer_status" with get_db() as conn: cur = conn.cursor() cur.execute( f""" INSERT INTO "{table}" ( id_kas, prn_no, agent_id, online, status, printer_type, cmd32_on, message, queue_size, failed_jobs, details, checked_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(id_kas, prn_no) DO UPDATE SET agent_id=excluded.agent_id, online=excluded.online, status=excluded.status, printer_type=excluded.printer_type, cmd32_on=excluded.cmd32_on, message=excluded.message, queue_size=excluded.queue_size, failed_jobs=excluded.failed_jobs, details=excluded.details, checked_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP """, ( status.id_kas, status.prn_no, status.agent_id or "", int(bool(status.online)), status.status or "unknown", status.printer_type or "", status.cmd32_on or "", status.message or "", int(status.queue_size or 0), int(status.failed_jobs or 0), _json_dump_obj(status.details), ), ) conn.commit() statuses = list_printer_status_db(prefix, status.id_kas) for item in statuses: if item.prn_no == status.prn_no: return item return data.PrinterStatusOut(**status.model_dump()) @app.post("/kasaucp/") def update_kasaucp( ucp: list[data.KasaUcp], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_kasaucp_db(prefix, ucp) def update_kasaucp_db(prefix: str, ucp: list[data.KasaUcp]): table = f"{prefix}_kasaucp" import json with get_db() as conn: cur = conn.cursor() # 🔹 existujúce cur.execute(f'SELECT id_kas FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 incoming incoming_ids = {item.id_kas for item in ucp} # 🔹 DELETE to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE id_kas=?', [(x,) for x in to_delete] ) # 🔹 UPSERT for item in ucp: printers_json = json.dumps( [p.model_dump() for p in item.printers] ) cur.execute(f""" INSERT INTO "{table}" (id_kas, printers) VALUES (?, ?) ON CONFLICT(id_kas) DO UPDATE SET printers = excluded.printers """, (item.id_kas, printers_json)) conn.commit() return {"ok": True} @app.post("/kasutxt/") def update_kasutxt( utxt: list[data.KasUtxt], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_kasutxt_db(prefix, utxt) def update_kasutxt_db(prefix: str, utxt: list[data.KasUtxt]): table = f"{prefix}_kasutxt" import json with get_db() as conn: cur = conn.cursor() # 🔹 existujúce cur.execute(f'SELECT id_kas FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 incoming incoming_ids = {item.id_kas for item in utxt} # 🔹 DELETE to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE id_kas=?', [(x,) for x in to_delete] ) # 🔹 UPSERT for item in utxt: riadky_json = json.dumps(item.riadky.model_dump()) cur.execute(f""" INSERT INTO "{table}" (id_kas, riadky) VALUES (?, ?) ON CONFLICT(id_kas) DO UPDATE SET riadky = excluded.riadky """, (item.id_kas, riadky_json)) conn.commit() return {"ok": True} @app.post("/hladiny/") def update_hladiny( ucp: list[data.Hladiny], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_hladiny_db(prefix, ucp) def update_hladiny_db(prefix: str, ucp: list[data.Hladiny]): table = f"{prefix}_hladiny" import json with get_db() as conn: cur = conn.cursor() # 🔹 existujúce cur.execute(f'SELECT id_kas FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 incoming incoming_ids = {item.id_kas for item in ucp} # 🔹 DELETE to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE id_kas=?', [(x,) for x in to_delete] ) # 🔹 UPSERT for item in ucp: riadky_json = json.dumps( [p.model_dump() for p in item.riadky] ) cur.execute(f""" INSERT INTO "{table}" (id_kas, riadky) VALUES (?, ?) ON CONFLICT(id_kas) DO UPDATE SET riadky = excluded.riadky """, (item.id_kas, riadky_json)) conn.commit() return {"ok": True} @app.post("/recepcia/") def update_recepcia( ucp: list[data.Recepcia], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_recepcia_db(prefix, ucp) def update_recepcia_db(prefix: str, ucp: list[data.Recepcia]): table = f"{prefix}_recepcia" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') rows = [] for item in ucp: rows.append( ( item.id, item.hotel, item.hor_ip, item.hor_port, item.hor_meno, item.hor_heslo, item.api_meno, item.api_heslo, item.typ_hotel, item.hor_prefix ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id, hotel, hor_ip, hor_port, hor_meno, hor_heslo, api_meno, api_heslo, typ_hotel, hor_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(rows)} def load_receptions_from_db(prefix: str) -> list[data.Recepcia]: table = f"{prefix}_recepcia" with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT id, hotel, hor_ip, hor_port, hor_meno, hor_heslo, api_meno, api_heslo, typ_hotel, hor_prefix FROM "{table}" ORDER BY hotel COLLATE NOCASE, id """ ) rows = cur.fetchall() return [ data.Recepcia( id=row[0], hotel=row[1], hor_ip=row[2], hor_port=row[3], hor_meno=row[4], hor_heslo=row[5], api_meno=row[6], api_heslo=row[7], typ_hotel=row[8], hor_prefix=row[9], ) for row in rows ] def load_reception_from_db(prefix: str, reception_id: int) -> data.Recepcia: for reception in load_receptions_from_db(prefix): if int(reception.id) == int(reception_id): return reception raise HTTPException(404, f"Recepcia {reception_id} nebola najdena") def ensure_postgres_for_reception(prefix: str, id_kas: str, reception: data.Recepcia): if int(getattr(reception, "typ_hotel", 0) or 0) != 6: return status = get_postgres_status_db(prefix, id_kas, test_connection=True) if not status.available: raise HTTPException(502, status.message or "PostgreSQL nie je dostupny.") def get_setup_param_values(prefix: str, id_kas: str) -> dict: return { param.var_name: param.var_value for param in get_effective_setup_parameters(prefix, id_kas) } def _strip_value(value) -> str: return "" if value is None else str(value).strip() def _int_value(value, default: int = 0) -> int: try: return int(float(str(value).strip())) except Exception: return default def _float_value(value, default: float = 0.0) -> float: try: return float(str(value).strip().replace(",", ".")) except Exception: return default def _bool_value(value, default: bool = False) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) text = str(value or "").strip().lower() if text in {"1", "t", "true", ".t.", "ano", "áno", "yes"}: return True if text in {"0", "f", "false", ".f.", "nie", "ne", "no", ""}: return False return default def _safe_pg_schema(conn: data.PostgresConnection) -> str: schema = _strip_value(getattr(conn, "schema_", "") or "food600") or "food600" if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", schema): raise HTTPException(422, "Neplatny nazov PostgreSQL schemy.") return schema def _pg_fetchall_dict(pg, sql: str, params: tuple = ()) -> list[dict]: cur = pg.cursor() try: cur.execute(sql, params) cols = [col[0] for col in (cur.description or [])] return [dict(zip(cols, row)) for row in cur.fetchall()] finally: cur.close() def _pg_fetchone_value(pg, sql: str, params: tuple = ()): cur = pg.cursor() try: cur.execute(sql, params) row = cur.fetchone() return row[0] if row else None finally: cur.close() def limits_cashier_enabled(prefix: str, id_kas: str) -> bool: params = get_setup_param_values(prefix, id_kas) return _bool_value(params.get("is_limspra"), False) def ensure_limits_postgres(prefix: str, id_kas: str) -> data.PostgresConnection: if not limits_cashier_enabled(prefix, id_kas): raise HTTPException(404, "Limity nie su povolene pre tuto kasu.") status = get_postgres_status_db(prefix, id_kas, test_connection=True) if not status.available: raise HTTPException(502, status.message or "PostgreSQL nie je dostupny.") return get_postgres_connection_db(prefix, include_password=True) def limit_table_id(id_limit, id_den) -> str: return f"LIM:{_int_value(id_limit, 0)}:{_int_value(id_den, 0)}" def parse_limit_table_id(stul: str) -> tuple[int, int]: parts = str(stul or "").strip().split(":") if len(parts) != 3 or parts[0].upper() != "LIM": raise HTTPException(422, "Neplatny identifikator limitoveho stola.") id_limit = _int_value(parts[1], 0) id_den = _int_value(parts[2], 0) if not id_limit or not id_den: raise HTTPException(422, "Neplatny limit alebo den limitu.") return id_limit, id_den def limit_lock_key(id_limit: int) -> str: return f"LIMIT_{int(id_limit or 0)}" def resolve_limit_lock_key(prefix: str, id_kas: str, id_limit: int) -> str: # Foodman pouziva: "LIMIT_" + allt(str(id_limit, 10, 0)). # Teda bez nul a bez hladania podobnych historickych riadkov. return limit_lock_key(id_limit) def limit_cashier_allowed(txt_kasy: str, id_kas: str) -> bool: cashiers = { part.strip() for part in str(txt_kasy or "").split(";") if part.strip() } id_text = str(id_kas or "").strip() accepted = {id_text} try: accepted.add(str(int(id_text))) accepted.add(f"{int(id_text):02d}") except Exception: pass normalized_cashiers = set(cashiers) for cashier in list(cashiers): try: normalized_cashiers.add(str(int(cashier))) normalized_cashiers.add(f"{int(cashier):02d}") except Exception: pass return bool(accepted & normalized_cashiers) def limit_display_name(menolimit, datum) -> str: name = _strip_value(menolimit) date = _strip_value(datum) return f"{name}\n{date}" if date else name def limit_display_name_from_rows(rows: list[dict], *, multiline: bool = True) -> str: name = "" date = "" for row in rows or []: if not name: name = _strip_value(row.get("menolimit")) if not date: date = _strip_value(row.get("datum")) if name and date: break text = limit_display_name(name, date) if not multiline: text = " ".join(part for part in text.splitlines() if part.strip()) return text def load_limit_tables_from_postgres(prefix: str, id_kas: str) -> list[data.LimitTable]: if not limits_cashier_enabled(prefix, id_kas): return [] conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) sql = f""" SELECT id_limit, id_den, menolimit, datum, txt_kasy, COUNT(*) AS row_count, MIN(poradie) AS poradie FROM "{schema}"."slimity_zoznam" GROUP BY id_limit, id_den, menolimit, datum, txt_kasy ORDER BY menolimit, datum, MIN(poradie) """ with postgres_service.connect(conn) as pg: rows = _pg_fetchall_dict(pg, sql) result: list[data.LimitTable] = [] skipped = 0 for row in rows: if not limit_cashier_allowed(row.get("txt_kasy", ""), id_kas): skipped += 1 continue id_limit = _int_value(row.get("id_limit"), 0) id_den = _int_value(row.get("id_den"), 0) result.append(data.LimitTable( table_id=limit_table_id(id_limit, id_den), id_limit=id_limit, id_den=id_den, menolimit=_strip_value(row.get("menolimit")), datum=_strip_value(row.get("datum")), txt_kasy=_strip_value(row.get("txt_kasy")), name=limit_display_name(row.get("menolimit"), row.get("datum")), row_count=_int_value(row.get("row_count"), 0), )) logger.info( "Limity loaded: prefix=%s id_kas=%s postgres_rows=%s filtered=%s skipped=%s", prefix, id_kas, len(rows), len(result), skipped, ) return result def acquire_limit_lock(prefix: str, id_kas: str, id_limit: int, id_den: int, client_id: str, user: str) -> data.LimitLockResult: table = f"{prefix}_limit_locks" key = limit_lock_key(id_limit) now = time.time() with get_db() as conn: cur = conn.cursor() init_limit_locks_schema(prefix, cur) cur.execute( f'SELECT client_id, user, locked_at FROM "{table}" WHERE lock_key=?', (key,), ) row = cur.fetchone() if row: owner_client, owner_user, locked_at = row if str(owner_client or "") != str(client_id or ""): pg_lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit) with _limit_pg_locks_guard: pg_info = _limit_pg_locks.get(pg_lock_id) pg_live = bool(pg_info and not _pg_connection_closed(pg_info.get("pg"))) if not pg_live: logger.warning( "Limit local lock is stale, overriding key=%s owner=%s", key, owner_client, ) else: age = int(now - float(locked_at or now)) return data.LimitLockResult( ok=False, table_id=limit_table_id(id_limit, id_den), id_limit=id_limit, id_den=id_den, message=f"Limit je otvoreny na terminali {owner_client} ({owner_user}), {age}s.", ) cur.execute(f""" INSERT INTO "{table}" (lock_key, id_kas, client_id, user, id_limit, id_den, locked_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(lock_key) DO UPDATE SET id_kas=excluded.id_kas, client_id=excluded.client_id, user=excluded.user, id_limit=excluded.id_limit, id_den=excluded.id_den, locked_at=excluded.locked_at """, (key, id_kas, client_id, user, id_limit, id_den, now)) return data.LimitLockResult( ok=True, table_id=limit_table_id(id_limit, id_den), id_limit=id_limit, id_den=id_den, message="", ) def release_limit_lock(prefix: str, id_limit: int, client_id: str | None = None) -> data.LimitLockResult: table = f"{prefix}_limit_locks" key = limit_lock_key(id_limit) with get_db() as conn: cur = conn.cursor() init_limit_locks_schema(prefix, cur) if client_id: cur.execute( f'DELETE FROM "{table}" WHERE lock_key=? AND client_id=?', (key, client_id), ) else: cur.execute(f'DELETE FROM "{table}" WHERE lock_key=?', (key,)) return data.LimitLockResult(ok=True, id_limit=id_limit, message="") def call_pg_semafor(prefix: str, id_kas: str, fn_name: str, *params) -> str: conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) placeholders = ", ".join(["%s"] * len(params)) sql = f'SELECT "{schema}"."{fn_name}"({placeholders})' with postgres_service.connect(conn) as pg: value = _pg_fetchone_value(pg, sql, tuple(params)) return _strip_value(value) def acquire_pg_limit_marker(prefix: str, id_kas: str, key: str, holder: str) -> tuple[bool, str]: conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) with postgres_service.connect(conn) as pg: cur = pg.cursor() try: cur.execute(f'SELECT kto FROM "{schema}"."semafor" WHERE n_semafor=%s', (key,)) row = cur.fetchone() current_holder = _strip_value(row[0]) if row else "" if current_holder and current_holder != holder: pg.rollback() return False, current_holder if row: cur.execute( f'UPDATE "{schema}"."semafor" SET kedy=now(), kto=%s WHERE n_semafor=%s', (holder, key), ) else: cur.execute( f'INSERT INTO "{schema}"."semafor" (n_semafor, kedy, kto) VALUES (%s, now(), %s)', (key, holder), ) pg.commit() return True, "" except Exception: pg.rollback() raise finally: cur.close() def release_pg_limit_marker(prefix: str, id_kas: str, key: str, holder: str | None = None) -> None: conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) with postgres_service.connect(conn) as pg: cur = pg.cursor() try: if holder: cur.execute( f'UPDATE "{schema}"."semafor" SET kto=%s, kedy=now() WHERE n_semafor=%s AND kto=%s', ("", key, holder), ) else: cur.execute( f'UPDATE "{schema}"."semafor" SET kto=%s, kedy=now() WHERE n_semafor=%s', ("", key), ) pg.commit() except Exception: pg.rollback() raise finally: cur.close() def _limit_pg_lock_key(prefix: str, id_kas: str, id_limit: int) -> tuple[str, str, int]: return (str(prefix), str(id_kas), int(id_limit or 0)) def _pg_connection_closed(pg) -> bool: closed = getattr(pg, "closed", False) try: return bool(int(closed)) except Exception: return bool(closed) def acquire_pg_limit_advisory_lock( prefix: str, id_kas: str, id_limit: int, id_den: int, client_id: str, user: str, ) -> tuple[bool, str]: lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit) key = resolve_limit_lock_key(prefix, id_kas, id_limit) holder = f"{client_id}:{user}"[:60] with _limit_pg_locks_guard: existing = _limit_pg_locks.get(lock_id) if existing: pg = existing.get("pg") if _pg_connection_closed(pg): _limit_pg_locks.pop(lock_id, None) elif str(existing.get("client_id", "")) == str(client_id): existing["locked_at"] = time.time() return True, "" else: owner = _strip_value(existing.get("holder")) or _strip_value(existing.get("client_id")) return False, owner conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) pg = None cur = None try: pg = postgres_service.open_connection(conn) cur = pg.cursor() cur.execute(f'SELECT "{schema}"."semafor_zapni"(%s, %s)', (key, holder)) row = cur.fetchone() result = _strip_value(row[0] if row else "") pg.commit() logger.info("Limit semafor_zapni key=%s holder=%s result=%s", key, holder, result) if result != "1": try: pg.close() except Exception: pass return False, "iny terminal" _limit_pg_locks[lock_id] = { "pg": pg, "schema": schema, "key": key, "holder": holder, "client_id": client_id, "user": user, "id_den": id_den, "locked_at": time.time(), } logger.info("Limit advisory lock acquired key=%s holder=%s", key, holder) return True, "" except Exception: if pg is not None: try: pg.close() except Exception: pass raise finally: if cur is not None: try: cur.close() except Exception: pass def release_pg_limit_advisory_lock( prefix: str, id_kas: str, id_limit: int, client_id: str | None = None, ) -> bool: lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit) with _limit_pg_locks_guard: info = _limit_pg_locks.get(lock_id) if not info: return False if client_id and str(info.get("client_id", "")) != str(client_id): return False info = _limit_pg_locks.pop(lock_id, None) pg = info.get("pg") cur = None try: if pg is not None and not _pg_connection_closed(pg): cur = pg.cursor() cur.execute( f'SELECT "{info["schema"]}"."semafor_vypni"(%s)', (info["key"],), ) pg.commit() logger.info("Limit advisory lock released key=%s holder=%s", info["key"], info.get("holder", "")) return True except Exception as e: logger.warning(f"Limit advisory unlock failed key={info.get('key')}: {e}") try: pg.rollback() except Exception: pass return False finally: if cur is not None: try: cur.close() except Exception: pass if pg is not None: try: pg.close() except Exception: pass return False def acquire_limit_semafor(prefix: str, id_kas: str, id_limit: int, id_den: int, client_id: str, user: str) -> data.LimitLockResult: lock = acquire_limit_lock(prefix, id_kas, id_limit, id_den, client_id, user) if not lock.ok: return lock try: pg_ok, pg_owner = acquire_pg_limit_advisory_lock(prefix, id_kas, id_limit, id_den, client_id, user) except Exception as e: release_limit_lock(prefix, id_limit, client_id) raise if not pg_ok: release_limit_lock(prefix, id_limit, client_id) return data.LimitLockResult( ok=False, table_id=limit_table_id(id_limit, id_den), id_limit=id_limit, id_den=id_den, message=f"Limit je zamknuty v PostgreSQL ({pg_owner or 'iny terminal'}).", ) return lock def release_limit_semafor(prefix: str, id_kas: str, id_limit: int, client_id: str | None = None) -> data.LimitLockResult: release_pg_limit_advisory_lock(prefix, id_kas, id_limit, client_id) return release_limit_lock(prefix, id_limit, client_id) def build_limit_ucet_from_postgres(prefix: str, id_kas: str, id_limit: int, id_den: int, user: str) -> data.Ucet: conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) with postgres_service.connect(conn) as pg: rows = _pg_fetchall_dict(pg, f""" SELECT * FROM "{schema}"."slimity_zoznam" WHERE id_limit=%s AND id_den=%s ORDER BY poradie, id_rov """, (id_limit, id_den)) if not rows: raise HTTPException(404, "Limit nebol najdeny.") if not any(limit_cashier_allowed(row.get("txt_kasy", ""), id_kas) for row in rows): raise HTTPException(403, "Limit nie je povoleny pre tuto kasu.") items = _pg_fetchall_dict(pg, f""" SELECT * FROM "{schema}"."slimity_polozky" WHERE id_limit=%s AND id_den=%s ORDER BY id_rov, c_hlad, poradie, idriadok """, (id_limit, id_den)) rov_map: dict[int, dict] = {} courses: list[dict] = [] for row in rows: id_rov = _int_value(row.get("id_rov"), 0) if not id_rov or id_rov in rov_map: continue course = { "id": f"rov:{id_rov}", "name": _strip_value(row.get("chod")) or f"Chod {len(courses) + 1}", "id_rov": id_rov, "cenhlad": _strip_value(row.get("cenhlad")), } rov_map[id_rov] = course courses.append(course) guests_by_id: dict[str, dict] = {} for row in items: c_hlad = _int_value(row.get("c_hlad"), 0) guest_id = f"hlad:{c_hlad}" if guest_id not in guests_by_id: guests_by_id[guest_id] = { "id": guest_id, "name": _strip_value(row.get("hladina")) or f"Hladina {c_hlad}", "c_hlad": c_hlad, } guests = list(guests_by_id.values()) or [{"id": "hlad:0", "name": "Hladina 0", "c_hlad": 0}] poloz: list[data.UcPol] = [] menu_parent_lines: dict[str, str] = {} menu_parent_qty: dict[str, float] = {} def fmenu_id(row) -> str: return _strip_value(row.get("id_fmenu") or row.get("id_fstmenu")) def fmenu_key(value: str, id_rov: int, c_hlad: int) -> str: text = _strip_value(value).upper() if len(text) > 1 and text[0] in {"H", "P"}: suffix = text[1:].lstrip("0") or "0" return f"lim-menu:{id_rov}:{c_hlad}:{suffix}" return "" for row in items: id_card = _int_value(row.get("c_karty"), 0) if not id_card: continue id_rov = _int_value(row.get("id_rov"), 0) c_hlad = _int_value(row.get("c_hlad"), 0) id_fmenu = fmenu_id(row) key = fmenu_key(id_fmenu, id_rov, c_hlad) if key and id_fmenu.upper().startswith("H"): menu_parent_lines[key] = f"lim:{_int_value(row.get('idriadok'), 0) or uuid.uuid4().hex}" for row in items: id_card = _int_value(row.get("c_karty"), 0) if not id_card: continue id_rov = _int_value(row.get("id_rov"), 0) course = rov_map.get(id_rov) or {} c_hlad = _int_value(row.get("c_hlad"), 0) line_id = f"lim:{_int_value(row.get('idriadok'), 0) or uuid.uuid4().hex}" id_fmenu = fmenu_id(row) menu_key = fmenu_key(id_fmenu, id_rov, c_hlad) fmenu_kind = id_fmenu[:1].upper() is_menu_parent = bool(menu_key and fmenu_kind == "H") is_menu_child = bool(menu_key and fmenu_kind == "P") if is_menu_parent and menu_key in menu_parent_lines: line_id = menu_parent_lines[menu_key] dph = _strip_value(row.get("dan")) if dph.endswith(".0"): dph = dph[:-2] price = _float_value(row.get("cena_prod"), 0.0) qty_units = _float_value(row.get("mnozstvi"), 0.0) delitel = max(_int_value(row.get("polka"), 0) + 1, 1) qty = round(qty_units * delitel, 4) is_decimal_qty = abs(qty_units - round(qty_units)) > 0.0001 typ_menu = 1 if is_menu_parent else (2 if is_menu_child else (12 if is_decimal_qty else 0)) group_id = menu_key or line_id parent_id = menu_parent_lines.get(menu_key) if is_menu_child else None pol = data.UcPol( id_card=id_card, c_druh=_int_value(row.get("c_druh"), 0), druh="", prn_no="", nazev=_strip_value(row.get("nazev")), cena=price, cena_puv=price, dph=dph, mena="", cenhlad=_strip_value(course.get("cenhlad")) or "0", pocet=qty, delitel=delitel, sklad=_strip_value(row.get("id_man")) or _strip_value(row.get("c_stredisk")) or "00", line_id=line_id, group_id=group_id, parent_id=parent_id, typ_menu=typ_menu, pol_pocet=qty, def_cena=price, def_dph=dph, def_hlad=_strip_value(course.get("cenhlad")) or "0", guest_id=f"hlad:{c_hlad}", course_id=f"rov:{id_rov}", limit_item_id=_int_value(row.get("idriadok"), 0), limit_rov_id=id_rov, limit_hlad_id=c_hlad, limit_fmenu_id=id_fmenu, zpravy=[_strip_value(row.get("poznamka"))] if _strip_value(row.get("poznamka")) else [], ) poloz.append(pol) if typ_menu == 1: menu_parent_qty[group_id] = float(pol.pocet or 0) for pol in poloz: if int(getattr(pol, "typ_menu", 0) or 0) != 2: continue parent_qty = menu_parent_qty.get(pol.group_id, 0) if parent_qty: pol.pol_pocet = round(float(pol.pocet or 0) / parent_qty, 4) ucet = data.Ucet( id_kas=id_kas, stul=limit_table_id(id_limit, id_den), table_name=limit_display_name_from_rows(rows, multiline=False), room_name="Limity", autor=user, open_at=data.stime_str(), origin="Limit", poloz=poloz, guests=guests, courses=courses or [{"id": "rov:0", "name": "Chod 1", "id_rov": 0}], guest_count=len(guests), course_count=len(courses or [1]), limit_id=id_limit, limit_den_id=id_den, limit_rov_ids=sorted({p.limit_rov_id for p in poloz if p.limit_rov_id}), limit_mode=True, ) ucet.sumdph() return ucet def _limit_int_from_id(value, prefix_text: str = "") -> int: text = _strip_value(value) if prefix_text and text.startswith(prefix_text): text = text[len(prefix_text):] return _int_value(text, 0) def _limit_pol_rov_id(pol) -> int: return ( _int_value(getattr(pol, "limit_rov_id", None), 0) or _limit_int_from_id(getattr(pol, "course_id", ""), "rov:") ) def _limit_pol_hlad_id(pol) -> int: return ( _int_value(getattr(pol, "limit_hlad_id", None), 0) or _limit_int_from_id(getattr(pol, "guest_id", ""), "hlad:") ) def limit_receipt_rov_ids(ucet: data.Ucet) -> list[int]: rov_ids = { _limit_pol_rov_id(pol) for pol in (getattr(ucet, "poloz", []) or []) if _limit_pol_rov_id(pol) > 0 } if not rov_ids: rov_ids = { _int_value(item, 0) for item in (getattr(ucet, "limit_rov_ids", []) or []) if _int_value(item, 0) > 0 } return sorted(rov_ids) def ensure_limit_lock_owner(prefix: str, id_kas: str, id_limit: int, client_id: str) -> None: lock_id = _limit_pg_lock_key(prefix, id_kas, id_limit) with _limit_pg_locks_guard: info = _limit_pg_locks.get(lock_id) pg = info.get("pg") if info else None if ( not info or _pg_connection_closed(pg) or str(info.get("client_id", "")) != str(client_id) ): raise HTTPException(409, "Limitovy stol uz nie je zamknuty tymto terminalom.") def _pg_table_columns(pg, schema: str, table_name: str) -> set[str]: rows = _pg_fetchall_dict(pg, """ SELECT column_name FROM information_schema.columns WHERE table_schema=%s AND table_name=%s """, (schema, table_name)) return {_strip_value(row.get("column_name")) for row in rows} def _limit_item_note(pol) -> str: notes = getattr(pol, "zpravy", []) or [] if isinstance(notes, list): return "\n".join(str(note) for note in notes if str(note).strip()) return _strip_value(notes) def _limit_item_quantity(pol) -> float: den = int(getattr(pol, "delitel", 1) or 1) den = den if den > 0 else 1 return round(float(getattr(pol, "pocet", 0) or 0) / den, 4) def _limit_fmenu_number(value: str) -> int: text = _strip_value(value).upper() if len(text) <= 1 or text[0] not in {"H", "P"}: return 0 return _int_value(text[1:], 0) def _limit_generated_fmenu_ids(items) -> dict[str, str]: max_no = 0 group_numbers: dict[str, int] = {} for pol in items: existing = _strip_value(getattr(pol, "limit_fmenu_id", "")) no = _limit_fmenu_number(existing) if no: max_no = max(max_no, no) group_id = _strip_value(getattr(pol, "group_id", "")) if group_id: group_numbers[group_id] = no for pol in items: typ_menu = _int_value(getattr(pol, "typ_menu", 0), 0) group_id = _strip_value(getattr(pol, "group_id", "")) if typ_menu in {1, 2} and group_id and group_id not in group_numbers: max_no += 1 group_numbers[group_id] = max_no result: dict[str, str] = {} for pol in items: line_id = _strip_value(getattr(pol, "line_id", "")) if not line_id: continue existing = _strip_value(getattr(pol, "limit_fmenu_id", "")) if existing: result[line_id] = existing continue typ_menu = _int_value(getattr(pol, "typ_menu", 0), 0) group_id = _strip_value(getattr(pol, "group_id", "")) no = group_numbers.get(group_id, 0) if typ_menu == 1 and no: result[line_id] = f"H{no:06d}" elif typ_menu == 2 and no: result[line_id] = f"P{no:06d}" else: result[line_id] = "" return result def _limit_group_stredisk_map(items, fooddat_map: dict[str, int]) -> dict[str, int]: result: dict[str, int] = {} for pol in items: group_id = _strip_value(getattr(pol, "group_id", "")) if not group_id or group_id in result: continue stredisk = fooddat_stredisk_for_sklad( fooddat_map, getattr(pol, "sklad", ""), ) if stredisk: result[group_id] = stredisk return result def _limit_item_values( ucet: data.Ucet, pol, poradie: int, fmenu_id: str = "", fooddat_map: dict[str, int] | None = None, group_stredisk_map: dict[str, int] | None = None, ) -> dict: delitel = max(_int_value(getattr(pol, "delitel", 1), 1), 1) sklad = _strip_value(getattr(pol, "sklad", "")) c_stredisk = fooddat_stredisk_for_sklad(fooddat_map or {}, sklad) if not c_stredisk and _int_value(getattr(pol, "typ_menu", 0), 0) in {1, 2}: c_stredisk = (group_stredisk_map or {}).get(_strip_value(getattr(pol, "group_id", "")), 0) return { "id_limit": int(getattr(ucet, "limit_id", 0) or 0), "id_den": int(getattr(ucet, "limit_den_id", 0) or 0), "id_rov": _limit_pol_rov_id(pol), "c_karty": int(getattr(pol, "id_card", 0) or 0), "cena_prod": float(getattr(pol, "cena", 0) or 0), "dan": _strip_value(getattr(pol, "dph", "")), "mnozstvi": _limit_item_quantity(pol), "c_stredisk": c_stredisk or _int_value(sklad, 0), "poradie": poradie, "poznamka": _limit_item_note(pol), "polka": delitel - 1, "c_hlad": _limit_pol_hlad_id(pol), "id_fmenu": _strip_value(fmenu_id or getattr(pol, "limit_fmenu_id", "")), "vlastnik": _strip_value(getattr(pol, "vlastnik", "")) or _strip_value(getattr(ucet, "id_kas", "")), } def save_limit_items_to_postgres(prefix: str, id_kas: str, ucet: data.Ucet, client_id: str | None = None) -> None: id_limit = int(getattr(ucet, "limit_id", 0) or 0) id_den = int(getattr(ucet, "limit_den_id", 0) or 0) if not id_limit or not id_den: raise HTTPException(422, "Limitovy ucet nema id_limit alebo id_den.") if client_id is not None: ensure_limit_lock_owner(prefix, id_kas, id_limit, client_id) conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) with postgres_service.connect(conn) as pg: cur = pg.cursor() try: columns = _pg_table_columns(pg, schema, "nlimity") if not columns: raise HTTPException(502, "V PostgreSQL neexistuje tabulka nlimity.") existing_rows = _pg_fetchall_dict(pg, f""" SELECT idriadok FROM "{schema}"."slimity_polozky" WHERE id_limit=%s AND id_den=%s AND COALESCE(c_karty, 0) <> 0 """, (id_limit, id_den)) existing_ids = {_int_value(row.get("idriadok"), 0) for row in existing_rows} seen_ids: set[int] = set() items = [pol for pol in (getattr(ucet, "poloz", []) or []) if int(getattr(pol, "id_card", 0) or 0)] fmenu_ids = _limit_generated_fmenu_ids(items) fooddat_map = get_fooddat_stredisk_map(prefix) group_stredisk_map = _limit_group_stredisk_map(items, fooddat_map) for poradie, pol in enumerate(items, start=1): values = _limit_item_values( ucet, pol, poradie, fmenu_ids.get(_strip_value(getattr(pol, "line_id", "")), ""), fooddat_map=fooddat_map, group_stredisk_map=group_stredisk_map, ) item_id = _int_value(getattr(pol, "limit_item_id", None), 0) writable = { key: value for key, value in values.items() if key in columns } if item_id: seen_ids.add(item_id) set_cols = [ key for key in writable if key not in {"idriadok", "id_limit", "id_den", "vlastnik"} ] if set_cols: sql = ", ".join(f'"{key}"=%s' for key in set_cols) cur.execute( f'UPDATE "{schema}"."nlimity" SET {sql} WHERE idriadok=%s', tuple(writable[key] for key in set_cols) + (item_id,), ) else: insert_cols = [ key for key in ( "id_limit", "id_den", "id_rov", "c_karty", "cena_prod", "dan", "mnozstvi", "c_stredisk", "poradie", "poznamka", "polka", "c_hlad", "id_fmenu", "vlastnik", ) if key in writable ] if not {"id_limit", "id_den", "id_rov", "c_karty"} <= set(insert_cols): raise HTTPException(502, "Tabulka nlimity nema potrebne stlpce pre vlozenie polozky.") placeholders = ", ".join(["%s"] * len(insert_cols)) cols_sql = ", ".join(f'"{key}"' for key in insert_cols) cur.execute( f'INSERT INTO "{schema}"."nlimity" ({cols_sql}) VALUES ({placeholders})', tuple(writable[key] for key in insert_cols), ) missing_ids = sorted(existing_ids - seen_ids) if missing_ids and "mnozstvi" in columns: placeholders = ", ".join(["%s"] * len(missing_ids)) cur.execute( f'UPDATE "{schema}"."nlimity" SET "mnozstvi"=0 WHERE idriadok IN ({placeholders})', tuple(missing_ids), ) pg.commit() except HTTPException: pg.rollback() raise except Exception as exc: pg.rollback() logger.exception("Limit items save failed") raise HTTPException(502, f"Limitove polozky sa nepodarilo ulozit: {exc}") from exc finally: cur.close() def next_limit_cenhlad(prefix: str, id_kas: str, id_limit: int, id_den: int) -> str: conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) with postgres_service.connect(conn) as pg: value = _pg_fetchone_value(pg, f""" SELECT MAX(NULLIF(TRIM(COALESCE(cenhlad, '')), '')) FROM "{schema}"."nlimitrov" WHERE id_limit=%s AND id_den=%s """, (id_limit, id_den)) current = _strip_value(value) if not current: return "A" ch = current[-1:].upper() if not ("A" <= ch <= "Y"): return "Z" return chr(ord(ch) + 1) def limit_nlimitrov_ucislo(ucislo: str | None) -> str: text = _strip_value(ucislo) if len(text) > 7: text = text[:2] + text[3:] return text[:7] def apply_limit_cenhlad_to_receipt_items(ucet: data.Ucet, cenhlad: str) -> None: limit_level = _strip_value(cenhlad) if not limit_level: return ucet.limit_cenhlad = limit_level for pol in getattr(ucet, "poloz", []) or []: pol.cenhlad = limit_level def apply_limit_payment_to_postgres(prefix: str, id_kas: str, ucet: data.Ucet, cenhlad: str | None = None) -> None: id_limit = int(getattr(ucet, "limit_id", 0) or 0) id_den = int(getattr(ucet, "limit_den_id", 0) or 0) rov_ids = limit_receipt_rov_ids(ucet) if not id_limit or not id_den or not rov_ids: raise HTTPException(422, "Limitovy ucet nema chody na zapis.") conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) cenhlad = _strip_value(cenhlad or getattr(ucet, "limit_cenhlad", "")) or next_limit_cenhlad(prefix, id_kas, id_limit, id_den) ucislo_limit = limit_nlimitrov_ucislo(getattr(ucet, "ucislo", "")) placeholders = ", ".join(["%s"] * len(rov_ids)) with postgres_service.connect(conn) as pg: cur = pg.cursor() try: cur.execute(f""" UPDATE "{schema}"."nlimitrov" SET u_cis=%s, cenhlad=%s, uzaverka=%s WHERE id_limit=%s AND id_den=%s AND id_rov IN ({placeholders}) """, (ucislo_limit, cenhlad, _strip_value(id_kas), id_limit, id_den, *rov_ids)) if cur.rowcount != len(rov_ids): raise HTTPException(409, f"Limitove chody sa nepodarilo oznacit ({cur.rowcount}/{len(rov_ids)}).") pg.commit() except HTTPException: pg.rollback() raise except Exception as exc: pg.rollback() logger.exception("Limit payment mark failed") raise HTTPException(502, f"Limit sa nepodarilo oznacit ako zaplateny: {exc}") from exc finally: cur.close() def clear_limit_payment_in_postgres(prefix: str, id_kas: str, ucet: data.Ucet) -> None: id_limit = int(getattr(ucet, "limit_id", 0) or 0) id_den = int(getattr(ucet, "limit_den_id", 0) or 0) rov_ids = limit_receipt_rov_ids(ucet) if not id_limit or not id_den or not rov_ids: return conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) placeholders = ", ".join(["%s"] * len(rov_ids)) with postgres_service.connect(conn) as pg: cur = pg.cursor() try: cur.execute(f""" UPDATE "{schema}"."nlimitrov" SET u_cis='', uzaverka='', cenhlad='' WHERE id_limit=%s AND id_den=%s AND id_rov IN ({placeholders}) """, (id_limit, id_den, *rov_ids)) pg.commit() except Exception as exc: pg.rollback() logger.exception("Limit payment clear failed") raise HTTPException(502, f"Limit sa nepodarilo odznacit: {exc}") from exc finally: cur.close() def sync_limit_closure_to_postgres(prefix: str, id_kas: str, raw_receipts: list[str], clsrep_id: int) -> None: limit_receipts: list[data.Ucet] = [] for raw in raw_receipts: try: ucet = data.Ucet.model_validate_json(raw) except Exception: continue if not getattr(ucet, "limit_id", None): continue if getattr(ucet, "is_storno", None) or getattr(ucet, "storno", None): continue if not getattr(ucet, "ucislo", None): continue if not limit_receipt_rov_ids(ucet): continue limit_receipts.append(ucet) if not limit_receipts: return conn = ensure_limits_postgres(prefix, id_kas) schema = _safe_pg_schema(conn) with postgres_service.connect(conn) as pg: cur = pg.cursor() try: for ucet in limit_receipts: rov_ids = limit_receipt_rov_ids(ucet) placeholders = ", ".join(["%s"] * len(rov_ids)) cur.execute(f""" UPDATE "{schema}"."nlimitrov" SET uzaverka=%s WHERE id_limit=%s AND id_den=%s AND u_cis=%s AND id_rov IN ({placeholders}) """, ( str(clsrep_id), int(getattr(ucet, "limit_id", 0) or 0), int(getattr(ucet, "limit_den_id", 0) or 0), limit_nlimitrov_ucislo(getattr(ucet, "ucislo", "")), *rov_ids, )) pg.commit() except Exception as exc: pg.rollback() logger.exception("Limit closure sync failed") raise HTTPException(502, f"Limitovu uzavierku sa nepodarilo zapisat do PostgreSQL: {exc}") from exc finally: cur.close() def insert_limit_closed_receipt_db(cur_pref: str, uct: data.Ucet, client_id: str) -> data.Ucet: id_limit = int(getattr(uct, "limit_id", 0) or 0) id_den = int(getattr(uct, "limit_den_id", 0) or 0) if not id_limit or not id_den: raise HTTPException(422, "Limitovy ucet nema id_limit alebo id_den.") ensure_limit_lock_owner(cur_pref, uct.id_kas, id_limit, client_id) rov_ids = limit_receipt_rov_ids(uct) if not rov_ids: raise HTTPException(422, "Limitovy ucet nema vybrany chod.") uct.limit_mode = True uct.limit_rov_ids = rov_ids uct.limit_cenhlad = next_limit_cenhlad(cur_pref, uct.id_kas, id_limit, id_den) table = f"{cur_pref}_ucty" now = time_hhmmss() with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) uct.ucislo = generate_ucislo(cur, table, uct.id_kas) uct.closed_at = uct.closed_at or now uct.datetime = uct.datetime or uct.closed_at uct.blocked_by = "" uct.stul = uct.stul or limit_table_id(id_limit, id_den) uct.room_name = uct.room_name or "Limity" strip_transient_hotel_charge_data(uct) ensure_hotel_charge_payment_targets(uct) apply_limit_cenhlad_to_receipt_items(uct, uct.limit_cenhlad) uct.sumdph() uct.checksum_val = uct.checksum() finalize_hotel_charge_on_close(cur_pref, uct) payload = uct.model_dump_json() cur.execute(f""" INSERT INTO "{table}" (ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( uct.ucislo, uct.id_kas, uct.stul, uct.room_name, "", uct.closed_at, uct.c_uzaverka, payload, )) apply_limit_payment_to_postgres(cur_pref, uct.id_kas, uct, uct.limit_cenhlad) return uct def hotel_raster_table_for_type(typ_hotel: int) -> str: typ_hotel = int(typ_hotel or 0) if typ_hotel in {17, 18}: return "hotrastre" if typ_hotel == 21: return "mewsrastre" if typ_hotel in {6, 10}: return "fidrastre" return "" def hotel_raster_table_name(prefix: str, typ_hotel: int) -> str: table_type = hotel_raster_table_for_type(typ_hotel) return f"{prefix}_{table_type}" if table_type else "" def price_level_raster_index(price_level: str) -> int: idx = _int_value(price_level, 1) if idx < 1: return 1 if idx > 9: return 9 return idx def load_c_druh_map(prefix: str, id_kas: str) -> dict[int, int]: table = f"{prefix}_cenik" result: dict[int, int] = {} with get_db() as conn: cur = conn.cursor() cur.execute(f'SELECT id_card, c_druh, data FROM "{table}" WHERE pokl = ?', (id_kas,)) rows = cur.fetchall() if not rows: cur.execute(f'SELECT id_card, c_druh, data FROM "{table}"') rows = cur.fetchall() for id_card_db, c_druh_db, raw_json in rows: id_card = _int_value(id_card_db, 0) c_druh = _int_value(c_druh_db, 0) if not id_card or not c_druh: try: payload = json.loads(raw_json) except Exception: payload = {} if not id_card: id_card = _int_value(payload.get("id_card"), 0) if not c_druh: c_druh = _int_value(payload.get("c_druh"), 0) if id_card: result[id_card] = c_druh return result def load_cenik_print_map(prefix: str, id_kas: str) -> dict[int, dict]: table = f"{prefix}_cenik" result: dict[int, dict] = {} with get_db() as conn: cur = conn.cursor() cur.execute(f'SELECT id_card, c_druh, druh, spart, prn_no, data FROM "{table}" WHERE pokl = ?', (id_kas,)) rows = cur.fetchall() if not rows: cur.execute(f'SELECT id_card, c_druh, druh, spart, prn_no, data FROM "{table}"') rows = cur.fetchall() for id_card_db, c_druh_db, druh_db, spart_db, prn_no_db, raw_json in rows: try: payload = json.loads(raw_json or "{}") except Exception: payload = {} id_card = _int_value(id_card_db, 0) or _int_value(payload.get("id_card"), 0) if not id_card: continue result[id_card] = { "c_druh": _int_value(c_druh_db, 0) or _int_value(payload.get("c_druh"), 0), "druh": _strip_value(druh_db) or _strip_value(payload.get("druh")), "spart": _strip_value(spart_db) or _strip_value(payload.get("spart")), "prn_no": _strip_value(prn_no_db) or _strip_value(payload.get("prn_no")), } return result def load_hotel_raster_rows(prefix: str, id_kas: str, typ_hotel: int, reception_id: int) -> list[dict]: table = hotel_raster_table_name(prefix, typ_hotel) if not table: return [] with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT * FROM "{table}" WHERE id_kas = ? AND (id_hotel = ? OR id_hotel = 0) ORDER BY CASE WHEN id_hotel = ? THEN 0 ELSE 1 END, id ''', (id_kas, reception_id, reception_id), ) columns = [desc[0] for desc in cur.description] return [ dict(zip(columns, row)) for row in cur.fetchall() ] def load_mews_tax_map(prefix: str, reception_id: int) -> dict[float, str]: table = f"{prefix}_mewsdph" with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT koefdph, mews_taxrate FROM "{table}" WHERE id_hotel = ? ''', (reception_id,), ) result = {} for koefdph, tax_rate in cur.fetchall(): key = round(_float_value(koefdph, 0.0), 4) value = _strip_value(tax_rate) if key and value: result[key] = value return result def mews_tax_code_for_rate(tax_map: dict[float, str], rate_value: str) -> str: if not tax_map: return "" rate = round(_float_value(rate_value, 0.0), 4) if rate in tax_map: return tax_map[rate] for key, value in tax_map.items(): if abs(key - rate) < 0.0001: return value return "" def raster_row_score(row: dict, c_druh: int, tmatr: str, budova: str) -> int | None: row_c_druh = _int_value(row.get("c_druh"), 0) if row_c_druh not in {c_druh, -100}: return None row_tmatr = _strip_value(row.get("tmatr")) row_budova = _strip_value(row.get("budova")) tmatr = _strip_value(tmatr) budova = _strip_value(budova) if row_tmatr and row_tmatr != tmatr: return None if row_budova and row_budova != budova: return None score = 0 if row_c_druh == c_druh: score += 100 if row_c_druh == -100: score += 10 if row_tmatr and row_tmatr == tmatr: score += 8 elif not row_tmatr: score += 3 if row_budova and row_budova == budova: score += 4 elif not row_budova: score += 1 return score def select_raster_row(rows: list[dict], c_druh: int, tmatr: str, budova: str) -> dict | None: scored = [] for row in rows: score = raster_row_score(row, c_druh, tmatr, budova) if score is not None: scored.append((score, row)) if not scored: return None scored.sort(key=lambda item: item[0], reverse=True) return scored[0][1] def raster_value_from_row(row: dict, typ_hotel: int, price_level: str) -> str: idx = price_level_raster_index(price_level) if int(typ_hotel or 0) in {6, 10}: value = _strip_value(row.get("raster")) if value and value != "0": return value value = _strip_value(row.get(f"raster{idx}")) if value == "0": return "" return value def select_raster_id(rows: list[dict], c_druh: int, tmatr: str, budova: str, typ_hotel: int, price_level: str) -> str: scored = [] for row in rows: score = raster_row_score(row, c_druh, tmatr, budova) if score is not None: scored.append((score, row)) scored.sort(key=lambda item: item[0], reverse=True) for _, row in scored: if _int_value(row.get("c_druh"), 0) == -100: raster_id = raster_value_from_row(row, typ_hotel, "1") if not raster_id: raster_id = raster_value_from_row(row, typ_hotel, price_level) else: raster_id = raster_value_from_row(row, typ_hotel, price_level) if raster_id: return raster_id return "" def _hotel_charge_item_from_pol(pol, c_druh: int) -> data.HotelChargeItem: quantity = _float_value(getattr(pol, "pocet", 0), 0.0) / max(_int_value(getattr(pol, "delitel", 1), 1), 1) unit_price = _float_value(getattr(pol, "cena", 0), 0.0) amount = round(quantity * unit_price, 2) return data.HotelChargeItem( line_id=_strip_value(getattr(pol, "line_id", "")), id_card=_int_value(getattr(pol, "id_card", 0), 0), name=_strip_value(getattr(pol, "nazev", "")), c_druh=c_druh, price_level=_strip_value(getattr(pol, "cenhlad", "")), dph=_strip_value(getattr(pol, "dph", "")), quantity=quantity, unit_price=unit_price, amount=amount, ) def _add_hotel_charge_line( lines: list[data.HotelChargeLine], item: data.HotelChargeItem, raster_id: str, itemized: bool, ): if itemized: lines.append(data.HotelChargeLine( raster_id=raster_id, c_druh=item.c_druh, price_level=item.price_level, dph=item.dph, description=item.name, quantity=item.quantity, unit_price=item.unit_price, amount=item.amount, items=[item], )) return for line in lines: if line.raster_id == raster_id and line.dph == item.dph: line.quantity = 1 line.amount = round(line.amount + item.amount, 2) line.unit_price = line.amount line.items.append(item) if item.c_druh not in {line.c_druh, 0}: line.c_druh = 0 return lines.append(data.HotelChargeLine( raster_id=raster_id, c_druh=item.c_druh, price_level=item.price_level, dph=item.dph, description="", quantity=item.quantity, unit_price=item.amount, amount=item.amount, items=[item], )) def prepare_hotel_charge_db( prefix: str, ucet: data.Ucet, target: data.HotelChargeTarget | None = None, payment: data.Platba | None = None, ) -> data.HotelChargePreparation: target = target or getattr(ucet, "hotel_charge", None) if not target: return data.HotelChargePreparation( ready=False, id_kas=_strip_value(getattr(ucet, "id_kas", "")), receipt_number=_strip_value(getattr(ucet, "ucislo", "")), errors=["Ucet nema vybrany hotelovy ciel."], ) typ_hotel = int(getattr(target, "typ_hotel", 0) or 0) reception_id = int(getattr(target, "reception_id", 0) or 0) raster_type = hotel_raster_table_for_type(typ_hotel) if not raster_type: return data.HotelChargePreparation( ready=False, id_kas=_strip_value(getattr(ucet, "id_kas", "")), typ_hotel=typ_hotel, reception_id=reception_id, reception_name=_strip_value(getattr(target, "reception_name", "")), receipt_number=_strip_value(getattr(ucet, "ucislo", "")), target=target, errors=[f"Typ recepcie {typ_hotel} nema definovanu tabulku rastrov."], ) id_kas = _strip_value(getattr(ucet, "id_kas", "")) params = get_setup_param_values(prefix, id_kas) if id_kas else {} itemized = _bool_setup_value(params.get("rastr_hot")) tmatr = _strip_value(getattr(target, "time_attribute", "")) budova = _strip_value(getattr(target, "building", "")) c_druh_map = load_c_druh_map(prefix, id_kas) if id_kas else {} raster_rows = load_hotel_raster_rows(prefix, id_kas, typ_hotel, reception_id) mews_tax_map = load_mews_tax_map(prefix, reception_id) if typ_hotel == 21 else {} lines: list[data.HotelChargeLine] = [] warnings: list[str] = [] errors: list[str] = [] currency = "" for pol in getattr(ucet, "poloz", []) or []: item_c_druh = _int_value(getattr(pol, "c_druh", 0), 0) if not item_c_druh: item_c_druh = c_druh_map.get(_int_value(getattr(pol, "id_card", 0), 0), 0) item = _hotel_charge_item_from_pol(pol, item_c_druh) if abs(item.amount) < 0.005: continue if not currency: currency = _strip_value(getattr(pol, "mena", "")) if not item.c_druh: warnings.append(f"Polozka {item.name or item.id_card} nema nastavene c_druh.") raster_id = select_raster_id(raster_rows, item.c_druh, tmatr, budova, typ_hotel, item.price_level) if not raster_id: errors.append( f"Pre polozku {item.name or item.id_card} sa nenasiel platny raster " f"(c_druh={item.c_druh}, hotel={reception_id}, hladina={item.price_level or '1'})." ) continue _add_hotel_charge_line(lines, item, raster_id, itemized) tip_amount = round(_float_value(getattr(payment, "tip", 0), 0.0), 2) if payment else 0.0 if abs(tip_amount) >= 0.005: tip_item = data.HotelChargeItem( line_id="TIP", id_card=0, name="TIP", c_druh=-1, price_level="1", dph="0", quantity=1, unit_price=tip_amount, amount=tip_amount, ) raster_id = select_raster_id(raster_rows, -1, tmatr, budova, typ_hotel, tip_item.price_level) if not raster_id: errors.append( f"Pre TIP sa nenasiel platny raster (c_druh=-1, hotel={reception_id})." ) else: _add_hotel_charge_line(lines, tip_item, raster_id, itemized) for line in lines: line.quantity = round(line.quantity, 3) line.amount = round(line.amount, 2) if line.quantity: line.unit_price = round(line.amount / line.quantity, 2) else: line.unit_price = line.amount total = round(sum(line.amount for line in lines), 2) logger.info( "HOTEL charge prepared: prefix=%s id_kas=%s typ=%s reception=%s " "receipt=%s target_room=%s target_guest=%s raster_rows=%s " "lines=%s total=%s rasters=%s warnings=%s errors=%s", prefix, id_kas, typ_hotel, reception_id, _strip_value(getattr(ucet, "ucislo", "")), _strip_value(getattr(target, "room_code", "")), _strip_value(getattr(target, "guest_name", "")), len(raster_rows), len(lines), total, [ { "raster": line.raster_id, "amount": line.amount, "dph": line.dph, "description": line.description, } for line in lines[:20] ], "; ".join(warnings), "; ".join(errors), ) return data.HotelChargePreparation( ready=bool(lines) and not errors, id_kas=id_kas, typ_hotel=typ_hotel, reception_id=reception_id, reception_name=_strip_value(getattr(target, "reception_name", "")), raster_table=raster_type, receipt_number=_strip_value(getattr(ucet, "ucislo", "")), currency=currency, target=target, lines=lines, total=total, warnings=warnings, errors=errors, ) def payment_hotel_charge_amount(ucet: data.Ucet, payment: data.Platba) -> float: amount = round(_float_value(getattr(payment, "suma_czk", 0), 0.0), 2) if not amount: amount = round(_float_value(getattr(payment, "suma", 0), 0.0), 2) return round(amount + _float_value(getattr(payment, "tip", 0), 0.0), 2) def scale_hotel_charge_preparation( preparation: data.HotelChargePreparation, target_amount: float, ) -> data.HotelChargePreparation: target_amount = round(_float_value(target_amount, 0.0), 2) if not preparation.ready or not preparation.lines or not target_amount: return preparation source_total = round(_float_value(preparation.total, 0.0), 2) if not source_total or abs(source_total - target_amount) < 0.01: return preparation result = preparation.model_copy(deep=True) ratio = target_amount / source_total running = 0.0 for idx, line in enumerate(result.lines): if idx == len(result.lines) - 1: amount = round(target_amount - running, 2) else: amount = round(line.amount * ratio, 2) running = round(running + amount, 2) line.amount = amount if line.quantity: line.unit_price = round(amount / line.quantity, 2) else: line.unit_price = amount line.items = [] result.total = round(sum(line.amount for line in result.lines), 2) return result def strip_transient_hotel_charge_data(ucet: data.Ucet) -> None: ucet.hotel_charge = None ucet.hotel_charge_preparation = None ucet.hotel_charge_send_result = None def ensure_hotel_charge_payment_targets(ucet: data.Ucet) -> None: account_target = getattr(ucet, "hotel_charge", None) payments = getattr(ucet, "platby", []) or [] if not account_target: return if not payments: raise HTTPException(422, "Hotelovy ucet nema ziadnu platbu.") if any(getattr(payment, "hotel_charge", None) for payment in payments): return logger.info("HOTEL charge target moved from account to first payment before finalize.") try: payments[0].hotel_charge = account_target except Exception: object.__setattr__(payments[0], "hotel_charge", account_target) @app.get("/hotel/receptions/", response_model=list[data.HotelReception]) def get_hotel_receptions( auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET hotel receptions: prefix={prefix} user={user}") return [ hotel_service.reception_public(reception) for reception in load_receptions_from_db(prefix) ] @app.get("/hotel/rooms/", response_model=data.HotelRoomsResponse) def get_hotel_rooms( reception_id: int, id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET hotel rooms: prefix={prefix} user={user}") reception = load_reception_from_db(prefix, reception_id) public = hotel_service.reception_public(reception) manual_room = hotel_service.manual_room_required(public.typ_hotel) if manual_room: return data.HotelRoomsResponse( reception=public, manual_room=True, rooms=[], message="Recepcny system vyzaduje manualne zadanie izby.", ) try: rooms = hotel_service.load_rooms( reception, get_setup_param_values(prefix, id_kas), ) except hotel_service.HotelServiceError as e: raise HTTPException(502, str(e)) return data.HotelRoomsResponse( reception=public, manual_room=False, rooms=rooms, ) @app.get("/hotel/guests/", response_model=list[data.HotelGuest]) def get_hotel_guests( reception_id: int, id_kas: str, room_id: str = "", room_code: str = "", account_id: str = "", auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth reception = load_reception_from_db(prefix, reception_id) ensure_postgres_for_reception(prefix, id_kas, reception) params = get_setup_param_values(prefix, id_kas) if int(getattr(reception, "typ_hotel", 0) or 0) == 6: try: return fidelio_db_service.load_guests( get_postgres_connection_db(prefix, include_password=True), id_kas=id_kas, params=params, room_code=room_code, ) except (fidelio_db_service.FidelioDbError, postgres_service.PostgresServiceError) as e: raise HTTPException(502, str(e)) try: return hotel_service.load_guests( reception, params, room_id=room_id, room_code=room_code, account_id=account_id, ) except hotel_service.HotelServiceError as e: raise HTTPException(502, str(e)) @app.post("/hotel/card/", response_model=data.HotelCardResult) def check_hotel_card( request: data.HotelCardRequest, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth reception = load_reception_from_db(prefix, request.reception_id) ensure_postgres_for_reception(prefix, request.id_kas, reception) params = get_setup_param_values(prefix, request.id_kas) if int(getattr(reception, "typ_hotel", 0) or 0) == 6: try: return fidelio_db_service.check_card( get_postgres_connection_db(prefix, include_password=True), id_kas=request.id_kas, params=params, card_code=request.card_code, ) except (fidelio_db_service.FidelioDbError, postgres_service.PostgresServiceError) as e: raise HTTPException(502, str(e)) try: return hotel_service.check_card( reception, params, request.card_code, ) except hotel_service.HotelServiceError as e: raise HTTPException(502, str(e)) @app.post("/hotel/charge/prepare/", response_model=data.HotelChargePreparation) def prepare_hotel_charge( ucet: data.Ucet, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( f"HOTEL charge prepare: prefix={prefix} user={user} " f"ucet={getattr(ucet, 'ucislo', '')}" ) return prepare_hotel_charge_db(prefix, ucet) def ucet_save_response(payload: dict, ucet: data.Ucet) -> dict: return payload def send_hotel_charge_db( prefix: str, ucet: data.Ucet, preparation: data.HotelChargePreparation | None = None, ) -> data.HotelChargeSendResult: preparation = preparation or prepare_hotel_charge_db(prefix, ucet) if not preparation.ready: message = "; ".join(preparation.errors or []) or "Hotelovy ucet nie je pripraveny." return data.HotelChargeSendResult( ok=False, message=message, preparation=preparation, ) id_kas = _strip_value(getattr(ucet, "id_kas", "")) reception = load_reception_from_db(prefix, preparation.reception_id) if int(preparation.typ_hotel or 0) == 6: ensure_postgres_for_reception(prefix, id_kas, reception) try: result = fidelio_db_service.charge_account( get_postgres_connection_db(prefix, include_password=True), id_kas=id_kas, preparation=preparation, ) except (fidelio_db_service.FidelioDbError, postgres_service.PostgresServiceError) as e: return data.HotelChargeSendResult( ok=False, message=str(e), preparation=preparation, ) else: try: result = hotel_service.charge_account( reception, get_setup_param_values(prefix, id_kas), preparation, ) except hotel_service.HotelServiceError as e: return data.HotelChargeSendResult( ok=False, message=str(e), preparation=preparation, ) return data.HotelChargeSendResult( ok=bool(result.get("ok")), message=str(result.get("message") or "OK"), request_number=result.get("request_number"), preparation=preparation, ) def finalize_hotel_charge_on_close(prefix: str, ucet: data.Ucet) -> None: if not getattr(ucet, "closed_at", None): strip_transient_hotel_charge_data(ucet) return payment_debug = [ { "code": getattr(payment, "code", ""), "name": getattr(payment, "nazev", ""), "amount": payment_hotel_charge_amount(ucet, payment), "has_hotel_charge": bool(getattr(payment, "hotel_charge", None)), } for payment in (getattr(ucet, "platby", []) or []) ] logger.info( "HOTEL charge finalize: prefix=%s receipt=%s closed_at=%s payments=%s account_target=%s", prefix, _strip_value(getattr(ucet, "ucislo", "")), _strip_value(getattr(ucet, "closed_at", "")), payment_debug, bool(getattr(ucet, "hotel_charge", None)), ) hotel_payment_targets = [ (payment, getattr(payment, "hotel_charge", None)) for payment in (getattr(ucet, "platby", []) or []) if getattr(payment, "hotel_charge", None) ] if not hotel_payment_targets and getattr(ucet, "hotel_charge", None) and getattr(ucet, "platby", None): logger.info("HOTEL charge finalize: using account-level transient target for first payment.") hotel_payment_targets = [(ucet.platby[0], ucet.hotel_charge)] if not hotel_payment_targets: logger.info("HOTEL charge finalize: no hotel payments.") strip_transient_hotel_charge_data(ucet) return logger.info("HOTEL charge finalize: selected hotel payments=%s", len(hotel_payment_targets)) for payment, target in hotel_payment_targets: if target and not getattr(payment, "hotel_charge", None): try: payment.hotel_charge = target except Exception: object.__setattr__(payment, "hotel_charge", target) try: logger.info( "HOTEL charge prepare start: prefix=%s receipt=%s payment=%s amount=%s target=%s", prefix, _strip_value(getattr(ucet, "ucislo", "")), getattr(payment, "nazev", ""), payment_hotel_charge_amount(ucet, payment), target.model_dump(mode="json") if hasattr(target, "model_dump") else target, ) preparation = prepare_hotel_charge_db(prefix, ucet, target=target, payment=payment) preparation = scale_hotel_charge_preparation( preparation, payment_hotel_charge_amount(ucet, payment), ) except HTTPException: raise except Exception as e: logger.exception( "HOTEL charge prepare failed: prefix=%s receipt=%s payment=%s target=%s", prefix, _strip_value(getattr(ucet, "ucislo", "")), getattr(payment, "nazev", ""), target.model_dump(mode="json") if hasattr(target, "model_dump") else target, ) raise HTTPException(502, f"Priprava hoteloveho uctu zlyhala: {e}") from e logger.info( "HOTEL charge close: prefix=%s id_kas=%s typ=%s reception=%s " "receipt=%s payment=%s amount=%s ready=%s lines=%s total=%s errors=%s target=%s", prefix, _strip_value(getattr(ucet, "id_kas", "")), preparation.typ_hotel, preparation.reception_id, preparation.receipt_number, getattr(payment, "nazev", ""), payment_hotel_charge_amount(ucet, payment), preparation.ready, len(preparation.lines or []), preparation.total, "; ".join(preparation.errors or []), preparation.target.model_dump(mode="json") if preparation.target else None, ) logger.info(f"HOTEL charge send request: {preparation}") try: result = send_hotel_charge_db(prefix, ucet, preparation=preparation) except HTTPException: raise except Exception as e: logger.exception( "HOTEL charge send failed: prefix=%s receipt=%s payment=%s preparation=%s", prefix, _strip_value(getattr(ucet, "ucislo", "")), getattr(payment, "nazev", ""), preparation.model_dump(mode="json") if hasattr(preparation, "model_dump") else preparation, ) raise HTTPException(502, f"Odoslanie hoteloveho uctu zlyhalo: {e}") from e logger.info( "HOTEL charge send result: prefix=%s receipt=%s payment=%s ok=%s message=%s request=%s", prefix, _strip_value(getattr(ucet, "ucislo", "")), getattr(payment, "nazev", ""), result.ok, result.message, result.request_number, ) if not result.ok: raise HTTPException(502, result.message or "Hotelovy ucet sa nepodarilo odoslat do recepcie.") strip_transient_hotel_charge_data(ucet) @app.post("/hotel/charge/send/", response_model=data.HotelChargeSendResult) def send_hotel_charge( ucet: data.Ucet, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info( f"HOTEL charge send: prefix={prefix} user={user} " f"ucet={getattr(ucet, 'ucislo', '')}" ) return send_hotel_charge_db(prefix, ucet) @app.post("/uveryall/") def update_uvery( ucp: list[data.Uvery], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_uvery_db(prefix, ucp) def update_uvery_db(prefix: str, ucp: list[data.Uvery]): table = f"{prefix}_uvery" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'SELECT hjmeno FROM "{table}"') db_ids = {row[0] for row in cur.fetchall()} # 🔹 2. incoming IDs incoming_ids = {(ucp1.hjmeno) for ucp1 in ucp} # 🔹 3. DELETE (čo už nie je v requeste) to_delete = db_ids - incoming_ids if to_delete: cur.executemany( f'DELETE FROM "{table}" WHERE hjmeno=?', list(to_delete) ) # 🔹 4. INSERT / UPDATE for item in ucp: cur.execute(f""" INSERT INTO "{table}" (hjmeno, adresa1, adresa2, adresa3, ico, dic, icdph) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(hjmeno) DO UPDATE SET adresa1 = excluded.adresa1, adresa2=excluded.adresa2, adresa3=excluded.adresa3, ico=excluded.ico, dic=excluded.dic, icdph=excluded.icdph """, (item.hjmeno, item.adresa1, item.adresa2, item.adresa3, item.ico, item.dic, item.icdph)) conn.commit() return {"ok": True} @app.post("/mewsdph/") def update_mewsdph( ucp: list[data.MewsDph], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_mewsdph_db(prefix, ucp) def update_mewsdph_db(prefix: str, ucp: list[data.MewsDph]): table = f"{prefix}_mewsdph" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') rows = [] for item in ucp: rows.append( ( item.id, item.id_hotel, item.mews_taxrate, item.koefdph ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id, id_hotel, mews_taxrate, koefdph) VALUES (?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(rows)} @app.post("/hotplatby/") def update_hotplatby( ucp: list[data.HotPlatby], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_hotplatby_db(prefix, ucp) def update_hotplatby_db(prefix: str, ucp: list[data.HotPlatby]): table = f"{prefix}_hotplatby" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') rows = [] for item in ucp: rows.append( ( item.id_hotel, item.druh_pl, item.hot_platba_id, item.hot_karta_id, item.hot_platba, item.hot_karta, item.po_uctoch, item.payment, item.id_meny ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id_hotel, druh_pl, hot_platba_id, hot_karta_id, hot_platba, hot_karta, po_uctoch, payment, id_meny) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(rows)} @app.get("/uvery/", response_model=list[data.UverFirma]) def get_uvery( q: str = "", limit: int = 2000, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET uvery: prefix={prefix} q={q!r}") #return load_uvery_from_db(prefix, q=q, limit=limit) return JSONResponse( content=[item.model_dump() for item in load_uvery_from_db(prefix, q=q, limit=limit)], media_type="application/json; charset=utf-8", ) def load_uvery_from_db(prefix: str, q: str = "", limit: int = 2000) -> list[data.UverFirma]: table = f"{prefix}_uvery" limit = max(1, min(int(limit or 2000), 10000)) terms = [t.strip().lower() for t in str(q or "").split() if t.strip()] where = "" params: list = [] if terms: searchable = ( "lower(coalesce(hjmeno,'') || ' ' || coalesce(adresa1,'') || ' ' || " "coalesce(adresa2,'') || ' ' || coalesce(adresa3,'') || ' ' || " "coalesce(ico,'') || ' ' || coalesce(icdph,'') || ' ' || coalesce(dic,''))" ) where = "WHERE " + " AND ".join([f"{searchable} LIKE ?" for _ in terms]) params.extend([f"%{term}%" for term in terms]) params.append(limit) with get_db() as conn: cur = conn.cursor() cur.execute( f""" SELECT id, hjmeno, adresa1, adresa2, adresa3, ico, icdph, dic FROM "{table}" {where} ORDER BY hjmeno COLLATE NOCASE LIMIT ? """, params, ) rows = cur.fetchall() return [ data.UverFirma( id=row[0], hjmeno=row[1], adresa1=row[2], adresa2=row[3], adresa3=row[4], ico=row[5], icdph=row[6], dic=row[7], ) for row in rows ] @app.post("/uvery/", response_model=data.UverFirma) def save_uver( firma: data.UverFirma, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"POST uver: prefix={prefix} firma={firma.hjmeno!r}") return save_uver_to_db(prefix, firma) def save_uver_to_db(prefix: str, firma: data.UverFirma) -> data.UverFirma: if not firma.hjmeno.strip(): raise HTTPException(422, "Meno firmy je povinne") table = f"{prefix}_uvery" with get_db() as conn: cur = conn.cursor() row_id = firma.id if row_id is None: cur.execute( f'SELECT id FROM "{table}" WHERE hjmeno = ? COLLATE NOCASE', (firma.hjmeno,), ) row = cur.fetchone() row_id = row[0] if row else None if row_id is None: cur.execute( f""" INSERT INTO "{table}" (hjmeno, adresa1, adresa2, adresa3, ico, icdph, dic) VALUES (?, ?, ?, ?, ?, ?, ?) """, (firma.hjmeno, firma.adresa1, firma.adresa2, firma.adresa3, firma.ico, firma.icdph, firma.dic), ) row_id = cur.lastrowid else: cur.execute( f""" UPDATE "{table}" SET hjmeno=?, adresa1=?, adresa2=?, adresa3=?, ico=?, icdph=?, dic=? WHERE id=? """, (firma.hjmeno, firma.adresa1, firma.adresa2, firma.adresa3, firma.ico, firma.icdph, firma.dic, row_id), ) conn.commit() return data.UverFirma( id=row_id, hjmeno=firma.hjmeno, adresa1=firma.adresa1, adresa2=firma.adresa2, adresa3=firma.adresa3, ico=firma.ico, icdph=firma.icdph, dic=firma.dic, ) @app.post("/hotrastre/") def update_hotrastre( ucp: list[data.HotRastre], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_hotrastre_db(prefix, ucp) def update_hotrastre_db(prefix: str, ucp: list[data.HotRastre]): table = f"{prefix}_hotrastre" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') rows = [] for item in ucp: rows.append( ( item.id, item.id_kas, item.id_hotel, item.c_druh, item.raster1, item.raster2, item.raster3, item.raster4, item.raster5, item.raster6, item.raster7, item.raster8, item.raster9, item.dph1, item.dph2, item.dph3, item.dph4, item.dph5, item.dph6, item.dph7, item.dph8, item.dph9, item.tmatr, item.budova, ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id, id_kas, id_hotel, c_druh, raster1, raster2, raster3, raster4, raster5, raster6, raster7, raster8, raster9, dph1, dph2, dph3, dph4, dph5, dph6, dph7, dph8, dph9, tmatr, budova) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(rows)} @app.post("/mewsrastre/") def update_mewsrastre( ucp: list[data.MewsRastre], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_mewsrastre_db(prefix, ucp) def update_mewsrastre_db(prefix: str, ucp: list[data.MewsRastre]): table = f"{prefix}_mewsrastre" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') rows = [] for item in ucp: rows.append( ( item.id, item.id_kas, item.id_hotel, item.c_druh, item.raster1, item.raster2, item.raster3, item.raster4, item.raster5, item.raster6, item.raster7, item.raster8, item.raster9, item.dph1, item.dph2, item.dph3, item.dph4, item.dph5, item.dph6, item.dph7, item.dph8, item.dph9, item.tmatr, item.budova, ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id, id_kas, id_hotel, c_druh, raster1, raster2, raster3, raster4, raster5, raster6, raster7, raster8, raster9, dph1, dph2, dph3, dph4, dph5, dph6, dph7, dph8, dph9, tmatr, budova) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(rows)} @app.post("/fidrastre/") def update_fidrastre( ucp: list[data.FidRastre], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return update_fidrastre_db(prefix, ucp) def update_fidrastre_db(prefix: str, ucp: list[data.FidRastre]): table = f"{prefix}_fidrastre" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') rows = [] for item in ucp: rows.append( ( item.id, item.id_kas, item.id_hotel, item.c_druh, item.raster, item.raster1, item.raster2, item.raster3, item.raster4, item.raster5, item.raster6, item.raster7, item.raster8, item.raster9, item.dph1, item.dph2, item.dph3, item.dph4, item.dph5, item.dph6, item.dph7, item.dph8, item.dph9, item.tmatr, item.budova, ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id, id_kas, id_hotel, c_druh, raster, raster1, raster2, raster3, raster4, raster5, raster6, raster7, raster8, raster9, dph1, dph2, dph3, dph4, dph5, dph6, dph7, dph8, dph9, tmatr, budova) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(rows)} @app.get("/zlavy/{id_kas}", response_model=list[data.Zlava]) def get_zlavy_for_kasa( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth logger.info(f"GET zlavy: prefix={prefix} pokladna={id_kas}") return zlavy_load_for_kasa(prefix, id_kas) def zlavy_load_for_kasa( prefix: str, id_kas: str, ) -> list[data.Zlava]: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_zlavy" where = ["id_kas = ?"] params: list = [id_kas] sql = f''' SELECT id, data FROM "{table}" WHERE {" AND ".join(where)} ORDER BY meno, id_zlavy_hlav ''' with get_db() as conn: cur = conn.cursor() cur.execute(sql, params) rows = cur.fetchall() result = [] for rowid, raw_json in rows: obj, changed = model_from_json_migrated(data.Zlava, raw_json) if changed: payload = obj.model_dump(mode="json") cur.execute( f''' UPDATE "{table}" SET meno=?, data=? WHERE id=? ''', ( obj.meno, json.dumps(payload, ensure_ascii=False, separators=(",", ":")), rowid, ), ) result.append(obj) conn.commit() return result @app.post("/zlavy/{id_kas}") def replace_zlavy_for_kasa( id_kas: str, zlavy: list[data.Zlava], auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth logger.info(f"POST zlavy: prefix={prefix} pokladna={id_kas} count={len(zlavy)}") return zlavy_replace_for_kasa(prefix, id_kas, zlavy) def zlavy_replace_for_kasa(prefix: str, id_kas: str, zlavy: list[data.Zlava]) -> dict: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_zlavy" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}" WHERE id_kas=?', (id_kas,)) rows = [] for item in zlavy: payload = item.model_dump(mode="json") raw_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) rows.append( ( id_kas, int(item.idriadok), item.meno, raw_json, ) ) if rows: cur.executemany( f''' INSERT INTO "{table}" (id_kas, id_zlavy_hlav, meno, data) VALUES (?, ?, ?, ?) ''', rows, ) conn.commit() return {"ok": True, "count": len(zlavy)} @app.delete("/zlavy/{id_kas}", status_code=204) def delete_zlavy_for_kasa( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth zlavy_delete_for_kasa(prefix, id_kas) def zlavy_delete_for_kasa(prefix: str, id_kas: str) -> bool: if len(id_kas.strip()) != 2: raise HTTPException(422, "Invalid id_kas") table = f"{prefix}_zlavy" with get_db() as conn: cur = conn.cursor() cur.execute(f'DELETE FROM "{table}" WHERE id_kas=?', (id_kas,)) deleted = cur.rowcount conn.commit() return deleted > 0 # ----------------------------------------------------- # ---Mapa stolu @app.get("/mapa_stolu/", response_model=data.MapaStolu) def get_mapa_stolu( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"GET mapa_stolu: prefix={prefix} pokladna={id_kas}") return get_mapa_stolu_from_db(prefix, id_kas) def get_mapa_stolu_from_db(cur_pref: str, id_kas: str) -> data.MapaStolu: table = f"{cur_pref}_mapa_stolu" with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT data FROM "{table}" WHERE EXISTS ( SELECT 1 FROM json_each(data, '$.pokladny') WHERE value = ? ) ORDER BY id DESC LIMIT 1 ''', (id_kas,), ) row = cur.fetchone() if not row: # fallback → vezmi poslední mapu (nebo seed) cur.execute( f''' SELECT data FROM "{table}" ORDER BY id DESC LIMIT 1 ''' ) row = cur.fetchone() if not row: raise HTTPException(404, f"Mapa stolu nenalezena") raw_json = row[0] # validace přes pydantic mapa = data.MapaStolu.model_validate_json(raw_json) return mapa @app.post("/mapa_stolu/") def save_mapa_stolu( mapa: data.MapaStolu, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f"POST mapa_stolu: prefix={prefix} pokladny={mapa.pokladny}") save_mapa_to_db(prefix, mapa) return {"ok": True} def save_mapa_to_db(cur_pref: str, mapa: data.MapaStolu): table = f"{cur_pref}_mapa_stolu" raw_json = json.dumps( mapa.model_dump(), ensure_ascii=False, separators=(",", ":"), sort_keys=True, ) with get_db() as conn: cur = conn.cursor() # smaž všechny mapy, které obsahují některou pokladnu for kas in mapa.pokladny: cur.execute( f''' DELETE FROM "{table}" WHERE EXISTS ( SELECT 1 FROM json_each(data, '$.pokladny') WHERE value = ? ) ''', (kas,), ) # vlož novou mapu cur.execute( f''' INSERT INTO "{table}" (data) VALUES (?) ''', (raw_json,), ) # ----------------------------------------------------- # ---OPERACE S UCTY class UcetBlockedError(Exception): pass class UcetNotFoundError(Exception): pass # ----------- def time_hhmmss() -> str: from datetime import datetime return datetime.now().strftime("%H:%M:%S") def is_block_expired(cur, prefix: str, id_kas: str, blocked_by: str) -> bool: #blocked_by = "|" try: client_id, _ = blocked_by.split("|", 1) except ValueError: return True row = cur.execute( """ SELECT last_seen FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND client_id=? """, (prefix, id_kas, client_id), ).fetchone() if not row: return True try: last_seen = float(row[0]) except (TypeError, ValueError): return True return (time.time() - last_seen) > BLOCK_EXPIRATION # ---najde otevreny ucet stolu------------------------- def find_open_ucet_by_stul(cur, table: str, stul: str, id_kas: str): """ Najde otevřený účet ke stolu. Vrací: (ucty_id, ucislo, blocked_by, data_json) nebo None, pokud neexistuje. """ cur.execute(f""" SELECT ucty_id, ucislo, blocked_by, data FROM "{table}" WHERE stul = ? AND id_kas = ? AND (closed_at IS NULL OR TRIM(closed_at) = '') LIMIT 1 """, (stul,id_kas)) return cur.fetchone() # ---generator cisla uctu def generate_ucislo(cur, table: str, id_kas: str) -> str: """ Vygeneruje nové číslo účtu ve tvaru KK000001 - KK = id_kas (2 znaky) - sekvence z EXISTUJÍCÍCH účtů pro danou pokladnu """ if not id_kas or len(id_kas) != 2: raise ValueError("id_kas must be 2 characters") cur.execute(f""" SELECT MAX(CAST(SUBSTR(ucislo, 3) AS INTEGER)) FROM "{table}" WHERE id_kas = ? AND ucislo IS NOT NULL """, (id_kas,)) max_num = cur.fetchone()[0] or 0 return data.next_ucislo(f"{id_kas}{max_num:06d}") # ---test je-li ucet blokovan @app.get("/ucet/is_blocked/") def is_blocked( stul: str = Query(...), id_kas:str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, user, client_id = auth cleanup_dead_clients( prefix, id_kas) return load_ucet_block_state( cur_pref=prefix, stul=stul, id_kas=id_kas) def load_ucet_block_state( cur_pref: str, stul: str, id_kas: str,) -> dict: table = f"{cur_pref}_ucty" sql = f""" SELECT blocked_by FROM "{table}" WHERE stul = ? AND id_kas = ? AND (closed_at IS NULL OR TRIM(closed_at) = '') """ with get_db() as conn: cur = conn.cursor() cur.execute(sql, (stul, id_kas,)) row = cur.fetchone() if not row: return { "exists": False, "blocked": False, "blocked_by": "", } blocked_by = row[0] blocked = bool(blocked_by and blocked_by.strip()) return { "exists": True, "blocked": blocked, "blocked_by": blocked_by or "", } # ---upsert operace endpoint @app.post("/ucet/") def upsert_ucet( uct: data.Ucet, block: bool = Query(False), auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, user, client_id = auth cleanup_dead_clients( prefix, uct.id_kas) logger.info(f'Upsert ucet') if not uct.id_kas: raise HTTPException(422, "id_kas must be set in Ucet") if uct.closed_at is None: if not uct.stul: raise HTTPException(422, "Stul must be set for open ucet") return upsert_ucet_db(prefix, uct, client_id, block) def insert_storno_ucet(cur_pref: str, uct: data.Ucet, client_id: str): table = f"{cur_pref}_ucty" now = time_hhmmss() with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) # storno účet NESMÍ mít stůl, to teda uplne nevim proc # uct.stul = None # nové číslo účtu uct.ucislo = generate_ucislo(cur, table, uct.id_kas) uct.closed_at = uct.closed_at or now #uct.open_at = None uct.blocked_by = "" ensure_ucet_room_name(cur_pref, uct) ensure_hotel_charge_payment_targets(uct) uct.checksum_val = uct.checksum() finalize_hotel_charge_on_close(cur_pref, uct) payload = uct.model_dump_json() cur.execute(f""" INSERT INTO "{table}" (ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( uct.ucislo, uct.id_kas, uct.stul, uct.room_name, "", uct.closed_at, uct.c_uzaverka, payload, )) return ucet_save_response({ "operation": "insert-storno", "ucislo": uct.ucislo, }, uct) def upsert_ucet_db(cur_pref: str, uct: data.Ucet, client_id: str, block: bool): table = f"{cur_pref}_ucty" id_kas = uct.id_kas now = time_hhmmss() logger.info(f"Upsert_ucet_db") # --- STORNO = VŽDY INSERT --- if uct.is_storno and uct.origin !="StorPaymChg": return insert_storno_ucet(cur_pref, uct, client_id) # --- UPDATE EXISTUJÍCÍHO ÚČTU PODLE UCISLA (STORNO) --- if uct.ucislo: with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f""" SELECT ucty_id, blocked_by, c_uzaverka, data FROM "{table}" WHERE ucislo=? """, (uct.ucislo,)) row = cur.fetchone() if not row: raise HTTPException(404, "Ucet not found") ucty_id, blocked_by_db, c_uzaverka_db, data_db = row if c_uzaverka_db: uct.c_uzaverka = c_uzaverka_db if blocked_by_db: #Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" #Petr 8.5.2026 ^ if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={uct.stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {uct.stul} je blokován: {blocked_by_db}" ) # historický účet – povol jen update pole storno strip_transient_hotel_charge_data(uct) ensure_ucet_room_name(cur_pref, uct) payload = uct.model_dump_json() new_block = f"{client_id}|{now}" if block else "" cur.execute(f""" UPDATE "{table}" SET blocked_by=?, room_name=?, data=? WHERE ucty_id=? """, (new_block, uct.room_name, payload, ucty_id)) return ucet_save_response({ "operation": "update-storno", "ucislo": uct.ucislo, }, uct) with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) # najdi otevřený účet ke stolu cur.execute(f""" SELECT ucty_id, blocked_by, data FROM "{table}" WHERE stul=? AND id_kas=? AND (closed_at IS NULL OR TRIM(closed_at)='') """, (uct.stul,id_kas,)) row = cur.fetchone() # update if row: ucty_id, blocked_by_db, data_db = row if blocked_by_db: owner = blocked_by_db.split("|", 1)[0] #if owner != client_id: # raise HTTPException(409, f"Ucet blocked by {blocked_by_db}") if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={uct.stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {uct.stul} je blokován: {blocked_by_db}" ) if uct.closed_at: uct.ucislo = generate_ucislo(cur, table, uct.id_kas) ensure_hotel_charge_payment_targets(uct) uct.checksum_val = uct.checksum() finalize_hotel_charge_on_close(cur_pref, uct) else: uct.ucislo = None uct.checksum_val = "" ensure_ucet_room_name(cur_pref, uct) payload = uct.model_dump_json() new_block = f"{client_id}|{now}" if block else "" cur.execute(f""" UPDATE "{table}" SET data=?, ucislo=?, closed_at=?, blocked_by=?, c_uzaverka=?, room_name=? WHERE ucty_id=? """, (payload, uct.ucislo, uct.closed_at, new_block, uct.c_uzaverka, uct.room_name, ucty_id)) return ucet_save_response({ "operation": "update", "stul": uct.stul, "ucislo": uct.ucislo, "blocked": bool(new_block), }, uct) # create if uct.closed_at: if not uct.ucislo: #pri oprave uzavreneho uctu uct.ucislo = generate_ucislo(cur, table, uct.id_kas) ensure_hotel_charge_payment_targets(uct) uct.checksum_val = uct.checksum() finalize_hotel_charge_on_close(cur_pref, uct) else: uct.ucislo = None uct.checksum_val = "" uct.open_at = now ensure_ucet_room_name(cur_pref, uct) payload = uct.model_dump_json() new_block = f"{client_id}|{now}" if block else "" cur.execute(f""" INSERT INTO "{table}" (ucislo, id_kas, stul, room_name, blocked_by, closed_at, c_uzaverka, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (uct.ucislo, uct.id_kas, uct.stul, uct.room_name, new_block, uct.closed_at, uct.c_uzaverka, payload)) return ucet_save_response({ "operation": "create", "stul": uct.stul, "ucislo": uct.ucislo, "blocked": bool(new_block), }, uct) # ---pripoji ucet k existujicimu @app.post("/ucet/merge/") def merge_ucet( req: data.MergeUcetRequest, auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, user, client_id = auth if not (id_kas := req.ucet.id_kas): raise HTTPException(422, "id_kas in object Ucet must be set") cleanup_dead_clients(prefix, id_kas) if not req.target_stul: raise HTTPException(422, "target_stul must be set") return merge_ucet_db( cur_pref=prefix, id_kas=id_kas, source=req.ucet, target_stul=req.target_stul, client_id=client_id, ) def merge_polozky(target: list, incoming: list): """ Přidá položky z incoming do target. NESLUČUJE, pouze append. """ if not incoming: return target.extend(incoming) def merge_ucet_db( cur_pref: str, id_kas: str, source: data.Ucet, target_stul: str, client_id: str, ) -> dict: """ Atomicky: - sloučí položky do otevřeného účtu na target_stul - NEBO vytvoří nový účet, pokud neexistuje """ table = f"{cur_pref}_ucty" now = time_hhmmss() with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) # --- 1️⃣ pokus najít otevřený účet --- cur.execute(f""" SELECT ucty_id, blocked_by, data FROM "{table}" WHERE stul = ? AND id_kas = ? AND (closed_at IS NULL OR TRIM(closed_at) = '') """, (target_stul, id_kas,)) row = cur.fetchone() # účet EXISTUJE → MERGE if row: ucty_id, blocked_by_db, data_db = row # --- kontrola blokace --- if blocked_by_db: # Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" # Petr 8.5. ^ #if owner != client_id: # raise HTTPException( # 409, # f"Cílový účet je blokován jiným terminálem: {blocked_by_db}" # ) if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={target_stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {target_stul} je blokován: {blocked_by_db}" ) # --- načti cílový účet --- if not data_db: raise HTTPException(500, "Cílový účet nemá data") if isinstance(data_db, str): target_payload = json.loads(data_db) elif isinstance(data_db, dict): target_payload = data_db else: raise HTTPException(500, f"Invalid data type: {type(data_db)}") target_ucet = data.Ucet(**target_payload) # --- MERGE POLOŽEK --- merge_polozky( target_ucet.poloz or [], source.poloz or [], ) target_ucet.guests = source.guests target_ucet.courses = source.courses ensure_ucet_room_name(cur_pref, target_ucet) payload = target_ucet.model_dump_json() new_block = f"{client_id}|{now}" cur.execute(f""" UPDATE "{table}" SET data = ?, room_name = ?, blocked_by = ? WHERE ucty_id = ? """, (payload, target_ucet.room_name, new_block, ucty_id)) return { "operation": "merge", "target_stul": target_stul, "created": False, "merged_items": len(source.poloz or []), } # VARIANTA B: účet NEEXISTUJE → CREATE new_ucet = source.model_copy(deep=True) new_ucet.stul = target_stul new_ucet.open_at = now new_ucet.ucislo = None new_ucet.checksum_val = "" ensure_ucet_room_name(cur_pref, new_ucet) payload = new_ucet.model_dump_json() new_block = f"{client_id}|{now}" cur.execute(f""" INSERT INTO "{table}" (ucislo, id_kas, stul, room_name, blocked_by, closed_at, data) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( None, new_ucet.id_kas, target_stul, new_ucet.room_name, new_block, None, payload, )) return { "operation": "create", "target_stul": target_stul, "created": True, "merged_items": len(source.poloz or []), } # ---nacte ucet ze serveru, bud dle stul (otevreny) nebo ucislo (uzavreny) @app.get("/ucet/") def get_ucet( id_kas: str = Query(...), ucislo: str | None = Query(None), stul: str | None = Query(None), block: bool = Query(True), # ✅ DEFAULTNĚ BLOKUJE auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, user, client_id = auth cleanup_dead_clients(prefix, id_kas) logger.info(f'Get ucet ') if (ucislo is None) == (stul is None): raise HTTPException( 422, "Zadej právě jeden parametr: ucislo NEBO stul" ) return get_ucet_db( cur_pref=prefix, id_kas=id_kas, ucislo=ucislo, stul=stul, block=block, client_id=client_id, ) def get_ucet_db( cur_pref: str, id_kas: str, ucislo: str | None, stul: str | None, block: bool, client_id: str, ) -> dict: table = f"{cur_pref}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) # --- 1️⃣ načtení účtu --- if ucislo is not None: cur.execute(f""" SELECT ucty_id, ucislo, blocked_by, c_uzaverka, room_name, data FROM "{table}" WHERE ucislo = ? """, (ucislo,)) else: cur.execute(f""" SELECT ucty_id, ucislo, blocked_by, c_uzaverka, room_name, data FROM "{table}" WHERE stul = ? AND id_kas = ? AND (closed_at IS NULL OR TRIM(closed_at) = '') """, (stul, id_kas)) row = cur.fetchone() if not row: raise HTTPException( 404, f"Účet nenalezen ({'ucislo' if ucislo else 'stul'})" ) ucty_id, ucislo_db, blocked_by_db, c_uzaverka_db, room_name_db, data_db = row if not data_db: raise HTTPException(500, "Ucet data is empty") if isinstance(data_db, str): #tohle dost blblo, radeji to tu necham payload = json.loads(data_db) payload, changed = migrate_ucet_payload(payload) if changed: cur.execute( f'UPDATE "{table}" SET data=? WHERE ucty_id=?', (json.dumps(payload), ucty_id) ) conn.commit() elif isinstance(data_db, dict): payload = data_db payload, changed = migrate_ucet_payload(payload) if changed: cur.execute( f'UPDATE "{table}" SET data=? WHERE ucty_id=?', (json.dumps(payload), ucty_id) ) conn.commit() else: raise HTTPException(500, f"Invalid data type: {type(data_db)}") try: ucet = data.Ucet(**payload) if not getattr(ucet, "room_name", ""): ucet.room_name = room_name_db or "" if not getattr(ucet, "room_name", ""): ensure_ucet_room_name(cur_pref, ucet) #ucet.c_uzaverka = c_uzaverka_db #logger.info(f"get_ucet_db ucet.c_uzaverka {ucet.c_uzaverka}") except ValidationError as e: raise HTTPException(500, "Invalid Ucet payload") # BLOKACE if block: if blocked_by_db: # Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" # Petr 8.5. ^ #if owner != client_id: # raise HTTPException( # 409, # f"Účet je blokován jiným terminálem: {blocked_by_db}" # ) if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}" ) # blokovaný mnou → OK ucet.blocked_by = blocked_by_db else: # zablokuj účet ucet.blocked_by = f"{client_id}|{time_hhmmss()}" cur.execute( f'UPDATE "{table}" SET blocked_by=? WHERE ucty_id=?', (ucet.blocked_by, ucty_id), ) else: # jen načtení bez blokace ucet.blocked_by = blocked_by_db or "" # Petr 11.5. ucet.id_kas = id_kas # Petr 11.5. return ucet.model_dump(mode="json") # ---vymaze ucet ze serveru dle stolu nebo cisla uctu bez ohledu na blokaci @app.delete("/ucet/") def delete_ucet( ucislo: str | None = Query(None), stul: str | None = Query(None), id_kas: str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, user, client_id = auth cleanup_dead_clients(prefix, id_kas) logger.info(f'Ucet delete {stul}') if (ucislo is None) == (stul is None): raise HTTPException( 422, "Zadej právě jeden parametr: ucislo NEBO stul" ) result = delete_ucet_db( cur_pref=prefix, ucislo=ucislo, stul=stul, id_kas = id_kas, client_id=client_id, ) return { "operation": "delete", **result, } def delete_ucet_db( cur_pref: str, ucislo: str | None, stul: str | None, id_kas: str, client_id: str,) -> dict: table = f"{cur_pref}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") # --- 1️⃣ najdi účet --- if ucislo is not None: cur.execute(f""" SELECT ucty_id, ucislo, blocked_by FROM "{table}" WHERE ucislo = ? """, (ucislo,)) else: cur.execute(f""" SELECT ucty_id, ucislo, blocked_by FROM "{table}" WHERE stul = ? AND id_kas=? AND (closed_at IS NULL OR TRIM(closed_at) = '') """, (stul,id_kas)) row = cur.fetchone() if not row: #print(f'stul {stul} id_kas {id_kas}') raise HTTPException( 404, f"Účet nenalezen ({'ucislo' if ucislo else 'stul'} )" ) ucty_id, ucislo_db, blocked_by_db = row # kontrola blokace --- if blocked_by_db: # Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" # Petr 8.5. ^ #if owner != client_id: # raise HTTPException( # 409, # f"Účet je blokován jiným terminálem: {blocked_by_db}" # ) if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}" ) # smazání --- cur.execute( f'DELETE FROM "{table}" WHERE ucty_id = ?', (ucty_id,) ) return { "ucislo": ucislo_db, "stul": stul, } # --- dump cisel uctu @app.get("/ucty/") def get_ucty( id_kas: str = Query(...), closed: bool = Query(False), onlynonclsrep: bool = Query(True), limit: int | None = Query(None, ge=1, le=2000), # 👈 nepovinný limit auth: tuple[str, str, str] = Depends(auth_ctx),): prefix, user, client_id = auth logger.info(f'Ucty dump') return { "ucty": load_ucty_for_select( cur_pref=prefix, id_kas=id_kas, closed=closed, onlynonclsrep=onlynonclsrep, limit=limit, )} def load_ucty_for_select( cur_pref: str, id_kas: str | None = None, closed: bool | None = None, onlynonclsrep: bool | None = None, limit: int | None = None,) -> list[dict]: table = f"{cur_pref}_ucty" out: list[dict] = [] where: list[str] = [] params: list = [] # --- filtr pokladny --- if id_kas is not None: where.append("u.id_kas = ?") params.append(id_kas) # --- pouze uzavrene ucty bez prirazene uzaverky --- if onlynonclsrep and closed is True and id_kas is not None: where.append("(u.c_uzaverka IS NULL OR u.c_uzaverka = 0)") # --- otevřené / uzavřené --- if closed is True: where.append("u.closed_at IS NOT NULL AND TRIM(u.closed_at) <> ''") elif closed is False: where.append("(u.closed_at IS NULL OR TRIM(u.closed_at) = '')") where_sql = f"WHERE {' AND '.join(where)}" if where else "" # --- řazení + limit --- if closed is True: # uzavřené účty – podle čísla účtu print("dle ucislo") order_sql = "ORDER BY u.ucislo DESC" if limit is None: limit = 50 limit_sql = "LIMIT ?" params.append(limit) else: # otevřené účty – podle stolu order_sql = """ ORDER BY CASE WHEN u.stul IS NULL THEN 1 ELSE 0 END, u.stul """ limit_sql = "" # --- SQL --- sql = f""" SELECT u.ucty_id, u.id_kas, u.c_uzaverka, u.ucislo, u.stul, u.room_name, u.blocked_by, u.closed_at, json_extract(u.data, '$.open_at') AS open_at, json_extract(u.data, '$.storno') AS storno, json_extract(u.data, '$.is_storno') AS is_storno, json_extract(u.data, '$.origin') AS origin, json_extract(u.data, '$.pohladavka') AS pohladavka, json_extract(u.data, '$.cash_operation') AS cash_operation, json_extract(u.data, '$.autor') AS autor, json_extract(u.data, '$.total_base_currency') AS total_base_currency, u.data AS raw_data FROM "{table}" u {where_sql} {order_sql} {limit_sql} """ with get_db() as conn: cur = conn.cursor() ensure_ucty_room_name_schema(cur_pref, cur) cur.execute(sql, params) rows = cur.fetchall() for ( ucty_id, id_kas, c_uzaverka, ucislo, stul, room_name, blocked_by, closed_at, open_at, storno, is_storno, origin, pohladavka, cash_operation, autor, total_base_currency, raw_data ) in rows: payments_text = "" status_text = "UCET" try: payload = json.loads(raw_data or "{}") payment_parts = [] for payment in payload.get("platby", []) or []: name = _strip_value(payment.get("nazev") or payment.get("code") or "") amount = float(payment.get("suma_czk", payment.get("suma", 0)) or 0) payment_parts.append(f"{name} {amount:.2f}".strip()) payments_text = ", ".join(payment_parts) if is_storno: status_text = "STORNO" elif storno: status_text = "STORNOVANY" elif cash_operation: status_text = "VKLAD" if str(cash_operation) == "manual_deposit" else "VYBER" else: item_status = _receipt_item_storno_status(payload.get("poloz", []) or []) if item_status: status_text = item_status except Exception: payments_text = "" status_text = "STORNO" if is_storno else ("STORNOVANY" if storno else "UCET") out.append({ "ucty_id": ucty_id, "id_kas": id_kas, "c_uzaverka": c_uzaverka, "ucislo": ucislo or "", "stul": stul, "room_name": room_name or "", "open_at": open_at, "closed_at": closed_at, "blocked_by": blocked_by or "", "closed": bool(closed_at and closed_at.strip()), "storno": storno, "is_storno": is_storno, "origin": origin, "pohladavka": _int_value(pohladavka, 0) or None, "cash_operation": cash_operation, "autor": autor, "total_base_currency": float(total_base_currency or 0.0), "payments_text": payments_text, "status_text": status_text, }) return out def _receipt_item_storno_status(items: list[dict]) -> str: has_partial = False has_closed = False has_available = False has_kstornu = False for item in items: try: units = abs(float(item.get("pocet", 0) or 0)) except Exception: units = 0.0 if units <= 0: continue if "kstornu" not in item or item.get("kstornu") is None: has_available = True continue has_kstornu = True try: available = max(min(float(item.get("kstornu") or 0), units), 0.0) except Exception: available = units if available > 0: has_available = True if available < units: has_partial = True if available <= 0: has_closed = True if has_partial and has_available: return "CIAST. STORNO" if has_kstornu and has_closed and not has_available: return "VYSTORNOVANY" return "" # --- manualni blokovani a odblokovni uctu @app.post("/ucet/block/") def block_ucet_by_stul( stul: str = Query(...), id_kas: str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth cleanup_dead_clients(prefix, id_kas) logger.info(f'Ucet block {stul}') return db_block_ucet_by_stul(prefix, stul, id_kas, client_id) def db_block_ucet_by_stul(cur_pref: str, stul: str, id_kas, client_id: str): table = f"{cur_pref}_ucty" now = time_hhmmss() with get_db() as conn: cur = conn.cursor() # 🔎 najdi OTEVŘENÝ účet ke stolu cur.execute(f""" SELECT ucty_id, blocked_by FROM "{table}" WHERE stul=? AND id_kas=? AND (closed_at IS NULL OR TRIM(closed_at)='') """, (stul,id_kas)) row = cur.fetchone() if not row: raise HTTPException(404, "Otevřený účet ke stolu neexistuje") ucty_id, blocked_by = row if blocked_by and not blocked_by.startswith(client_id): raise HTTPException(409, f"Účet blokován {blocked_by}") cur.execute(f""" UPDATE "{table}" SET blocked_by=? WHERE ucty_id=? """, (f"{client_id}|{now}", ucty_id)) return {"status": "blocked", "stul": stul, "id_kas": id_kas} from fastapi import Query, Depends, HTTPException @app.post("/ucet/unblock/") def unblock_ucet( stul: str | None = Query(None), ucislo: str | None = Query(None), id_kas: str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth logger.info(f'Ucet unblock stul={stul}, ucislo={ucislo}') cleanup_dead_clients(prefix, id_kas) # musí být zadán právě jeden parametr if not stul and not ucislo: raise HTTPException(status_code=400, detail="Musí být zadán stul nebo ucislo") if stul and ucislo: raise HTTPException(status_code=400, detail="Nelze zadat současně stul i ucislo") if stul: return db_unblock_ucet_by_stul(prefix, stul, id_kas, client_id) if ucislo: print(ucislo) return db_unblock_ucet_by_ucislo(prefix, ucislo, id_kas, client_id) def db_unblock_ucet_by_stul(cur_pref: str, stul: str, id_kas: str, client_id: str): table = f"{cur_pref}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute(f""" SELECT ucty_id, blocked_by FROM "{table}" WHERE stul=? AND id_kas=? AND (closed_at IS NULL OR TRIM(closed_at)='') """, (stul,id_kas)) row = cur.fetchone() if not row: raise HTTPException(404, "Otevřený účet ke stolu neexistuje") ucty_id, blocked_by_db = row # smí odblokovat jen vlastník blokace if blocked_by_db: # Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" # Petr 8.5. ^ if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}" ) cur.execute(f""" UPDATE "{table}" SET blocked_by='' WHERE ucty_id=? """, (ucty_id,)) return {"status": "unblocked", "stul": stul} def db_unblock_ucet_by_ucislo(cur_pref: str, ucislo: str, id_kas: str, client_id: str): table = f"{cur_pref}_ucty" with get_db() as conn: cur = conn.cursor() cur.execute(f""" SELECT ucty_id, blocked_by FROM "{table}" WHERE ucislo=? AND id_kas=? AND closed_at IS NOT NULL AND TRIM(closed_at) != '' """, (ucislo, id_kas)) row = cur.fetchone() if not row: raise HTTPException(404, f"Otevřený účet č. {ucislo} neexistuje") ucty_id, blocked_by_db = row # smí odblokovat jen vlastník blokace if blocked_by_db: # Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" # Petr 8.5. ^ if owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (ucislo={ucislo}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet č. {ucislo} je blokován: {blocked_by_db}" ) cur.execute(f""" UPDATE "{table}" SET blocked_by='' WHERE ucty_id=? """, (ucty_id,)) return {"status": "unblocked", "ucislo": ucislo} # --- otevře účet ke stolu + atomicky ho zablokuje (nebo vytvoří) @app.post("/ucet/open/") def open_block_create_ucet( stul: str = Query(...), id_kas: str = Query(...), auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth cleanup_dead_clients(prefix, id_kas) return open_block_create_ucet_db( cur_pref=prefix, stul=stul, id_kas=id_kas, client_id=client_id, ) def open_block_create_ucet_db( cur_pref: str, stul: str, id_kas: str, client_id: str, ) -> dict: """ Atomicky: - najde OTEVŘENÝ účet ke stolu (closed_at prázdné, ucislo NULL) - pokud existuje: - je-li blokovaný jiným → 409 - jinak ho zablokuje - pokud neexistuje: - vytvoří PRÁZDNÝ otevřený účet - rovnou ho zablokuje """ table = f"{cur_pref}_ucty" now = time_hhmmss() block_val = f"{client_id}|{now}" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_ucty_room_name_schema(cur_pref, cur) # --- najdi OTEVŘENÝ účet ke stolu --- cur.execute(f""" SELECT ucty_id, blocked_by FROM "{table}" WHERE stul = ? AND id_kas = ? AND (closed_at IS NULL OR TRIM(closed_at) = '') AND ucislo IS NULL LIMIT 1 """, (stul, id_kas)) row = cur.fetchone() # otevřený účet EXISTUJE if row: ucty_id, blocked_by_db = row # Petr 8.5. owner = blocked_by_db.split("|", 1)[0] if blocked_by_db else "" # Petr 8.5. ^ if owner and owner != client_id: # kontrola expirace blocku if is_block_expired(cur, cur_pref, id_kas, blocked_by_db): logger.warning( f"BLOCK expired → releasing (stul={stul}, blocked_by={blocked_by_db})" ) # uvolni expirovaný block cur.execute( f'UPDATE "{table}" SET blocked_by=NULL WHERE ucty_id=?', (ucty_id,), ) else: raise HTTPException( 409, f"Otevřený účet ke stolu {stul} je blokován: {blocked_by_db}" ) # zablokuj existující otevřený účet cur.execute( f'UPDATE "{table}" SET blocked_by=? WHERE ucty_id=?', (block_val, ucty_id), ) return { "operation": "open-existing", "stul": stul, "created": False, "blocked": True, } # otevřený účet NEEXISTUJE → CREATE empty_ucet = data.Ucet( id_kas=id_kas, stul=stul, poloz=[], open_at=now, closed_at=None, ucislo=None, blocked_by=block_val, guests = [{"id": "g1", "name": "Hosť 1"}], courses = [{"id": "c1", "name": "Chod 1"}] ) ensure_ucet_room_name(cur_pref, empty_ucet) payload = empty_ucet.model_dump_json() cur.execute( f""" INSERT INTO "{table}" (ucislo, id_kas, stul, room_name, blocked_by, closed_at, data) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( None, # ucislo id_kas, stul, empty_ucet.room_name, block_val, None, # closed_at payload, ), ) return { "operation": "create", "stul": stul, "created": True, "blocked": True, } # ----------------------------------------------------- # Uzaverka # ----------------------------------------------------- def ensure_closure_runtime_schema(prefix: str, cur) -> None: init_tab_closerep(prefix=prefix, cur=cur) init_platby_schema(prefix=prefix, cur=cur) init_closure_cash_state_schema(prefix=prefix, cur=cur) init_closure_transfer_outbox_schema(prefix=prefix, cur=cur) CLOSURE_REPORT_FLAG_NAMES = [ "t_uz_ucet", "t_uz_trzdr", "t_uz_harek", "t_uz_dph", "t_uz_man", "t_uz_cenhl", "t_uz_man_dph", "t_uz_puctu", "t_uz_stzur", "t_uz_casni", "t_uz_cshot", "t_uz_spdph", "t_uz_poh_drpl", "t_uz_vkl_drpl", "t_uz_trz_vkl_drpl", "t_uz_vklad_vyber", "t_uz_drpl", "t_uz_odovzdanie", "t_uz_fisk_platby", "t_uz_drpldan", "t_uz_drplfisdan", "t_uz_dph_fis", "t_uz_terminal", "t_uz_mena", "t_uz_stoly", "t_uz_puctu_cas", ] def closure_report_settings(setup_params: dict) -> dict: settings = { "men_sp_man": _strip_value(setup_params.get("men_sp_man")), "uzav_odvod": _strip_value(setup_params.get("uzav_odvod")), "is_fiskal": _bool_value(setup_params.get("is_fiskal"), False), } settings["flags"] = { name: _bool_value(setup_params.get(name), False) for name in CLOSURE_REPORT_FLAG_NAMES } return settings def get_ucty_notinclsrep_DB( cur, table_ucty: str, table_clsrep: str, id_kas: str, ): sql = f""" SELECT data, c_uzaverka FROM "{table_ucty}" WHERE id_kas = ? AND closed_at IS NOT NULL AND TRIM(COALESCE(ucislo, '')) != '' AND (c_uzaverka IS NULL OR c_uzaverka = 0) ORDER BY ucislo """ cur.execute(sql, (id_kas,)) rows = cur.fetchall() result = [] for raw_json, c_uzaverka in rows: payload = json.loads(raw_json) payload["c_uzaverka"] = c_uzaverka result.append(payload) return result @app.get("/ucty/notinclsrep/", response_model=list[data.Ucet]) def get_ucty_notinclsrep( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth table_ucty = f"{prefix}_ucty" table_clsrep = f"{prefix}_clsrep" logger.info(f"Ucty notinclsrep kas={id_kas} user={user}") with get_db() as conn: cur = conn.cursor() ensure_closure_runtime_schema(prefix, cur) rows = get_ucty_notinclsrep_DB( cur=cur, table_ucty=table_ucty, table_clsrep=table_clsrep, id_kas=id_kas, ) return rows @app.get("/closure/detail/", response_model=data.ClosureDetailOut) def get_closure_detail( clsrep_no: str, id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth table_clsrep = f"{prefix}_clsrep" table_ucty = f"{prefix}_ucty" logger.info(f"Closure detail clsrep={clsrep_no} kas={id_kas} user={user}") with get_db() as conn: cur = conn.cursor() ensure_closure_runtime_schema(prefix, cur) result = get_closure_detail_DB( cur=cur, table_clsrep=table_clsrep, table_ucty=table_ucty, clsrep_no=clsrep_no, id_kas=id_kas, ) if not result: raise HTTPException(404, "Uzávěrka nenalezena") return result def get_closure_detail_DB( cur, table_clsrep: str, table_ucty: str, clsrep_no: str, id_kas: str, ): sql = f""" SELECT clsrep_id, data, ucislo_st, ucislo_end, men_sp_man, uzav_odvod, closure_warnings FROM "{table_clsrep}" WHERE clsrep_no = ? AND id_kas = ? """ cur.execute(sql, (clsrep_no, id_kas)) row = cur.fetchone() if not row: return None clsrep_id = row[0] clsrep_data = json.loads(row[1]) ucislo_od = row[2] ucislo_do = row[3] men_sp_man = row[4] if len(row) > 4 else "" uzav_odvod = row[5] if len(row) > 5 else "" closure_warnings = [] if len(row) > 6 and row[6]: try: closure_warnings = json.loads(row[6]) except Exception: closure_warnings = [] sql = f""" SELECT data, c_uzaverka FROM "{table_ucty}" WHERE id_kas = ? AND c_uzaverka = ? ORDER BY ucislo """ cur.execute(sql, (id_kas, clsrep_id)) rows = cur.fetchall() if not rows: sql = f""" SELECT data, c_uzaverka FROM "{table_ucty}" WHERE id_kas = ? AND ucislo BETWEEN ? AND ? ORDER BY ucislo """ cur.execute(sql, (id_kas, ucislo_od, ucislo_do)) rows = cur.fetchall() ucty = [] for raw_json, c_uzaverka in rows: payload = json.loads(raw_json) payload["c_uzaverka"] = c_uzaverka ucty.append(payload) return { "clsrep": { "clsrep_id": clsrep_id, "clsrep_no": clsrep_no, "ucislo_od": ucislo_od, "ucislo_do": ucislo_do, "men_sp_man": men_sp_man, "uzav_odvod": uzav_odvod, "closure_warnings": closure_warnings, }, "data": clsrep_data, "ucty": ucty, } @app.get("/closure/list/", response_model=list[data.ClosureIntervalOut]) def get_closure_list( id_kas: str, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, user, client_id = auth table_clsrep = f"{prefix}_clsrep" logger.info(f"Closure list request kas={id_kas} user={user}") with get_db() as conn: cur = conn.cursor() ensure_closure_runtime_schema(prefix, cur) rows = get_closure_intervals( cur=cur, table_clsrep=table_clsrep, id_kas=id_kas, ) return rows def get_closure_intervals(cur, table_clsrep: str, id_kas: str): sql = f""" SELECT clsrep_no, ucislo_st, ucislo_end, dta_from, dta_to FROM "{table_clsrep}" WHERE id_kas = ? ORDER BY clsrep_id DESC """ cur.execute(sql, (id_kas,)) rows = cur.fetchall() result = [] for r in rows: result.append({ "clsrep_no": r[0], "ucislo_od": r[1], "ucislo_do": r[2], "closed_at_od": r[3], "closed_at_do": r[4], }) return result def _closure_cash_row_for_db(row: dict[str, Any]) -> dict[str, Any]: return { "prn_no": _strip_value(row.get("prn_no")), "payment_code": _strip_value(row.get("payment_code")), "payment_name": _strip_value(row.get("payment_name")), "opening_amount": _float_value(row.get("opening_amount"), 0.0), "sales_amount": _float_value(row.get("sales_amount"), 0.0), "receivable_amount": _float_value(row.get("receivable_amount"), 0.0), "manual_deposit_amount": _float_value(row.get("manual_deposit_amount"), 0.0), "manual_withdrawal_amount": _float_value(row.get("manual_withdrawal_amount"), 0.0), "auto_deposit_amount": _float_value(row.get("auto_deposit_amount"), 0.0), "auto_withdrawal_amount": _float_value(row.get("auto_withdrawal_amount"), 0.0), "carry_amount": _float_value(row.get("carry_amount"), 0.0), "generated_ucislo": _strip_value(row.get("generated_ucislo")), "fiscal_result": json.dumps(row.get("fiscal_result") or {}, ensure_ascii=False), "status": _strip_value(row.get("status") or "pending"), "error": _strip_value(row.get("error")), } def persist_closure_cash_state(prefix: str, clsrep_id: int, id_kas: str, rows: list[dict[str, Any]]) -> None: if not rows: return table = f"{prefix}_closure_cash_state" with get_db() as conn: cur = conn.cursor() init_closure_cash_state_schema(prefix, cur) for row in rows: db_row = _closure_cash_row_for_db(row) cur.execute(f""" INSERT INTO "{table}" ( clsrep_id, id_kas, prn_no, payment_code, payment_name, opening_amount, sales_amount, receivable_amount, manual_deposit_amount, manual_withdrawal_amount, auto_deposit_amount, auto_withdrawal_amount, carry_amount, generated_ucislo, fiscal_result, status, error, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(clsrep_id, id_kas, prn_no, payment_code) DO UPDATE SET payment_name=excluded.payment_name, opening_amount=excluded.opening_amount, sales_amount=excluded.sales_amount, receivable_amount=excluded.receivable_amount, manual_deposit_amount=excluded.manual_deposit_amount, manual_withdrawal_amount=excluded.manual_withdrawal_amount, auto_deposit_amount=excluded.auto_deposit_amount, auto_withdrawal_amount=excluded.auto_withdrawal_amount, carry_amount=excluded.carry_amount, generated_ucislo=excluded.generated_ucislo, fiscal_result=excluded.fiscal_result, status=excluded.status, error=excluded.error, updated_at=CURRENT_TIMESTAMP """, ( clsrep_id, id_kas, db_row["prn_no"], db_row["payment_code"], db_row["payment_name"], db_row["opening_amount"], db_row["sales_amount"], db_row["receivable_amount"], db_row["manual_deposit_amount"], db_row["manual_withdrawal_amount"], db_row["auto_deposit_amount"], db_row["auto_withdrawal_amount"], db_row["carry_amount"], db_row["generated_ucislo"], db_row["fiscal_result"], db_row["status"], db_row["error"], )) conn.commit() def _update_closure_report_payload(prefix: str, clsrep_id: int, report: data.ClosureReportOut) -> None: table = f"{prefix}_clsrep" with get_db() as conn: cur = conn.cursor() cur.execute(f""" UPDATE "{table}" SET data=?, closure_warnings=? WHERE clsrep_id=? """, ( json.dumps(report.model_dump(), ensure_ascii=False), json.dumps(report.warnings or [], ensure_ascii=False), clsrep_id, )) conn.commit() def _load_closure_transfer_rows_db( prefix: str, id_kas: str, clsrep_id: int | None = None, status: str | None = None, ) -> list[dict[str, Any]]: table = f"{prefix}_closure_transfer_outbox" where = ["id_kas=?"] params: list[Any] = [id_kas] if clsrep_id is not None: where.append("clsrep_id=?") params.append(clsrep_id) if status: where.append("status=?") params.append(status) with get_db() as conn: cur = conn.cursor() init_closure_transfer_outbox_schema(prefix, cur) cur.execute(f""" SELECT id, clsrep_id, id_kas, target_type, reception_id, reception_name, typ_hotel, payload, response, status, attempts, last_error, created_at, updated_at, sent_at FROM "{table}" WHERE {" AND ".join(where)} ORDER BY clsrep_id DESC, id DESC """, params) rows = cur.fetchall() def load_json_cell(value: str | None) -> dict: try: loaded = json.loads(value or "{}") return loaded if isinstance(loaded, dict) else {} except Exception: return {} return [ { "id": row[0], "clsrep_id": row[1], "id_kas": row[2], "target_type": row[3], "reception_id": row[4], "reception_name": row[5], "typ_hotel": row[6], "payload": load_json_cell(row[7]), "response": load_json_cell(row[8]), "status": row[9], "attempts": row[10], "last_error": row[11], "created_at": row[12], "updated_at": row[13], "sent_at": row[14], } for row in rows ] def _insert_closure_transfer_outbox( prefix: str, clsrep_id: int, id_kas: str, reception: data.Recepcia, payload: dict, ) -> int: table = f"{prefix}_closure_transfer_outbox" with get_db() as conn: cur = conn.cursor() init_closure_transfer_outbox_schema(prefix, cur) cur.execute(f""" INSERT INTO "{table}" (clsrep_id, id_kas, target_type, reception_id, reception_name, typ_hotel, payload, response, status, attempts, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( clsrep_id, id_kas, "hotel_closure", int(getattr(reception, "id", 0) or 0), _strip_value(getattr(reception, "hotel", "")), int(getattr(reception, "typ_hotel", 0) or 0), json.dumps(payload, ensure_ascii=False), "{}", "pending", 0, "", )) transfer_id = int(cur.lastrowid) conn.commit() return transfer_id def _update_closure_transfer_outbox( prefix: str, transfer_id: int, status: str, response: dict | None = None, error: str = "", ) -> None: table = f"{prefix}_closure_transfer_outbox" sent_sql = ", sent_at=CURRENT_TIMESTAMP" if status == "sent" else "" with get_db() as conn: cur = conn.cursor() init_closure_transfer_outbox_schema(prefix, cur) cur.execute(f""" UPDATE "{table}" SET status=?, response=?, attempts=attempts+1, last_error=?, updated_at=CURRENT_TIMESTAMP {sent_sql} WHERE id=? """, ( status, json.dumps(response or {}, ensure_ascii=False), (error or "")[:1000], transfer_id, )) conn.commit() def _closure_cash_register_code(prefix: str, id_kas: str) -> str: table = f"{prefix}_fooddat" candidates = {_strip_value(id_kas)} try: candidates.add(str(int(float(id_kas)))) candidates.add(str(int(float(id_kas))).zfill(2)) except Exception: pass with get_db() as conn: cur = conn.cursor() try: cur.execute(f'SELECT id, id_zkratka FROM "{table}"') except sqlite3.Error: return _strip_value(id_kas) for raw_id, raw_name in cur.fetchall(): if _strip_value(raw_id) in candidates and _strip_value(raw_name): return _strip_value(raw_name) return _strip_value(id_kas) def _closure_transfer_reception( prefix: str, setup_params: dict, ) -> tuple[data.Recepcia | None, str]: receptions = load_receptions_from_db(prefix) if not receptions: return None, "Uzavierkovy prenos do recepcie je povoleny, ale nie je nastavena ziadna recepcia." wanted_id = _int_value(setup_params.get("id_prenh"), 0) if wanted_id: for reception in receptions: if int(getattr(reception, "id", 0) or 0) == wanted_id: return reception, "" return None, f"Recepcia id_prenh={wanted_id} nebola najdena." if len(receptions) == 1: return receptions[0], "" return None, "Pre uzavierkovy prenos je nastavenych viac recepcii; vypln parameter id_prenh." def _load_hotplatby_map(prefix: str, reception_id: int) -> dict[str, dict[str, Any]]: table = f"{prefix}_hotplatby" with get_db() as conn: cur = conn.cursor() try: cur.execute(f""" SELECT id_hotel, druh_pl, hot_platba_id, hot_karta_id, hot_platba, hot_karta, po_uctoch, payment, id_meny FROM "{table}" WHERE id_hotel = ? OR id_hotel = 0 ORDER BY CASE WHEN id_hotel = ? THEN 0 ELSE 1 END, druh_pl """, (reception_id, reception_id)) except sqlite3.Error: return {} rows = cur.fetchall() result: dict[str, dict[str, Any]] = {} for row in rows: item = { "id_hotel": row[0], "druh_pl": _strip_value(row[1]), "hot_platba_id": _int_value(row[2], 0), "hot_karta_id": _int_value(row[3], 0), "hot_platba": _strip_value(row[4]), "hot_karta": _strip_value(row[5]), "po_uctoch": _int_value(row[6], 0), "payment": _strip_value(row[7]), "id_meny": _int_value(row[8], 0), } for key in (item["druh_pl"], item["hot_platba"], item["payment"]): norm = key.strip().lower() if norm and norm not in result: result[norm] = item return result def _closure_payment_amount(payment: data.Platba) -> float: amount = _float_value(getattr(payment, "suma_czk", 0), 0.0) if abs(amount) < 0.0001: amount = _float_value(getattr(payment, "suma", 0), 0.0) * (_float_value(getattr(payment, "rate", 1), 1.0) or 1.0) return round(amount, 2) def _closure_payment_tip(payment: data.Platba) -> float: tip = _float_value(getattr(payment, "tip", 0), 0.0) rate = _float_value(getattr(payment, "rate", 1), 1.0) or 1.0 return round(tip * rate, 2) def _closure_payment_mapping( payment: data.Platba, hotplatby: dict[str, dict[str, Any]], ) -> dict[str, Any] | None: candidates = [ _strip_value(getattr(payment, "code", "")), _strip_value(getattr(payment, "nazev", "")), ] for candidate in candidates: mapping = hotplatby.get(candidate.lower()) if mapping: return mapping return None def _closure_item_qty(pol: data.UcPol) -> float: return _float_value(getattr(pol, "pocet", 0), 0.0) / max(_int_value(getattr(pol, "delitel", 1), 1), 1) def _closure_item_amount(pol: data.UcPol) -> float: return round(_float_value(getattr(pol, "cena", 0), 0.0) * _closure_item_qty(pol), 4) def _closure_vat_amount(rate_value: str, gross_amount: float) -> float: rate_text = _strip_value(rate_value).replace(",", ".") try: rate = float(rate_text) except Exception: return 0.0 if rate == -1: return 0.0 if rate > 2.0: return round(gross_amount * rate / (100.0 + rate), 4) if rate > 1.0: return round(gross_amount * (rate - 1.0) / rate, 4) if rate > 0.0: return round(gross_amount * rate / (1.0 + rate), 4) return 0.0 def _closure_raster_id( raster_rows: list[dict], c_druh: int, tmatr: str, budova: str, typ_hotel: int, price_level: str, ) -> str: raster_id = select_raster_id(raster_rows, c_druh, tmatr, budova, typ_hotel, price_level) if raster_id: return raster_id if c_druh not in {-100, -200}: raster_id = select_raster_id(raster_rows, -100, tmatr, budova, typ_hotel, price_level) if not raster_id and c_druh == -200: raster_id = select_raster_id(raster_rows, -100, tmatr, budova, typ_hotel, price_level) return raster_id def _add_closure_transfer_item( grouped: dict[tuple, dict[str, Any]], *, receipt_number: str, note: str, mapping: dict[str, Any], raster_id: str, amount: float, vat_amount: float, currency: str, dph: str, c_druh: int, ): amount = round(amount, 4) vat_amount = round(vat_amount, 4) if abs(amount) < 0.0001 and abs(vat_amount) < 0.0001: return key = ( receipt_number, raster_id, mapping.get("hot_platba_id", 0), mapping.get("hot_karta_id", 0), mapping.get("id_meny", 0), currency, dph, c_druh, ) if key not in grouped: grouped[key] = { "receipt_number": receipt_number, "note": note, "raster_id": raster_id, "payment_method_id": mapping.get("hot_platba_id", 0), "credit_card_type_id": mapping.get("hot_karta_id", 0), "currency_id": mapping.get("id_meny", 0), "currency": currency, "dph": dph, "c_druh": c_druh, "amount": 0.0, "amount_currency": 0.0, "vat_amount": 0.0, "credit_card_number": "", "exchange_rate": "", } grouped[key]["amount"] = round(grouped[key]["amount"] + amount, 4) grouped[key]["amount_currency"] = round(grouped[key]["amount_currency"] + amount, 4) grouped[key]["vat_amount"] = round(grouped[key]["vat_amount"] + vat_amount, 4) def _build_closure_reception_payload( prefix: str, id_kas: str, clsrep_id: int, report: data.ClosureReportOut, receipts: list[data.Ucet], reception: data.Recepcia, setup_params: dict, ) -> tuple[dict | None, list[str]]: reception_id = int(getattr(reception, "id", 0) or 0) typ_hotel = int(getattr(reception, "typ_hotel", 0) or 0) hotplatby = _load_hotplatby_map(prefix, reception_id) warnings: list[str] = [] if not hotplatby: return None, [f"Pre recepciu {getattr(reception, 'hotel', '')} nie su nastavene hotplatby."] raster_rows = load_hotel_raster_rows(prefix, id_kas, typ_hotel, reception_id) if not raster_rows: return None, [f"Pre recepciu {getattr(reception, 'hotel', '')} nie su nastavene hotelove rastre."] use_time_attr = _bool_value(setup_params.get("is_uzprenhtimeatr"), False) grouped: dict[tuple, dict[str, Any]] = {} missing_payments: set[str] = set() missing_rasters: set[str] = set() note = f"UZ{id_kas}/{report.clsrep_no or clsrep_id}" for ucet in receipts: if _strip_value(getattr(ucet, "cash_operation", "")): continue if _int_value(getattr(ucet, "pohladavka", 0), 0) == 1: continue payments = list(getattr(ucet, "platby", []) or []) if not payments: continue mapped_payments = [] for payment in payments: mapping = _closure_payment_mapping(payment, hotplatby) if not mapping: payment_name = _strip_value(getattr(payment, "code", "")) or _strip_value(getattr(payment, "nazev", "")) if payment_name: missing_payments.add(payment_name) continue amount = _closure_payment_amount(payment) mapped_payments.append((payment, mapping, amount)) if not mapped_payments: continue payment_total = round(sum(amount for _, _, amount in mapped_payments), 4) if abs(payment_total) < 0.0001: continue receipt_currency = "" for pol in getattr(ucet, "poloz", []) or []: if not receipt_currency: receipt_currency = _strip_value(getattr(pol, "mena", "")) or "EUR" receipt_currency = receipt_currency or "EUR" for payment, mapping, payment_amount in mapped_payments: share = payment_amount / payment_total if abs(payment_total) >= 0.0001 else 0.0 receipt_number = _strip_value(getattr(ucet, "ucislo", "")) if _int_value(mapping.get("po_uctoch"), 0) == 1 else "0" target = getattr(payment, "hotel_charge", None) or getattr(ucet, "hotel_charge", None) tmatr = "" budova = "" if target and use_time_attr: tmatr = _strip_value(getattr(target, "time_attribute", "")) budova = _strip_value(getattr(target, "building", "")) currency = _strip_value(getattr(payment, "unit", "")) or receipt_currency for pol in getattr(ucet, "poloz", []) or []: amount = round(_closure_item_amount(pol) * share, 4) if abs(amount) < 0.0001: continue c_druh = _int_value(getattr(pol, "c_druh", 0), 0) price_level = _strip_value(getattr(pol, "cenhlad", "")) or "1" raster_id = _closure_raster_id(raster_rows, c_druh, tmatr, budova, typ_hotel, price_level) if not raster_id: missing_rasters.add(f"{getattr(pol, 'nazev', '') or getattr(pol, 'id_card', '')} (c_druh={c_druh}, hladina={price_level})") continue dph = _strip_value(getattr(pol, "dph", "")) _add_closure_transfer_item( grouped, receipt_number=receipt_number, note=note, mapping=mapping, raster_id=raster_id, amount=amount, vat_amount=_closure_vat_amount(dph, amount), currency=currency, dph=dph, c_druh=c_druh, ) tip = _closure_payment_tip(payment) if abs(tip) >= 0.005: raster_id = _closure_raster_id(raster_rows, -1, tmatr, budova, typ_hotel, "1") if not raster_id: missing_rasters.add("TIP (c_druh=-1)") else: _add_closure_transfer_item( grouped, receipt_number=receipt_number, note=note, mapping=mapping, raster_id=raster_id, amount=tip, vat_amount=0.0, currency=currency, dph="0", c_druh=-1, ) round50 = round(_float_value(getattr(ucet, "round50", 0), 0.0) * share, 4) if abs(round50) >= 0.0001: raster_id = _closure_raster_id(raster_rows, -200, tmatr, budova, typ_hotel, "1") if not raster_id: missing_rasters.add("Zaokruhlenie (c_druh=-200)") else: _add_closure_transfer_item( grouped, receipt_number=receipt_number, note=note, mapping=mapping, raster_id=raster_id, amount=round50, vat_amount=0.0, currency=currency, dph="0", c_druh=-200, ) if missing_payments: warnings.append( "Uzavierkovy prenos: v hotplatby nie su namapovane platby " + ", ".join(sorted(missing_payments)[:10]) + ("..." if len(missing_payments) > 10 else "") ) if missing_rasters: warnings.append( "Uzavierkovy prenos: chybaju rastre pre " + ", ".join(sorted(missing_rasters)[:10]) + ("..." if len(missing_rasters) > 10 else "") ) items = sorted( grouped.values(), key=lambda item: ( _strip_value(item.get("receipt_number")), _strip_value(item.get("raster_id")), _int_value(item.get("payment_method_id"), 0), _int_value(item.get("credit_card_type_id"), 0), ), ) if not items: return None, warnings or ["Uzavierkovy prenos do recepcie nema ziadne odosielatelne polozky."] payload = { "kind": "closure_reception_transfer", "clsrep_id": clsrep_id, "clsrep_no": report.clsrep_no, "id_kas": id_kas, "cash_register_code": _closure_cash_register_code(prefix, id_kas), "reception_id": reception_id, "reception_name": _strip_value(getattr(reception, "hotel", "")), "typ_hotel": typ_hotel, "interval": report.interval.model_dump(mode="json"), "note": note, "items": items, } return payload, warnings def finalize_closure_reception_transfers( prefix: str, id_kas: str, clsrep_id: int, report: data.ClosureReportOut, setup_params: dict, raw_receipts: list[str], ) -> data.ClosureReportOut: warnings = list(report.warnings or []) if not _bool_value(setup_params.get("is_uzprenh"), False): report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id) report.warnings = warnings _update_closure_report_payload(prefix, clsrep_id, report) return report reception, reception_error = _closure_transfer_reception(prefix, setup_params) if not reception: warnings.append(reception_error) report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id) report.warnings = warnings _update_closure_report_payload(prefix, clsrep_id, report) return report receipts: list[data.Ucet] = [] for raw in raw_receipts: try: receipts.append(data.Ucet.model_validate_json(raw)) except Exception: logger.exception("Closure reception transfer skipped invalid receipt JSON.") payload, payload_warnings = _build_closure_reception_payload( prefix, id_kas, clsrep_id, report, receipts, reception, setup_params, ) warnings.extend(payload_warnings) if not payload: report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id) report.warnings = warnings _update_closure_report_payload(prefix, clsrep_id, report) return report transfer_id = _insert_closure_transfer_outbox(prefix, clsrep_id, id_kas, reception, payload) try: result = hotel_service.transfer_cash(reception, setup_params, payload) _update_closure_transfer_outbox(prefix, transfer_id, "sent", response=result) logger.info( "Closure reception transfer sent: prefix=%s clsrep_id=%s transfer_id=%s reception=%s", prefix, clsrep_id, transfer_id, getattr(reception, "hotel", ""), ) except Exception as exc: message = str(exc) logger.exception( "Closure reception transfer failed: prefix=%s clsrep_id=%s transfer_id=%s reception=%s", prefix, clsrep_id, transfer_id, getattr(reception, "hotel", ""), ) _update_closure_transfer_outbox(prefix, transfer_id, "failed", response={}, error=message) warnings.append(f"Prenos uzavierky do recepcie zlyhal: {message}") report.transfers = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id) report.warnings = warnings _update_closure_report_payload(prefix, clsrep_id, report) return report def finalize_closure_cash_actions( prefix: str, id_kas: str, clsrep_id: int, report: data.ClosureReportOut, setup_params: dict, client_id: str, cash_carry: list[data.ClosureCarryInput] | None = None, ) -> data.ClosureReportOut: rows = [dict(row) for row in (report.cash_state or [])] warnings = list(report.warnings or []) is_fiskal = _bool_value(setup_params.get("is_fiskal"), False) uzav_odvod = _strip_value(setup_params.get("uzav_odvod")) payment_map = { _strip_value(payment.code): payment for payment in get_setup_platby_from_db(prefix, id_kas) } carry_map: dict[tuple[str, str], float] = {} for item in cash_carry or []: key = (_strip_value(item.prn_no), _strip_value(item.payment_code)) carry_map[key] = round(max(_float_value(item.carry_amount, 0.0), 0.0), 2) for row in rows: balance = round(_float_value(row.get("balance_amount"), 0.0), 2) row["balance_amount"] = balance row["carry_amount"] = balance row["auto_deposit_amount"] = round(_float_value(row.get("auto_deposit_amount"), 0.0), 2) row["auto_withdrawal_amount"] = round(_float_value(row.get("auto_withdrawal_amount"), 0.0), 2) row["status"] = "settled" if abs(balance) < 0.005 else "carry" row["error"] = "" if not is_fiskal or uzav_odvod not in {"1", "2"}: continue if int(row.get("payment_odvod") or 0) != 1: continue payment_code = _strip_value(row.get("payment_code")) payment = payment_map.get(payment_code) if not payment: row["status"] = "failed" row["error"] = f"Platba {payment_code} nie je v nastaveni platobnych metod." warnings.append(row["error"]) continue operation = "auto_withdrawal" amount = balance target_carry = 0.0 if uzav_odvod == "2": key = (_strip_value(row.get("prn_no")), payment_code) if key not in carry_map: continue target_carry = carry_map[key] amount = round(balance - target_carry, 2) if abs(amount) <= 0.004: row["carry_amount"] = target_carry row["status"] = "settled" if abs(target_carry) < 0.005 else "carry" continue if amount < 0: operation = "auto_deposit" amount = abs(amount) elif balance <= 0.004: continue try: req = data.FiscalCashOperationRequest( id_kas=id_kas, operation=operation, amount=amount, payment=payment, printer_no=_strip_value(row.get("prn_no")), author="Uzavierka", pos_name=report.clsrep_no or "", ) result = print_fiscal_cash_operation_db( prefix, req, client_id, c_uzaverka=clsrep_id, ) if operation == "auto_deposit": row["auto_deposit_amount"] = amount else: row["auto_withdrawal_amount"] = amount row["carry_amount"] = target_carry row["status"] = "settled" if abs(target_carry) < 0.005 else "carry" row["generated_ucislo"] = _strip_value(getattr(result.ucet, "ucislo", "")) row["fiscal_result"] = result.fiscal_result or {} except Exception as exc: message = str(exc) logger.exception( "Closure auto cash operation failed: prefix=%s clsrep_id=%s operation=%s prn=%s payment=%s amount=%s", prefix, clsrep_id, operation, row.get("prn_no"), payment_code, amount, ) row["status"] = "failed" row["error"] = message[:500] row["carry_amount"] = balance warnings.append( f"Automaticky vklad/vyber zlyhal ({row.get('prn_no')}/{payment_code}): {message}" ) persist_closure_cash_state(prefix, clsrep_id, id_kas, rows) report.cash_state = rows report.warnings = warnings _update_closure_report_payload(prefix, clsrep_id, report) return report def get_closure_cash_state_db(prefix: str, id_kas: str, clsrep_id: int | None = None, status: str | None = None) -> list[dict]: table = f"{prefix}_closure_cash_state" where = ["id_kas=?"] params: list[Any] = [id_kas] if clsrep_id is not None: where.append("clsrep_id=?") params.append(clsrep_id) if status: where.append("status=?") params.append(status) with get_db() as conn: cur = conn.cursor() init_closure_cash_state_schema(prefix, cur) cur.execute(f""" SELECT id, clsrep_id, id_kas, prn_no, payment_code, payment_name, opening_amount, sales_amount, receivable_amount, manual_deposit_amount, manual_withdrawal_amount, auto_deposit_amount, auto_withdrawal_amount, carry_amount, generated_ucislo, fiscal_result, status, error, created_at, updated_at FROM "{table}" WHERE {" AND ".join(where)} ORDER BY clsrep_id DESC, prn_no, payment_name, payment_code """, params) rows = cur.fetchall() result = [] for row in rows: fiscal_result = {} if row[15]: try: fiscal_result = json.loads(row[15]) except Exception: fiscal_result = {} result.append({ "id": row[0], "clsrep_id": row[1], "id_kas": row[2], "prn_no": row[3], "payment_code": row[4], "payment_name": row[5], "opening_amount": row[6], "sales_amount": row[7], "receivable_amount": row[8], "manual_deposit_amount": row[9], "manual_withdrawal_amount": row[10], "auto_deposit_amount": row[11], "auto_withdrawal_amount": row[12], "carry_amount": row[13], "generated_ucislo": row[14], "fiscal_result": fiscal_result, "status": row[16], "error": row[17], "created_at": row[18], "updated_at": row[19], }) return result @app.get("/closure/cash-state/") def get_closure_cash_state( id_kas: str, clsrep_id: int | None = None, status: str | None = None, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return get_closure_cash_state_db(prefix, id_kas, clsrep_id=clsrep_id, status=status) @app.get("/closure/transfers/") def get_closure_transfers( id_kas: str, clsrep_id: int | None = None, status: str | None = None, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth return _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=clsrep_id, status=status) @app.post("/closure/transfers/{transfer_id}/retry") def retry_closure_transfer( transfer_id: int, auth: tuple[str, str, str] = Depends(auth_ctx), ): prefix, _, _ = auth table = f"{prefix}_closure_transfer_outbox" with get_db() as conn: cur = conn.cursor() init_closure_transfer_outbox_schema(prefix, cur) cur.execute(f""" SELECT id_kas, reception_id, payload FROM "{table}" WHERE id=? """, (transfer_id,)) row = cur.fetchone() if not row: raise HTTPException(404, f"Prenos uzavierky {transfer_id} nebol najdeny.") id_kas = _strip_value(row[0]) reception_id = _int_value(row[1], 0) try: payload = json.loads(row[2] or "{}") except Exception: payload = {} if not payload: raise HTTPException(422, "Prenos uzavierky nema ulozeny payload.") reception = load_reception_from_db(prefix, reception_id) setup_params = get_setup_param_values(prefix, id_kas) try: result = hotel_service.transfer_cash(reception, setup_params, payload) _update_closure_transfer_outbox(prefix, transfer_id, "sent", response=result) except Exception as exc: message = str(exc) logger.exception("Closure transfer retry failed: prefix=%s transfer_id=%s", prefix, transfer_id) _update_closure_transfer_outbox(prefix, transfer_id, "failed", response={}, error=message) raise HTTPException(502, message) from exc rows = _load_closure_transfer_rows_db(prefix, id_kas, clsrep_id=_int_value(payload.get("clsrep_id"), 0) or None) return next((item for item in rows if int(item.get("id") or 0) == transfer_id), {"ok": True}) @app.get("/closure/",response_model=server_clsrep.ClosureReportOut) def get_closure_report( id_kas: str, ucislo_od: str | None = None, ucislo_do: str | None = None, auth: tuple[str, str, str] = Depends(auth_ctx),): logger.info(f'Closing report from check = {ucislo_od} to check={ucislo_do}') prefix, user, client_id = auth table_ucty = f"{prefix}_ucty" table_clsrep = f"{prefix}_clsrep" setup_params = get_setup_param_values(prefix, id_kas) with get_db() as conn: cur=conn.cursor() ensure_closure_runtime_schema(prefix, cur) report = server_clsrep.compute_closure_report( cur=cur, table_ucty=table_ucty, table_clsrep=table_clsrep, ucislo_st=ucislo_od, ucislo_end=ucislo_do, id_kas=id_kas, ) if not report: raise HTTPException(404, "Uzávěrka je prázdná") report.closure_settings = closure_report_settings(setup_params) return report @app.post("/closure/save/",response_model=server_clsrep.ClosureReportOut) def save_closure_report( id_kas: str, ucislo_od: str | None = None, ucislo_do: str | None = None, request: data.ClosureSaveRequest | None = Body(None), auth: tuple[str, str, str] = Depends(auth_ctx),): logger.info(f"SAVE closure from {ucislo_od} to {ucislo_do}") prefix, user, client_id = auth table_ucty = f"{prefix}_ucty" table_clsrep = f"{prefix}_clsrep" setup_params = get_setup_param_values(prefix, id_kas) closure_settings = closure_report_settings(setup_params) with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") ensure_closure_runtime_schema(prefix, cur) cur.execute(""" SELECT 1 FROM heartbeat_clients WHERE prefix=? AND id_kas=? AND client_id!=? LIMIT 1 """, (prefix, id_kas, client_id)) if cur.fetchone(): raise HTTPException( 409, "Uzávěrku nelze provést – jiný terminál je aktivní." ) #with get_db() as conn: # SPOČÍTEJ UZÁVĚRKU report = server_clsrep.compute_closure_report( cur=cur, table_ucty=table_ucty, table_clsrep=table_clsrep, ucislo_st=ucislo_od, ucislo_end=ucislo_do, id_kas=id_kas, ) if not report: raise HTTPException(400, "Uzávěrka je prázdná") report.closure_settings = closure_settings cur = conn.cursor() # KONTROLA DUPLICITY (ochrana proti race condition) cur.execute(f""" SELECT 1 FROM "{table_clsrep}" WHERE id_kas=? AND clsrep_no=? """, (id_kas, report.clsrep_no)) if cur.fetchone(): raise HTTPException(409, "Uzávěrka s tímto číslem již existuje") # INSERT cur.execute(f""" INSERT INTO "{table_clsrep}" (clsrep_no, blocked_by, ucislo_st, ucislo_end, dta_from, dta_to, id_kas, data, men_sp_man, uzav_odvod, closure_warnings) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( report.clsrep_no, # ← TADY TO CHYBĚLO client_id, report.interval.ucislo_od, report.interval.ucislo_do, report.interval.closed_at_od, report.interval.closed_at_do, id_kas, json.dumps(report.model_dump(), ensure_ascii=False), closure_settings.get("men_sp_man", ""), closure_settings.get("uzav_odvod", ""), json.dumps(report.warnings or [], ensure_ascii=False), )) clsrep_id = cur.lastrowid cur.execute(f""" UPDATE "{table_ucty}" SET c_uzaverka = ? WHERE id_kas = ? AND closed_at IS NOT NULL AND TRIM(COALESCE(ucislo, '')) != '' AND (c_uzaverka IS NULL OR c_uzaverka = 0) AND CAST(ucislo AS INTEGER) >= CAST(? AS INTEGER) AND CAST(ucislo AS INTEGER) <= CAST(? AS INTEGER) """, ( clsrep_id, id_kas, report.interval.ucislo_od, report.interval.ucislo_do, )) if cur.rowcount <= 0: raise HTTPException(409, "Uzávěrka nebyla přiřazena k žádnému účtu") assigned_count = cur.rowcount cur.execute(f""" SELECT data FROM "{table_ucty}" WHERE id_kas = ? AND c_uzaverka = ? """, (id_kas, clsrep_id)) raw_receipts = [row[0] for row in cur.fetchall()] sync_limit_closure_to_postgres( prefix, id_kas, raw_receipts, clsrep_id, ) logger.info( "Closure saved clsrep_id=%s clsrep_no=%s assigned=%s interval=%s-%s", clsrep_id, report.clsrep_no, assigned_count, report.interval.ucislo_od, report.interval.ucislo_do, ) conn.commit() report = finalize_closure_cash_actions( prefix=prefix, id_kas=id_kas, clsrep_id=clsrep_id, report=report, setup_params=setup_params, client_id=client_id, cash_carry=request.cash_carry if request else None, ) report = finalize_closure_reception_transfers( prefix=prefix, id_kas=id_kas, clsrep_id=clsrep_id, report=report, setup_params=setup_params, raw_receipts=raw_receipts, ) return report # ----------------------------------------------------- # Operace s cenikem # ----------------------------------------------------- #od Milana ------------- @app.post("/cenik/items/{pokl}") def save_cen_items( pokl: str, cenp: data.CenPolCreate | list[data.CenPolCreate], auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth if isinstance(cenp, list): ok=cenik_delete_pokl(prefix, pokl) if ok: print(f'\nSmazan cenik pokladny {pokl} ') else: print(f'Mazani ceniku pokladny {pokl} selhalo nebo neexistuje') result=cen_add_batch(prefix, cenp) else: result=cen_add(prefix, cenp) return result #Milan 11.03.26 def _cenik_db_tuple(item: data.CenPolCreate | data.CenPol) -> tuple: return ( item.pokl, int(getattr(item, "id_card", 0) or 0), int(getattr(item, "c_druh", 0) or 0), getattr(item, "druh", "") or "", getattr(item, "spart", "") or "", getattr(item, "prn_no", "") or "", item.model_dump_json(), ) def cen_add_batch(cur_pref: str, items: list[data.CenPolCreate]) -> dict: table = f"{cur_pref}_cenik" rows = [ _cenik_db_tuple(i) for i in items ] try: with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.executemany( f'INSERT INTO "{table}" (pokl, id_card, c_druh, druh, spart, prn_no, data) VALUES (?, ?, ?, ?, ?, ?, ?)', rows, ) return { "ok": True, "inserted": len(rows), "error": "OK" } except Exception as e: return { "ok": False, "error": str(e), "inserted": 0 } #-----od Milana @app.get("/fstmenu/pokl/{pokl}", response_model=list[data.FstMenuKasa]) def get_fstmenu_for_pokl(pokl: str, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth logger.info(f"GET_fstmenu: prefix={prefix} pokl={pokl}") return fstmenu_load_for_pokl(prefix, pokl) def fstmenu_load_for_pokl(prefix, pokl): table = f"{prefix}_fstmenu" with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT c_karty, polozky FROM "{table}" WHERE id_kas = ? ''', (pokl,) ) rows = cur.fetchall() result = [] for c_karty, polozky in rows: if isinstance(polozky, str): polozky = json.loads(polozky) result.append({ "c_karty": c_karty, "polozky": polozky # už je JSON → nechaj tak }) return result @app.post("/save_cenpol/", status_code=204) def save_cen(cenp: data.CenPolCreate, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth logger.info(f'Save_CenPol {cenp.ch_name}') #Milan 11.03.26 result=cen_add(prefix, cenp) def cen_add(cur_pref: str, cenp: data.CenPolCreate) -> dict: table = f"{cur_pref}_cenik" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") #Milan 11.03.26 try: cur.execute( f'INSERT INTO "{table}" (pokl, id_card, c_druh, druh, spart, prn_no, data) VALUES (?, ?, ?, ?, ?, ?, ?)', _cenik_db_tuple(cenp), ) return { "ok": True, "inserted": 1, "error": "OK" } except Exception as e: return { "ok": False, "error": str(e), "inserted": 0 } @app.get("/cenik/pokl/{pokl}", response_model=list[data.CenPol]) def get_cenik_for_pokl(pokl: str, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth logger.info(f"GET_cenik: prefix={prefix} pokl={pokl}") return cen_load_for_pokl(prefix, pokl) def cen_load_for_pokl(prefix, pokl): table = f"{prefix}_cenik" with get_db() as conn: cur = conn.cursor() cur.execute( f'SELECT rowid, data FROM "{table}" WHERE pokl=?', (pokl,) ) rows = cur.fetchall() result = [] for rowid, json_data in rows: obj, changed = model_from_json_migrated(data.CenPol, json_data) cur.execute( f'UPDATE "{table}" SET id_card=?, c_druh=?, druh=?, spart=?, prn_no=?, data=? WHERE rowid=?', ( int(getattr(obj, "id_card", 0) or 0), int(getattr(obj, "c_druh", 0) or 0), getattr(obj, "druh", "") or "", getattr(obj, "spart", "") or "", getattr(obj, "prn_no", "") or "", obj.model_dump_json() if changed else json_data, rowid, ) ) result.append(obj) conn.commit() return result @app.delete("/cenik/pokl/{pokl}", status_code=204) def delete_cenik_pokl(pokl: str, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth if not cenik_delete_pokl(prefix, pokl): raise HTTPException(404, f"Ceník pro pokladnu {pokl} neexistuje") def cenik_delete_pokl(cur_pref: str, pokl: str) -> bool: table = f"{cur_pref}_cenik" with get_db() as conn: cur = conn.cursor() cur.execute( f'DELETE FROM "{table}" WHERE pokl = ?', (pokl,) ) deleted = cur.rowcount return deleted > 0 @app.post("/cenik/replace/", status_code=204) def replace_cenik(cenik: data.Cenik, auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, user, client_id = auth cenik_replace(prefix, cenik) def cenik_replace(cur_pref: str, cenik: data.Cenik) -> None: table = f"{cur_pref}_cenik" with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") cur.execute(f'DELETE FROM "{table}"') for p in cenik.cenpol: cur.execute( f'INSERT INTO "{table}" (pokl, id_card, c_druh, druh, spart, prn_no, data) VALUES (?, ?, ?, ?, ?, ?, ?)', _cenik_db_tuple(p), ) def _cenik_text_db_tuple(item: data.CenikText) -> tuple: lang = str(getattr(item, "jazyk", "sk") or "sk").strip().lower() if lang == "cz": lang = "cs" text = item.model_copy(update={"jazyk": lang}) return ( int(text.id_card or 0), text.jazyk, text.d_name or "", text.ch_name or "", text.dat_cas_zm or "", text.model_dump_json(), ) @app.post("/foodman/zmeny") def update_s_zmeny( auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, _, _ = auth return touch_foodman_data_change(prefix) @app.post("/cenik/texty") def replace_cenik_texty( texty: list[data.CenikText], lang: str | None = Query(None), auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, _, _ = auth return cenik_texty_replace(prefix, texty, lang=lang) @app.post("/cenik/texty/{pokl}") def replace_cenik_texty_for_pokl( pokl: str, texty: list[data.CenikText], lang: str | None = Query(None), auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, _, _ = auth return cenik_texty_replace(prefix, texty, lang=lang) def cenik_texty_replace(prefix: str, texty: list[data.CenikText], lang: str | None = None) -> dict: table = f"{prefix}_cenik_texty" lang = (lang or "").strip().lower() if lang == "cz": lang = "cs" rows = [ _cenik_text_db_tuple( item.model_copy(update={"jazyk": lang}) if lang else item, ) for item in texty ] try: with get_db() as conn: cur = conn.cursor() cur.execute("BEGIN IMMEDIATE") if lang: cur.execute(f'DELETE FROM "{table}" WHERE jazyk = ?', (lang,)) else: cur.execute(f'DELETE FROM "{table}"') cur.executemany( f''' INSERT INTO "{table}" (id_card, jazyk, d_name, ch_name, dat_cas_zm, data) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id_card, jazyk) DO UPDATE SET d_name=excluded.d_name, ch_name=excluded.ch_name, dat_cas_zm=excluded.dat_cas_zm, data=excluded.data ''', rows, ) return {"ok": True, "inserted": len(rows), "error": "OK"} except Exception as e: logger.exception("Cenik texty replace failed") return {"ok": False, "inserted": 0, "error": str(e)} @app.get("/cenik/texty", response_model=list[data.CenikText]) def get_cenik_texty( lang: str = Query("sk"), auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, _, _ = auth return cenik_texty_load(prefix, lang) @app.get("/cenik/texty/{pokl}", response_model=list[data.CenikText]) def get_cenik_texty_for_pokl( pokl: str, lang: str = Query("sk"), auth: tuple[str, str, str] = Depends(auth_ctx)): prefix, _, _ = auth return cenik_texty_load(prefix, lang) def cenik_texty_load(prefix: str, lang: str) -> list[data.CenikText]: table = f"{prefix}_cenik_texty" lang = str(lang or "sk").strip().lower() if lang == "cz": lang = "cs" with get_db() as conn: cur = conn.cursor() cur.execute( f''' SELECT data FROM "{table}" WHERE jazyk = ? ORDER BY id_card ''', (lang,), ) result = [] for (raw_json,) in cur.fetchall(): try: result.append(data.CenikText.model_validate_json(raw_json)) except Exception: logger.exception("Invalid cenik_texty JSON") return result #ucty_ram=data.Ucty(ucty=[]) #struktura pro serverove ukladani uctu do pameti # L.L. (22.06.2026) - Zaciatok:aktualizácia cenníka, setupu a mapy stolov podľa datumu a času poslednej zmeny def init_s_zmena_schema(prefix: str, cur): table = f"{prefix}_s_zmena" cur.execute(f""" CREATE TABLE IF NOT EXISTS "{table}" ( id INT PRIMARY KEY, zmena TIMESTAMP NOT NULL ) """) def foodman_change_timestamp() -> str: return datetime.now().isoformat(timespec="milliseconds") def ensure_foodman_data_change_cur( cur: sqlite3.Cursor, prefix: str, ) -> data.FoodManDataChange: init_s_zmena_schema(prefix, cur) table = f"{prefix}_s_zmena" cur.execute( f'SELECT zmena FROM "{table}" ', ) row = cur.fetchone() if row: return data.FoodManDataChange(zmena=row[0]) zmena = foodman_change_timestamp() cur.execute( f'INSERT INTO "{table}" ( zmena) VALUES (?)', (zmena), ) return data.FoodManDataChange(zmena=zmena) def touch_foodman_data_change_cur( cur: sqlite3.Cursor, prefix: str, ) -> dict: init_s_zmena_schema(prefix, cur) table = f"{prefix}_s_zmena" zmena = foodman_change_timestamp() cur.execute( f'SELECT zmena FROM "{table}" LIMIT 1', ) row = cur.fetchone() if row: cur.execute( f'UPDATE "{table}" SET zmena = ?', (zmena,), ) else: cur.execute( f'INSERT INTO "{table}" (zmena) VALUES (?)', (zmena,), ) return {"ok": True, "zmena": zmena} def touch_foodman_data_change(prefix: str) -> dict: with get_db() as conn: cur = conn.cursor() result=touch_foodman_data_change_cur(cur, prefix) conn.commit() return result # L.L. (22.06.2026) - Koniec: aktualizácia cenníka, setupu a mapy stolov podľa datumu a času poslednej zmeny #migrace json dle modelu def migrate_dict_to_model(model_cls, data: dict): #Rekurzivně doplní chybějící pole podle Pydantic modelu. #Vrací (data, changed) changed = False for name, field in model_cls.model_fields.items(): # pole chybí if name not in data: if field.default is not None: data[name] = field.default changed = True elif field.default_factory is not None: data[name] = field.default_factory() changed = True value = data.get(name) # -------- nested BaseModel -------- if isinstance(value, dict) and isinstance(field.annotation, type) and issubclass(field.annotation, BaseModel): new_val, ch = migrate_dict_to_model(field.annotation, value) data[name] = new_val changed |= ch # -------- list[BaseModel] -------- if isinstance(value, list): inner = getattr(field.annotation, "__args__", None) if inner: inner_type = inner[0] if isinstance(inner_type, type) and issubclass(inner_type, BaseModel): new_list = [] for item in value: if isinstance(item, dict): new_item, ch = migrate_dict_to_model(inner_type, item) changed |= ch new_list.append(new_item) else: new_list.append(item) data[name] = new_list return data, changed def model_from_json_migrated(model_cls, json_str: str): data = json.loads(json_str) data, changed = migrate_dict_to_model(model_cls, data) obj = model_cls.model_validate(data) return obj, changed import uuid def migrate_ucet_payload(payload: dict): changed = False if "round50" not in payload: payload["round50"] = 0.0 changed = True fiscal_result = payload.get("fiscal_result") if isinstance(fiscal_result, dict) and "response" in fiscal_result: fiscal_result.pop("response", None) changed = True for payment in payload.get("platby", []) or []: if isinstance(payment, dict) and "round50" in payment: payment.pop("round50", None) changed = True if "guests" not in payload: payload["guests"] = [{"id": "g1", "name": "Hosť 1"}] payload["guest_count"] = 1 changed = True if "courses" not in payload: payload["courses"] = [{"id": "c1", "name": "Chod 1"}] payload["course_count"] = 1 changed = True for p in payload.get("poloz", []): if "line_id" not in p: p["line_id"] = uuid.uuid4().hex changed = True if "group_id" not in p: # obyčajná položka = vlastná group p["group_id"] = p["line_id"] changed = True if "parent_id" not in p: p["parent_id"] = None changed = True if "typ_menu" not in p: p["typ_menu"] = 0 changed = True if "pol_pocet" not in p: p["pol_pocet"] = 1 changed = True if "def_cena" not in p: p["def_cena"] = p["cena"] changed = True if "def_dph" not in p: p["def_dph"] = p["dph"] changed = True if "def_hlad" not in p: p["def_hlad"] = p["cenhlad"] changed = True if "zpravy" not in p: p["zpravy"] = [] changed = True #if "guests" not in p: # p["guests"] = [{"id": "g1", "name": "Hosť 1"}] # p["guest_count"] = 1 # changed = True #if "courses" not in p: # p["courses"] = [{"id": "c1", "name": "Chod 1"}] # p["courses"] = 1 # changed = True return payload, changed aa=data.Ucet(autor="Petr Kobrle", poloz=[])