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

2628 lines
95 KiB
Python

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.scrollview import ScrollView
from kivy.metrics import dp
from kivy.logger import Logger
from kivy.uix.modalview import ModalView
from kivy.graphics import Color, RoundedRectangle
from urllib.parse import quote
from kivy.uix.image import Image
from kivy.core.image import Image as CoreImage
import qrcode
from decimal import Decimal, ROUND_HALF_UP
from uuid import uuid4
from io import BytesIO
from numberpad import NumberPad
import api_call
import data
import bankterm_service
# JQ
import threading
from kivy.clock import Clock
import requests
import uuid
from datetime import datetime, date, time as dt_time
from requests.auth import HTTPBasicAuth
import json
# JQ
class PaymentItem:
"""
Jedna platba v PaymentDialogu.
Uchovává částku v originální měně a přepočet do CZK.
"""
def __init__(
self,
ptype,
suma: float,
suma_czk: float,
note: str | None = None,
hotel_charge=None,
p_kopii: int | None = None,
tip: float = 0.0,
adjustment_ids=None,
rounding_delta: float = 0.0,
terminal_result: dict | None = None,
):
self.id = str(uuid4())
# typ platby (PaymentType)
self.ptype = ptype
# částka v měně platby
self.suma = round(float(suma), 2)
# částka přepočtená do CZK
self.suma_czk = round(float(suma_czk), 2)
self.note = note
self.hotel_charge = hotel_charge
copy_count = p_kopii if p_kopii is not None else getattr(ptype, "p_kopii", 1)
try:
self.p_kopii = max(int(copy_count), 0)
except Exception:
self.p_kopii = 1
self.tip = round(float(tip or 0), 2)
self.adjustment_ids = list(adjustment_ids or [])
self.rounding_delta = round(float(rounding_delta or 0), 2)
self.terminal_result = dict(terminal_result or {})
def spd_qr(*, iban: str, amount_czk: float, message: str = "", vs: str | None = None) -> str:
iban = iban.replace(" ", "")
parts = [
"SPD*1.0",
f"ACC:{iban}",
f"AM:{amount_czk:.2f}",
"CC:CZK",
]
if vs:
parts.append(f"X-VS:{vs}")
if message:
parts.append(f"MSG:{quote(message[:60])}")
return "*".join(parts)
def make_qr_texture(
*,
iban: str,
amount_czk: float,
message: str,
vs: str | None = None,
):
qr_text = spd_qr(
iban=iban,
amount_czk=amount_czk,
message=message,
vs=vs,
)
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=6,
border=1,
)
qr.add_data(qr_text)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
core = CoreImage(buf, ext="png")
return core.texture
class QRPreviewDialog(ModalView):
def __init__(self, texture, on_confirm, **kw):
super().__init__(
size_hint=(None, None),
size=(dp(360), dp(440)),
auto_dismiss=False,
**kw
)
self.on_confirm = on_confirm
root = BoxLayout(orientation="vertical", padding=dp(8), spacing=dp(8))
img = Image(texture=texture, allow_stretch=True)
root.add_widget(img)
btn = Button(
text="ZAPLACENO",
size_hint_y=None,
height=dp(48),
)
btn.bind(on_press=self._confirm)
root.add_widget(btn)
self.add_widget(root)
def _confirm(self, *_):
self.dismiss()
self.on_confirm()
# =====================================================
# MODELY
# =====================================================
class PaymentDialog(ModalView):
def __init__(self, kasutxt, controller, ucet, payment_types, on_done, setup, on_cancel=None, **kw):
super().__init__(
size_hint=(None, None),
size=(dp(760), dp(520)),
auto_dismiss=False,
**kw
)
self.setup = setup
self.ucet = ucet
self.on_done = on_done
self.on_cancel = on_cancel
self.kasutxt = kasutxt
self.payments: list[PaymentItem] = []
self.payment_types = payment_types
self.controller = controller
self.discount_abs = 0.0
self.discount_pct: float | None = None
# JQ Platba kartou
self.current_service_id = None
self.card_payment_running = False
self.terminal_type = "BESTERON"
self.sale_id = "Alto/foodw32"
self.terminal_url = "http://10.109.39.73:7500/"
self.terminal_id = "Foodw32"
self.terminal_user = "user"
self.terminal_password = "pass"
# JQ Konec Platba kartou
root = BoxLayout(orientation="vertical", padding=dp(6), spacing=dp(6))
# ================= INFO =================
self.lbl_total = Label()
self.lbl_paid = Label()
self.lbl_remain = Label()
info = GridLayout(cols=2, size_hint_y=None, height=dp(90))
info.add_widget(Label(text="CELKEM"))
info.add_widget(self.lbl_total)
info.add_widget(Label(text="ZAPLACENO"))
info.add_widget(self.lbl_paid)
info.add_widget(Label(text="ZBÝVÁ"))
info.add_widget(self.lbl_remain)
root.add_widget(info)
# ================= STŘED =================
mid = BoxLayout(spacing=dp(6))
self.list_box = GridLayout(cols=1, spacing=dp(4))
mid.add_widget(self.list_box)
right = GridLayout(cols=1, size_hint_x=None, width=dp(160), spacing=dp(4))
for pt in self.payment_types:
b = Button(text=pt.name)
b.bind(on_press=lambda _, p=pt: self._add_payment(p))
right.add_widget(b)
mid.add_widget(right)
root.add_widget(mid)
# ================= DOLNÍ =================
bottom = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(6))
bottom.add_widget(Button(text="SLEVA", on_press=self._on_discount))
bottom.add_widget(Button(text="SLEVA %", on_press=self._on_discount_pct))
# bottom.add_widget(Button(text="ZRUŠIT", on_press=lambda *_: self._cancel()))
# JQ
bottom.add_widget(Button(text="ZRUŠIT", on_press=lambda *_: self._cancel_all()))
# JQ Konec
root.add_widget(bottom)
self.add_widget(root)
self._refresh()
# =====================================================
# STAV
# =====================================================
def _total_items(self) -> float:
return float(self.ucet.total_czk())
def _discount(self) -> float:
return float(self.discount_abs or 0)
def _to_pay(self) -> float:
return max(self._total_items() - self._discount(), 0.0)
def _paid(self) -> float:
return sum((p.suma_czk for p in self.payments), 0.0)
def _remaining(self) -> float:
return max(self._to_pay() - self._paid(), 0.0)
# =====================================================
# UI
# =====================================================
def _refresh(self):
self.lbl_total.text = f"{self._total_items():.2f} + {self._currency()} "
self.lbl_paid.text = f"{self._paid():.2f}+{self._currency()}"
self.lbl_remain.text = f"{self._remaining():.2f}+{self._currency()}"
self.list_box.clear_widgets()
# -------- SLEVA --------
if self.discount_abs > 0:
row = BoxLayout(size_hint_y=None, height=dp(40))
txt = "SLEVA"
if self.discount_pct is not None:
txt += f" ({self.discount_pct:.2f}%)"
row.add_widget(Label(text=txt))
row.add_widget(Label(text=f"-{self.discount_abs:.2f}+{self._currency()}"))
self.list_box.add_widget(row)
# -------- PLATBY --------
for p in self.payments:
row = BoxLayout(size_hint_y=None, height=dp(40))
rate = float(p.ptype.rate)
if rate != 1.0:
left = f"{p.ptype.name}: {p.suma:.2f} {p.ptype.unit}"
right = f"= {p.suma_czk:.2f}+{self._currency()}"
else:
left = p.ptype.name
right = f"{p.suma_czk:.2f}+{self._currency()}"
row.add_widget(Label(text=left))
row.add_widget(Label(text=right))
btn = Button(text="≤", size_hint_x=None, width=dp(40))
btn.bind(on_press=lambda _, pid=p.id: self._remove_payment(pid))
row.add_widget(btn)
self.list_box.add_widget(row)
if self._remaining() <= 0.009:
self._finish()
# =====================================================
# PLATBY
# =====================================================
def _add_payment(self, ptype: data.PaymentType):
remaining_czk = float(self._remaining())
rate = float(ptype.rate)
initial = remaining_czk / rate if rate != 1.0 else remaining_czk
def accept(val: str):
if not val:
return
s = str(val).strip().replace(" ", "").replace(",", ".")
try:
if "/" in s:
num, den = s.split("/", 1)
suma = float(num) / float(den)
else:
suma = float(s)
except Exception:
return
if suma <= 0:
return
suma_czk = round(suma * rate, 2)
is_cash = (ptype.is_cash == True)
if not is_cash:
suma_czk = min(suma_czk, remaining_czk)
suma = round(suma_czk / rate, 2)
if suma_czk <= 0:
return
# QR platba
if ptype.code == "QR":
texture = make_qr_texture(
iban=self.setup.iban,
amount_czk=suma_czk,
message=f"Účet {self.ucet.ucislo or self.ucet.stul}",
vs=self.ucet.ucislo,
)
def confirmed(suma_=suma, suma_czk_=suma_czk):
self.payments.append(
PaymentItem(
ptype=ptype,
suma=round(suma_, 2),
suma_czk=round(suma_czk_, 2),
)
)
self._refresh()
QRPreviewDialog(texture=texture, on_confirm=confirmed).open()
return
# PLATBA kartou
if ptype.code == "CARD":
#if terminal_type == "BESTERON":
self._card_payment_async(ptype, suma, suma_czk)
# self._refresh()
return
# ostatní platby
self.payments.append(
PaymentItem(
ptype=ptype,
suma=round(suma, 2),
suma_czk=round(suma_czk, 2),
)
)
self._refresh()
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=2,
max_len=7,
initial_value=f"{initial:.2f}",
on_accept=accept,
).open()
def _remove_payment(self, pid: str):
self.payments = [p for p in self.payments if p.id != pid]
self._refresh()
# =====================================================
# SLEVA ABS
# =====================================================
def _on_discount(self, *_):
def accept(val: str):
try:
amt = float(val)
except Exception:
return
if amt <= 0:
return
amt = min(amt, self._remaining())
self.discount_abs = round(amt, 2)
self.discount_pct = None
self._refresh()
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=2,
max_len=6,
initial_value=f"{self._remaining():.2f}",
on_accept=accept,
).open()
# =====================================================
# SLEVA %
# =====================================================
def _on_discount_pct(self, *_):
total = self._total_items()
def accept(val: str):
try:
pct = float(val)
except Exception:
return
if pct <= 0:
return
discount = round(total * pct / 100.0, 2)
paid = self._paid()
if paid > total - discount:
needed_discount = total - paid
if needed_discount <= 0:
return
pct = round(needed_discount / total * 100.0, 2)
discount = needed_discount
self._popup_info(
"Sleva upravena",
f"Sleva upravena na {pct}% aby pokryla již zaplacenou částku."
)
self.discount_abs = round(discount, 2)
self.discount_pct = pct
self._refresh()
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=2,
max_len=5,
initial_value="10",
on_accept=accept,
).open()
# =====================================================
# POPUP
# =====================================================
def _popup_info(self, title, text):
box = BoxLayout(orientation="vertical", padding=dp(10), spacing=dp(10))
box.add_widget(Label(text=text))
btn = Button(text="OK", size_hint_y=None, height=dp(40))
popup = ModalView(size_hint=(None, None), size=(dp(320), dp(200)))
def _dismiss_after_touch(*_):
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
btn.bind(on_release=_dismiss_after_touch)
box.add_widget(btn)
popup.add_widget(box)
popup.open()
# =====================================================
# FINISH
# =====================================================
def _finish(self):
self.ucet.discount_abs = float(self.discount_abs)
self.ucet.platby = []
for p in self.payments:
self.ucet.platby.append(
data.Platba(
code=p.ptype.code,
nazev=p.ptype.name,
suma=float(p.suma),
suma_czk=float(p.suma_czk),
unit=p.ptype.unit,
rate=float(p.ptype.rate),
p_kopii=max(int(getattr(p, "p_kopii", getattr(p.ptype, "p_kopii", 1)) or 0), 0),
tip=float(getattr(p, "tip", 0) or 0),
)
)
self.on_done(self.ucet)
self.dismiss()
# =====================================================
# CANCEL
# =====================================================
def _cancel(self):
if self.on_cancel:
self.on_cancel()
self.dismiss()
# JQ
def _cancel_all(self):
if self.card_payment_running:
self._abort_transaction()
if self.on_cancel:
self.on_cancel()
self.dismiss()
def _card_payment_async(self, ptype, suma, suma_czk, note=None, hotel_charge=None, adjustment_ids=None, rounding_delta: float = 0.0):
if self.card_payment_running:
self._popup_info("Platba", "Už probíhá jiná karetní transakce.")
return
self.card_payment_running = True
popup = self._popup_wait(
"Platba kartou",
"Přiložte kartu k terminálu..."
)
def worker():
result = self._call_terminal_api(suma_czk)
def done(dt):
self.card_payment_running = False
popup.dismiss()
if result["success"]:
self._extract_receipt(result["raw"])
self.payments.append(
PaymentItem(
ptype=ptype,
suma=round(suma, 2),
suma_czk=round(suma_czk, 2),
note=note,
hotel_charge=hotel_charge,
adjustment_ids=adjustment_ids,
rounding_delta=rounding_delta,
terminal_result=result,
)
)
if hasattr(self, "_next_payment_hotel_charge"):
self._next_payment_hotel_charge = None
if hasattr(self, "_next_payment_adjustment_ids"):
self._next_payment_adjustment_ids = []
if getattr(self, "quick_complete", False):
self._finish()
return
self._refresh()
else:
if hasattr(self, "_next_payment_adjustment_ids"):
remove_ids = set(self._next_payment_adjustment_ids or [])
self.applied_discounts = [
app for app in getattr(self, "applied_discounts", [])
if app.id not in remove_ids
]
self._next_payment_adjustment_ids = []
if hasattr(self, "recalculate"):
self.recalculate()
self._popup_info("Platba zamítnuta", result.get("error", "Chyba terminálu"))
from kivy.clock import Clock
Clock.schedule_once(done)
import threading
threading.Thread(target=worker, daemon=True).start()
def _card_payment_result(self, success, ptype, suma, suma_czk, note=None, hotel_charge=None, adjustment_ids=None, rounding_delta: float = 0.0):
if success:
self.payments.append(
PaymentItem(
ptype=ptype,
suma=round(suma, 2),
suma_czk=round(suma_czk, 2),
note=note,
hotel_charge=hotel_charge,
adjustment_ids=adjustment_ids,
rounding_delta=rounding_delta,
)
)
if getattr(self, "quick_complete", False):
self._finish()
return
if hasattr(self, "_next_payment_hotel_charge"):
self._next_payment_hotel_charge = None
self._refresh()
else:
self._popup_info(
"Platba zamítnuta",
"Terminál odmítl transakci."
)
def _bank_terminal_client(self):
term = self._selected_bankterm()
term_data = getattr(term, "term_data", None) if term else None
config = bankterm_service.BankTerminalConfig(
terminal_type=str(getattr(term_data, "typ", "") or self.terminal_type),
url=self._bankterm_url(term_data) if term_data else self.terminal_url,
terminal_id=str(getattr(term_data, "terminal_id", "") or self.terminal_id),
sale_id=str(getattr(term_data, "sale_id", "") or self.sale_id),
user=str(getattr(term_data, "terminal_user", "") or self.terminal_user),
password=str(getattr(term_data, "terminal_password", "") or self.terminal_password),
currency="CZK",
timeout=60,
)
return bankterm_service.create_bank_terminal_client(config)
def _selected_bankterm(self):
printer = self.printer_by_no.get(str(self.selected_printer or ""))
id_term = str(getattr(printer, "id_term", "") or "").strip() if printer else ""
if not id_term:
return None
return self.bankterm_map.get(id_term)
def _selected_bankterm_type(self) -> str:
term = self._selected_bankterm()
term_data = getattr(term, "term_data", None) if term else None
return str(getattr(term_data, "typ", "") or "").strip().upper()
def _bankterm_url(self, term_data) -> str:
reqadr = str(getattr(term_data, "eft_reqadr", "") or "").strip()
if reqadr.startswith("http://") or reqadr.startswith("https://"):
return reqadr
host = str(getattr(term_data, "eft_ipadr", "") or "").strip()
if not host:
return self.terminal_url
protocol = str(getattr(term_data, "protokol", "") or "http").strip().lower()
if not protocol.startswith(("http://", "https://")):
protocol = f"{protocol}://"
port = int(getattr(term_data, "eft_rempor", 0) or getattr(term_data, "eft_lclpor", 0) or 0)
url = f"{protocol}{host}"
if port:
url = f"{url}:{port}"
if reqadr:
url = f"{url}/{reqadr.lstrip('/')}"
return url
def _call_terminal_api(self, amount):
result = self._bank_terminal_client().payment(float(amount))
if result.service_id:
self.current_service_id = result.service_id
return result.legacy_dict()
def _abort_transaction(self):
if not self.current_service_id:
return
self._bank_terminal_client().abort(self.current_service_id)
def _log_transaction(self, data):
with open("terminal_log.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps({
"time": datetime.utcnow().isoformat(),
"data": data
}) + "\n")
def _extract_receipt(self, resp):
text = self._bank_terminal_client().extract_receipt_text(resp)
if text:
print("=== SLIP ===")
print(text)
def _refund(self, original_service_id, amount):
result = self._bank_terminal_client().refund(original_service_id, float(amount))
return result.legacy_dict()
def _popup_wait(self, title, text):
box = BoxLayout(orientation="vertical", padding=dp(10), spacing=dp(10))
box.add_widget(Label(text=text))
btn = Button(text="ZRUŠIT", size_hint_y=None, height=dp(40))
popup = ModalView(size_hint=(None, None), size=(dp(320), dp(200)))
btn.bind(on_press=lambda *_: self._abort_transaction())
box.add_widget(btn)
popup.add_widget(box)
popup.open()
return popup
# JQ KONEC
class GuidedPaymentDialog(PaymentDialog):
STEP_DISCOUNT = "discount"
STEP_PAYMENT = "payment"
def __init__(
self,
ucet,
controller,
payment_types,
on_done,
setup,
on_cancel=None,
discounts=None,
discount_permissions=None,
discounts_all_allowed=False,
printers=None,
bankterms=None,
default_printer=None,
on_printer_change=None,
preferred_payment=None,
handler_runner=None,
discount_runner=None,
cenik_map=None,
kasutxt:data.KasUtxtRiadky=None,
quick_complete: bool = False,
**kw
):
ModalView.__init__(
self,
size_hint=(1, 1),
auto_dismiss=False,
**kw
)
self.setup = setup
self.ucet = ucet
self.on_done = on_done
self.on_cancel = on_cancel
self.handler_runner = handler_runner
self.discount_runner = discount_runner
self.controller = controller
self.payment_types = sorted(
list(payment_types or []),
key=lambda p: (getattr(p, "poradie", 0) or 0, str(getattr(p, "name", ""))),
)
self.discounts = list(discounts or [])
self.discount_permissions = {
str(x).strip()
for x in (discount_permissions or [])
if str(x).strip()
}
self.discounts_all_allowed = bool(discounts_all_allowed)
self.cenik_map = dict(cenik_map or {})
self.printers = list(printers or [])
self.printer_by_no = {
str(getattr(p, "prn_no", "") or ""): p
for p in self.printers
}
self.printer_map = {
getattr(p, "prn_no", ""): getattr(p, "prn_name", "")
for p in self.printers
}
self.bankterms = list(bankterms or [])
self.bankterm_map = {
str(getattr(term, "id_term", "") or ""): term
for term in self.bankterms
}
default_printer = str(default_printer or "").strip()
if default_printer and default_printer not in self.printer_by_no:
default_printer = ""
self.selected_printer = default_printer or (
getattr(self.printers[0], "prn_no", "") if self.printers else ""
)
self.on_printer_change = on_printer_change
self.kasutxt=kasutxt
self.preferred_payment = preferred_payment
self._preferred_opened = False
self.quick_complete = bool(quick_complete)
self.payments: list[PaymentItem] = []
self.applied_discounts: list[data.UcetDiscount] = [
self._coerce_discount_application(item)
for item in (getattr(self.ucet, "discounts_applied", []) or [])
]
self.selected_discount = None
self.discount_abs = 0.0
self.discount_pct: float | None = None
self._base_poloz = [
pol.model_copy(deep=True)
for pol in (getattr(self.ucet, "poloz", []) or [])
]
self._base_total_before_money = round(float(self.ucet.total_czk()), 2)
self._current_total = self._base_total_before_money
self._recalculating = False
self.loyalty_card = ""
self.loyalty_name = ""
self.loyalty_info = ""
self.send_receipt_email = bool(getattr(self.ucet, "send_receipt_email", False))
self.handler_info = ""
self._next_payment_note = None
self._next_payment_hotel_charge = None
self._next_payment_adjustment_ids: list[str] = []
self._handler_ptype = None
self.current_service_id = None
self.card_payment_running = False
self.terminal_type = "BESTERON"
self.sale_id = "Alto/foodw32"
self.terminal_url = "http://10.109.39.73:7500/"
self.terminal_id = "Foodw32"
self.terminal_user = "user"
self.terminal_password = "pass"
self.step = (
self.STEP_DISCOUNT
if self._has_discount_step()
else self.STEP_PAYMENT
)
self._build_ui()
self.recalculate()
self._refresh()
def on_open(self):
if self.step == self.STEP_PAYMENT and not self.quick_complete:
self._open_preferred_payment_once()
def run_quick_payment(self, ptype=None):
self.quick_complete = True
payment_type = ptype or self.preferred_payment
if not payment_type:
self._popup_info("Platba", "Nie je nastavený druh rýchlej platby.")
return False
if payment_type not in self.payment_types:
match = str(getattr(payment_type, "code", "") or "").strip()
payment_type = next(
(
p for p in self.payment_types
if str(getattr(p, "code", "") or "").strip() == match
),
None,
)
if not payment_type:
self._popup_info("Platba", "Nastavený druh platby nie je dostupný.")
return False
self.step = self.STEP_PAYMENT
self._begin_add_payment(payment_type)
return True
def on_touch_down(self, touch):
super().on_touch_down(touch)
return True
def on_touch_move(self, touch):
super().on_touch_move(touch)
return True
def on_touch_up(self, touch):
super().on_touch_up(touch)
return True
def _build_ui(self):
root = BoxLayout(orientation="vertical", padding=dp(8), spacing=dp(8))
self.add_widget(root)
header = BoxLayout(size_hint_y=None, height=dp(72), spacing=dp(8))
title_box = BoxLayout(orientation="vertical")
self.lbl_title = self._label("Platba", font_size=24, bold=True)
self.lbl_step = self._label("")
title_box.add_widget(self.lbl_title)
title_box.add_widget(self.lbl_step)
header.add_widget(title_box)
self.lbl_to_pay_top = self._label("", font_size=24, bold=True)
header.add_widget(self.lbl_to_pay_top)
self.btn_printer = Button(
text="Tlačiareň",
size_hint=(None, 1),
width=dp(190),
)
self._fit_button_text(self.btn_printer)
self.btn_printer.bind(on_release=self._open_printer_popup)
header.add_widget(self.btn_printer)
root.add_widget(header)
body = BoxLayout(spacing=dp(8))
nav = GridLayout(
cols=1,
size_hint=(None, 1),
width=dp(88),
spacing=dp(6),
)
self.btn_discount_step = Button(text="1\nZľava\n/karta")
self.btn_payment_step = Button(text="2\nPlatby")
self._fit_button_text(self.btn_discount_step, font_size=13)
self._fit_button_text(self.btn_payment_step, font_size=13)
self.btn_discount_step.bind(on_press=lambda *_: self._go_discount())
self.btn_payment_step.bind(on_press=lambda *_: self._go_payment())
nav.add_widget(self.btn_discount_step)
nav.add_widget(self.btn_payment_step)
body.add_widget(nav)
self.content = BoxLayout(orientation="vertical", spacing=dp(8))
body.add_widget(self.content)
summary = BoxLayout(
orientation="vertical",
size_hint=(None, 1),
width=dp(330),
spacing=dp(8),
padding=(dp(10), dp(10), dp(10), dp(10)),
)
self._paint_box(summary, (0.17, 0.17, 0.17, 1), radius=4)
self.lbl_customer = self._label("", font_size=21, bold=True, halign="center", size_hint_y=None, height=dp(32))
self.lbl_table = self._label("", font_size=14, halign="center", size_hint_y=None, height=dp(24))
summary.add_widget(self.lbl_customer)
summary.add_widget(self.lbl_table)
self.totals_box = BoxLayout(orientation="vertical", spacing=dp(2), size_hint_y=None, height=dp(154))
self.lbl_total = self._summary_value_row("Celková suma")
self.lbl_discount = self._summary_value_row("Zľava")
self.lbl_after_discount = self._summary_value_row("Suma po zľave")
self.lbl_tip = self._summary_value_row("TIP")
self.lbl_paid = self._summary_value_row("Zaplatené")
self.lbl_remain = self._summary_value_row("Zostáva")
summary.add_widget(self.totals_box)
self.lbl_loyalty = self._label("", font_size=14, size_hint_y=None, height=dp(28))
summary.add_widget(self.lbl_loyalty)
self.summary_box = GridLayout(cols=1, spacing=dp(5), size_hint_y=None)
self.summary_box.bind(
minimum_height=self.summary_box.setter("height")
)
scroll = ScrollView(do_scroll_x=False, do_scroll_y=True, bar_width=dp(10))
scroll.add_widget(self.summary_box)
summary.add_widget(scroll)
footer = BoxLayout(size_hint_y=None, height=dp(46), spacing=dp(8))
self.btn_summary_action = Button(text="")
self.btn_summary_copies = Button(text="")
self._fit_button_text(self.btn_summary_action, font_size=13)
self._fit_button_text(self.btn_summary_copies, font_size=13)
self.btn_summary_action.bind(on_release=self._summary_action)
self.btn_summary_copies.bind(on_release=self._edit_receipt_copies)
footer.add_widget(self.btn_summary_action)
footer.add_widget(self.btn_summary_copies)
summary.add_widget(footer)
body.add_widget(summary)
root.add_widget(body)
bottom = BoxLayout(size_hint_y=None, height=dp(56), spacing=dp(8))
self.btn_back = Button(text="Späť")
self.btn_cancel = Button(text="Zrušiť")
self.btn_cancel.background_color = (0.6, 0.2, 0.2, 1)
self.btn_primary = Button(text="Pokračovať")
self._fit_button_text(self.btn_back)
self._fit_button_text(self.btn_cancel)
self._fit_button_text(self.btn_primary)
self.btn_back.bind(on_press=lambda *_: self._back())
self.btn_cancel.bind(on_press=lambda *_: self._cancel_all())
self.btn_primary.bind(on_press=lambda *_: self._primary())
bottom.add_widget(self.btn_back)
bottom.add_widget(self.btn_cancel)
bottom.add_widget(self.btn_primary)
root.add_widget(bottom)
def _label(self, text="", **kwargs):
halign = kwargs.pop("halign", "left")
valign = kwargs.pop("valign", "middle")
lbl = Label(text=text, halign=halign, valign=valign, **kwargs)
lbl.bind(size=lambda inst, *_: setattr(inst, "text_size", inst.size))
return lbl
def _paint_box(self, widget, color, radius=0):
with widget.canvas.before:
bg_color = Color(*color)
corner = (dp(radius), dp(radius))
bg = RoundedRectangle(pos=widget.pos, size=widget.size, radius=[corner, corner, corner, corner])
def _sync_bg(inst, *_):
bg.pos = inst.pos
bg.size = inst.size
widget.bind(pos=_sync_bg, size=_sync_bg)
widget._bg_color_instr = bg_color
return widget
def _set_box_color(self, widget, color):
instr = getattr(widget, "_bg_color_instr", None)
if instr:
instr.rgba = color
def _fit_button_text(self, btn, font_size=16):
btn.font_size = font_size
btn.halign = "center"
btn.valign = "middle"
btn.bind(
size=lambda inst, *_:
setattr(inst, "text_size", (max(inst.width - dp(12), 1), max(inst.height - dp(8), 1)))
)
return btn
def _panel_title(self, text):
return self._label(text, font_size=21, bold=True, size_hint_y=None, height=dp(38))
def _row(self, left, right="", height=40):
row = BoxLayout(size_hint_y=None, height=dp(height), spacing=dp(6))
row.add_widget(self._label(left))
if right:
row.add_widget(self._label(right))
return row
def _summary_value_row(self, label):
row = BoxLayout(size_hint_y=None, height=dp(24), spacing=dp(8))
left = self._label(label, font_size=14)
right = self._label("", font_size=14, halign="right")
row.add_widget(left)
row.add_widget(right)
self.totals_box.add_widget(row)
return right
def _has_discount_step(self):
return self._discounts_enabled() or self._loyalty_enabled()
def _discounts_enabled(self):
return bool(getattr(self.setup, "is_sleva", False))
def _loyalty_enabled(self):
return bool(getattr(self.setup, "loyalty_system", ""))
def _total_items(self) -> float:
return round(float(getattr(self, "_base_total_before_money", 0.0) or 0.0), 2)
def _discount(self) -> float:
return round(float(self.discount_abs or 0), 2)
def _to_pay(self) -> float:
return max(round(float(getattr(self, "_current_total", self.ucet.total_czk()) or 0.0), 2), 0.0)
def _paid(self) -> float:
return round(sum((p.suma_czk for p in self.payments), 0.0), 2)
def _settlement_rounding_delta(self) -> float:
return round(sum((getattr(p, "rounding_delta", 0.0) or 0.0 for p in self.payments), 0.0), 2)
def _account_round50(self) -> float:
return round(self._paid() - self._to_pay(), 2)
def _paid_effective(self) -> float:
return round(self._paid() - self._settlement_rounding_delta(), 2)
def _remaining(self) -> float:
return max(round(self._to_pay() - self._paid_effective(), 2), 0.0)
def _change(self) -> float:
return max(round(self._paid_effective() - self._to_pay(), 2), 0.0)
def _tip_total(self) -> float:
return round(sum((getattr(p, "tip", 0.0) or 0.0 for p in self.payments), 0.0), 2)
def _currency(self) -> str:
return getattr(self.setup, "zkr_mena", "Kč") if self.setup else "Kč"
def _money(self, value) -> str:
return f"{float(value or 0):.2f} {self._currency()}"
def _payment_round_step(self, ptype) -> float:
mode = int(getattr(ptype, "round50", 0) or 0)
return {
5: 0.05,
1: 0.50,
4: 1.00,
2: 10.00,
}.get(mode, 0.0)
def _round_amount_for_payment(self, ptype, amount_czk: float) -> tuple[float, float]:
original = round(float(amount_czk or 0), 2)
step = self._payment_round_step(ptype)
if step <= 0:
return original, 0.0
rounded_decimal = (
Decimal(str(original)) / Decimal(str(step))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * Decimal(str(step))
rounded = round(float(rounded_decimal), 2)
return rounded, round(rounded - original, 2)
def _coerce_discount_application(self, item) -> data.UcetDiscount:
if isinstance(item, data.UcetDiscount):
return item.model_copy(deep=True)
if isinstance(item, dict):
return data.UcetDiscount(**item)
return data.UcetDiscount()
def _new_adjustment_id(self) -> str:
return str(uuid4())
def _line_key(self, pol) -> str:
return str(getattr(pol, "line_id", "") or "")
def _current_line_items(self, line_ids=None):
allowed = {str(x) for x in (line_ids or []) if str(x)}
result = []
for pol in (getattr(self.ucet, "poloz", []) or []):
if allowed and self._line_key(pol) not in allowed:
continue
qty = float(getattr(pol, "pocet", 0) or 0) / max(int(getattr(pol, "delitel", 1) or 1), 1)
if qty <= 0:
continue
if float(getattr(pol, "cena", 0) or 0) < 0:
continue
result.append(pol)
return result
def _price_for_level(self, pol, price_level):
cenpol = self.cenik_map.get(int(getattr(pol, "id_card", 0) or 0))
if not cenpol:
return None
price_level = str(price_level or "").strip()
cena = next((c for c in (getattr(cenpol, "ceny", []) or []) if str(getattr(c, "name", "")).strip() == price_level), None)
if not cena:
return None
delitel = int(getattr(pol, "delitel", 1) or 1)
if delitel == 2:
unit_price = getattr(cena, "cena2", getattr(cena, "cena", 0))
elif delitel == 3:
unit_price = getattr(cena, "cena3", None)
if unit_price is None:
unit_price = getattr(cena, "cena", 0)
elif delitel == 4:
unit_price = getattr(cena, "cena4", None)
if unit_price is None:
unit_price = getattr(cena, "cena", 0)
else:
unit_price = getattr(cena, "cena", 0)
return cena, float(unit_price or 0)
def _apply_price_level_to_lines(self, price_level, line_ids=None):
allowed = {str(x) for x in (line_ids or []) if str(x)}
for pol in (getattr(self.ucet, "poloz", []) or []):
if allowed and self._line_key(pol) not in allowed:
continue
price_data = self._price_for_level(pol, price_level)
if not price_data:
continue
cena, unit_price = price_data
typ_menu = int(getattr(pol, "typ_menu", 0) or 0)
if typ_menu not in (10, 11):
pol.cena = round(unit_price, 4)
pol.cenhlad = getattr(cena, "name", str(price_level))
pol.dph = getattr(cena, "dan", getattr(pol, "dph", ""))
pol.mena = getattr(cena, "mena", getattr(pol, "mena", ""))
def _line_total_current(self, pol) -> float:
qty = float(getattr(pol, "pocet", 0) or 0) / max(int(getattr(pol, "delitel", 1) or 1), 1)
return round(qty * float(getattr(pol, "cena", 0) or 0), 4)
def _distribute_adjustment(self, items, amount: float) -> float:
items = list(items or [])
if not items:
return 0.0
subtotal = round(sum(self._line_total_current(pol) for pol in items), 2)
if subtotal <= 0:
return 0.0
amount = round(float(amount or 0), 2)
if amount > 0:
amount = min(amount, subtotal)
if abs(amount) < 0.005:
return 0.0
remaining = amount
applied = 0.0
for idx, pol in enumerate(items):
line_total = round(self._line_total_current(pol), 2)
if line_total <= 0:
continue
if idx == len(items) - 1:
delta = remaining
else:
delta = round(amount * (line_total / subtotal), 2)
remaining = round(remaining - delta, 2)
new_total = round(line_total - delta, 2)
if new_total < 0:
delta = line_total
new_total = 0.0
qty = float(getattr(pol, "pocet", 0) or 0) / max(int(getattr(pol, "delitel", 1) or 1), 1)
if qty <= 0:
continue
pol.cena = round(new_total / qty, 4)
applied = round(applied + delta, 2)
return applied
def _update_discount_metadata(self):
total = round(float(self.ucet.total_czk()), 2)
self._current_total = total
self.discount_abs = round(self._base_total_before_money - total, 2)
self.discount_pct = None
money_apps = [app for app in self.applied_discounts if int(app.typ_zlavy or 0) != 2]
price_apps = [app for app in self.applied_discounts if int(app.typ_zlavy or 0) == 2]
self.selected_discount = self.applied_discounts[-1] if self.applied_discounts else None
self.ucet.discounts_applied = [app.model_copy(deep=True) for app in self.applied_discounts]
self.ucet.discounts_prorated = True
self.ucet.discount_abs = float(self.discount_abs)
names = [app.name for app in money_apps if app.name]
self.ucet.discount_name = ", ".join(names)
self.ucet.discount_id = money_apps[-1].id_zlavy if money_apps else (price_apps[-1].id_zlavy if price_apps else None)
try:
self.ucet.sumdph()
except Exception:
Logger.exception("PAYMENT recalculate: DPH recompute failed")
def _has_overpayment(self) -> bool:
return self._paid_effective() > self._to_pay() + 0.009
def _warn_overpayment_if_needed(self):
if self._has_overpayment():
self._popup_info(
"Platba",
"Zadané platby sú vyššie ako hodnota účtu po zľavách. Odstráňte alebo upravte niektorú platbu.",
)
def _discard_pending_payment_adjustments(self):
remove_ids = set(getattr(self, "_next_payment_adjustment_ids", []) or [])
if not remove_ids:
return
self.applied_discounts = [
app for app in self.applied_discounts
if app.id not in remove_ids
]
self._next_payment_adjustment_ids = []
self.recalculate()
def recalculate(self, show_overpaid_warning: bool = False):
if self._recalculating:
return
self._recalculating = True
try:
self.ucet.poloz = [
pol.model_copy(deep=True)
for pol in self._base_poloz
]
for pol in (getattr(self.ucet, "poloz", []) or []):
pol.cena_puv = float(getattr(pol, "cena", 0) or 0)
price_apps = [app for app in self.applied_discounts if int(app.typ_zlavy or 0) == 2]
for app in price_apps:
if app.price_level:
self._apply_price_level_to_lines(app.price_level, app.line_ids)
for pol in (getattr(self.ucet, "poloz", []) or []):
pol.cena_puv = float(getattr(pol, "cena", 0) or 0)
self._base_total_before_money = round(float(self.ucet.total_czk()), 2)
absolute_apps = [
app for app in self.applied_discounts
if int(app.typ_zlavy or 0) in (3, 5)
]
percent_apps = [
app for app in self.applied_discounts
if int(app.typ_zlavy or 0) in (1, 4)
]
for app in absolute_apps + percent_apps:
items = self._current_line_items(app.line_ids)
base = round(sum(self._line_total_current(pol) for pol in items), 2)
if base <= 0:
app.amount = 0.0
continue
typ = int(app.typ_zlavy or 1)
if typ in (1, 4):
pct = float(app.pct if app.pct is not None else app.value or 0)
amount = round(base * pct / 100.0, 2)
if typ == 4:
amount = -amount
app.pct = pct
else:
amount = abs(float(app.value or app.amount or 0))
if typ == 5:
amount = -amount
applied = self._distribute_adjustment(items, amount)
app.amount = round(applied, 2)
self._update_discount_metadata()
finally:
self._recalculating = False
if show_overpaid_warning:
self._warn_overpayment_if_needed()
def _receipt_copies(self) -> int:
if self.payments:
return max(max(int(getattr(p, "p_kopii", 1) or 0), 0) for p in self.payments)
return 1
def _can_finish(self):
if not self.payments:
return False
return abs(self._paid_effective() - self._to_pay()) <= 0.009
def _refresh(self):
self.lbl_step.text = (
"1/2 Zľavy a vernostná karta"
if self.step == self.STEP_DISCOUNT
else "2/2 Platby a potvrdenie"
)
sumazlavy=self._discount()
self.lbl_to_pay_top.text = f"K úhrade: {self._money(self._to_pay())}"
self.lbl_customer.text = self._customer_title()
self.lbl_table.text = self._account_subtitle()
self.lbl_total.text = self._money(self._total_items())
if sumazlavy > 0:
self.lbl_discount.text = f"-{float(sumazlavy or 0):.2f} {self._currency()}"
elif sumazlavy == 0:
self.lbl_discount.text = f"{float(sumazlavy or 0):.2f} {self._currency()}"
else:
self.lbl_discount.text = f"+{float(-sumazlavy or 0):.2f} {self._currency()}"
self.lbl_after_discount.text = self._money(self._to_pay())
self.lbl_tip.text = self._money(self._tip_total())
self.lbl_paid.text = self._money(self._paid())
remain_text = self._money(self._remaining())
if self._change() > 0:
remain_text = self._money(self._change())
self.lbl_remain.text = remain_text
self.lbl_loyalty.text = self._loyalty_summary()
self.btn_summary_action.text = self._summary_action_text()
self.btn_summary_copies.text = f"Kópií: {self._receipt_copies()}" if self.payments else "Kópie"
povolene = getattr(self.setup, "nkop_edit", True) if self.setup else True
if povolene :
self.btn_summary_copies.disabled = not bool(self.payments)
else:
self.btn_summary_copies.disabled = True
self._refresh_printer_button()
self._refresh_summary_list()
self.btn_discount_step.disabled = not self._has_discount_step()
self.btn_payment_step.disabled = False
self.btn_back.disabled = self.step == self.STEP_DISCOUNT
self.btn_primary.text = (
"Pokračovať na platbu"
if self.step == self.STEP_DISCOUNT
else "Vykonať platbu"
)
self.btn_primary.disabled = (
self.step == self.STEP_PAYMENT and not self._can_finish()
)
self._refresh_primary_button_style()
if self.step == self.STEP_DISCOUNT:
self._render_discount_step()
else:
self._render_payment_step()
def _refresh_summary_list(self):
self.summary_box.clear_widgets()
for app in self.applied_discounts:
typ = int(app.typ_zlavy or 1)
if typ == 2:
value = f"hladina {app.price_level}"
else:
value = app.amount
value = f"-{float(value or 0):.2f} {self._currency()}" if value > 0 else f"+{float(-value or 0):.2f} {self._currency()}"
self.summary_box.add_widget(self._row(
app.name or "Úprava účtu",
value,
))
if self.loyalty_card or self.loyalty_name:
self.summary_box.add_widget(self._row("Vernosť", self._loyalty_summary()))
for pay in self.payments:
self.summary_box.add_widget(self._payment_summary_row(pay))
if self.payments:
rounding_total = self._account_round50()
if abs(rounding_total) >= 0.005:
self.summary_box.add_widget(self._row("Zaokrúhlenie", self._money(rounding_total)))
def _customer_title(self):
if self.loyalty_name:
return self.loyalty_name
target = getattr(self.ucet, "hotel_charge", None)
if not target:
target = next((getattr(pay, "hotel_charge", None) for pay in self.payments if getattr(pay, "hotel_charge", None)), None)
if target:
guest_name = str(getattr(target, "guest_name", "") or "").strip()
if guest_name:
return guest_name
return f"Účet {getattr(self.ucet, 'stul', '') or ''}".strip() or "Účet"
def _account_subtitle(self):
parts = []
stul = getattr(self.ucet, "stul", None)
if stul:
parts.append(f"Stôl {stul}")
guests = getattr(self.ucet, "guest_count", 0) or 0
if guests:
parts.append(f"{guests} hostí")
return ", ".join(parts)
def _refresh_primary_button_style(self):
if self.step == self.STEP_PAYMENT and self._can_finish():
self.btn_primary.background_color = (0.2, 0.6, 0.2, 1)
else:
#self.btn_primary.background_normal = ""
self.btn_primary.background_color = (0.32, 0.32, 0.32, 1)
def _summary_action_text(self):
if not self.payments:
return "Predúčet"
return "Účtenka emailom\nÁno" if self.send_receipt_email else "Účtenka emailom\nNie"
def _payment_summary_row(self, pay):
row = BoxLayout(size_hint_y=None, height=dp(64), spacing=dp(5), padding=(dp(4), 0, dp(4), 0))
self._paint_box(row, (0.23, 0.23, 0.23, 1), radius=3)
left = BoxLayout(orientation="vertical", spacing=dp(1))
label = pay.ptype.name
amount_text = self._money(pay.suma_czk)
if float(pay.ptype.rate or 1.0) != 1.0:
label = f"{pay.ptype.name}: {pay.suma:.2f} {pay.ptype.unit}"
amount_text = self._money(pay.suma_czk)
left.add_widget(self._label(label, font_size=14, size_hint_y=None, height=dp(24)))
detail = self._payment_detail_text(pay)
left.add_widget(self._label(detail, font_size=12, size_hint_y=None, height=dp(20)))
row.add_widget(left)
row.add_widget(self._label(amount_text, font_size=14, halign="right", size_hint=(None, 1), width=dp(82)))
btn_tip = Button(text=self._tip_button_text(pay), size_hint=(None, 1), width=dp(58))
self._fit_button_text(btn_tip, font_size=12)
btn_tip.bind(on_release=lambda _, pid=pay.id: self._ask_tip(pid))
row.add_widget(btn_tip)
btn = Button(text="X", size_hint=(None, 1), width=dp(36))
btn.bind(on_press=lambda _, pid=pay.id: self._remove_payment(pid))
row.add_widget(btn)
return row
def _payment_detail_text(self, pay):
parts = []
if getattr(pay, "note", None):
parts.append(str(pay.note))
if getattr(pay, "hotel_charge", None):
target = pay.hotel_charge
room = str(getattr(target, "room_number", "") or "").strip()
guest = str(getattr(target, "guest_name", "") or "").strip()
hotel = str(getattr(target, "reception_name", "") or "").strip()
target_text = " / ".join(x for x in (hotel, room, guest) if x)
if target_text:
parts.append(target_text)
if self._terminal_result_success(pay):
parts.append("Terminál OK")
elif self._requires_direct_terminal_payment(pay):
parts.append("Terminál pri ukončení")
if getattr(pay, "tip", 0):
parts.append(f"TIP {self._money(pay.tip)}")
return " | ".join(parts) or "Zadať TIP"
def _tip_button_text(self, pay):
tip = getattr(pay, "tip", 0.0) or 0.0
return f"TIP\n{tip:.2f}" if tip else "TIP"
def _loyalty_summary(self):
if self.loyalty_name:
return self.loyalty_name
if self.loyalty_card:
return f"Karta {self.loyalty_card}"
return "Vernosť: -"
def _render_discount_step(self):
self.content.clear_widgets()
self.content.add_widget(self._panel_title("Zľavy a vernostný systém"))
if self._discounts_enabled():
discounts = self._available_discounts()
if discounts:
self.content.add_widget(self._label("Dostupné zľavy na účet", size_hint_y=None, height=dp(28)))
grid = GridLayout(cols=2, spacing=dp(6), size_hint_y=None)
grid.bind(minimum_height=grid.setter("height"))
for zlava in discounts:
btn = Button(
text=self._discount_button_text(zlava),
size_hint_y=None,
height=dp(58),
)
self._fit_button_text(btn, font_size=15)
btn.bind(on_press=lambda _, z=zlava: self._select_discount(z))
grid.add_widget(btn)
scroll = ScrollView(do_scroll_x=False, do_scroll_y=True)
scroll.add_widget(grid)
self.content.add_widget(scroll)
else:
self.content.add_widget(self._label("Nie je dostupná žiadna zľava na účet."))
else:
self.content.add_widget(self._label("Zľavy nie sú povolené v nastavení pokladne."))
loyalty_box = BoxLayout(size_hint_y=None, height=dp(58), spacing=dp(6))
if self._loyalty_enabled():
btn_card = Button(text="Načítať kartu")
btn_system = Button(text="Vernostný systém")
self._fit_button_text(btn_card)
self._fit_button_text(btn_system)
btn_card.bind(on_press=lambda *_: self._scan_loyalty_card())
btn_system.bind(on_press=lambda *_: self._open_loyalty_system())
loyalty_box.add_widget(btn_card)
loyalty_box.add_widget(btn_system)
self.content.add_widget(loyalty_box)
actions = BoxLayout(size_hint_y=None, height=dp(54), spacing=dp(6))
btn_clear = Button(text="Bez zľavy")
btn_continue = Button(text="Pokračovať")
self._fit_button_text(btn_clear)
self._fit_button_text(btn_continue)
btn_clear.bind(on_press=lambda *_: self._clear_discount())
btn_continue.bind(on_press=lambda *_: self._go_payment())
actions.add_widget(btn_clear)
actions.add_widget(btn_continue)
self.content.add_widget(actions)
def _render_payment_step(self):
self.content.clear_widgets()
self.content.add_widget(self._panel_title("Druhy platby"))
allowed = self._allowed_payment_types()
if not allowed:
self.content.add_widget(self._label("Nie je dostupný žiadny povolený druh platby."))
return
grid = GridLayout(cols=2, spacing=dp(6), size_hint_y=None)
grid.bind(minimum_height=grid.setter("height"))
for ptype in allowed:
text = self._payment_button_text(ptype)
btn = Button(
text=text,
size_hint_y=None,
height=self._payment_button_height(text),
)
self._fit_button_text(btn, font_size=14)
btn.bind(on_press=lambda _, p=ptype: self._begin_add_payment(p))
grid.add_widget(btn)
scroll = ScrollView(do_scroll_x=False, do_scroll_y=True)
scroll.add_widget(grid)
self.content.add_widget(scroll)
if self.payments:
fiscal = "fiškálne" if self.payments[0].ptype.fiscal else "nefiškálne"
self.content.add_widget(self._label(
f"Ďalšie platby sú filtrované na {fiscal} platby.",
size_hint_y=None,
height=dp(34),
))
def _payment_button_text(self, ptype):
text = str(getattr(ptype, "name", "") or "").strip()
if getattr(ptype, "unit", ""):
text += f"\n{ptype.unit}"
return text
def _payment_button_height(self, text):
line_count = sum(
max(1, (len(line) + 23) // 24)
for line in (str(text or "").splitlines() or [""])
)
return dp(max(76, 30 + line_count * 24))
def _go_discount(self):
if not self._has_discount_step():
return
self.step = self.STEP_DISCOUNT
self._refresh()
def _go_payment(self):
self.step = self.STEP_PAYMENT
self._refresh()
self._open_preferred_payment_once()
def _back(self):
if self.step == self.STEP_PAYMENT and self._has_discount_step():
self.step = self.STEP_DISCOUNT
self._refresh()
def _primary(self):
if self.step == self.STEP_DISCOUNT:
self._go_payment()
return
self._finish()
def _open_preferred_payment_once(self):
if self._preferred_opened or not self.preferred_payment:
return
if self.preferred_payment not in self.payment_types:
return
self._preferred_opened = True
Clock.schedule_once(lambda *_: self._begin_add_payment(self.preferred_payment), 0.05)
def _available_discounts(self):
now = datetime.now()
return [
zlava
for zlava in self.discounts
if self._discount_allowed(zlava, now)
]
def _discount_allowed(self, zlava, now):
if getattr(zlava, "naucet", 0) != 1:
return False
if self._has_single_only_discount():
return False
if int(getattr(zlava, "ibajedna", 0) or 0) == 1 and self.applied_discounts:
return False
if not self._discount_permission_ok(zlava):
return False
if not self._date_ok(zlava, now.date()):
return False
if not self._time_ok(zlava, now.time()):
return False
if not self._day_ok(zlava, now.isoweekday()):
return False
if not self._month_ok(zlava, now.month):
return False
if int(getattr(zlava, "ajpocenovejhladine", 0) or 0) != 1 and self._has_nondefault_price_level():
return False
if int(getattr(zlava, "ajpozlavenapolozku", 0) or 0) != 1 and self._has_item_discount():
return False
if not self._discountable_items(zlava):
return False
return True
def _has_single_only_discount(self) -> bool:
return any(int(getattr(app, "ibajedna", 0) or 0) == 1 for app in self.applied_discounts)
def _discount_permission_ok(self, zlava):
if self.discounts_all_allowed:
return True
if not self.discount_permissions:
return False
candidates = {
str(getattr(zlava, "idriadok", "")).strip(),
str(getattr(zlava, "meno", "")).strip(),
str(getattr(zlava, "prg_dotaz", "")).strip(),
str(getattr(zlava, "premenna", "")).strip(),
}
return bool(candidates & self.discount_permissions)
def _date_ok(self, zlava, today):
datumod = self._as_date(getattr(zlava, "datumod", None))
datumdo = self._as_date(getattr(zlava, "datumdo", None))
if datumod and today < datumod:
return False
if datumdo and today > datumdo:
return False
return True
def _as_date(self, value):
if not value:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value)[:10])
except Exception:
return None
def _time_ok(self, zlava, now_time):
casod = self._as_time(getattr(zlava, "casod", ""))
casdo = self._as_time(getattr(zlava, "casdo", ""))
if not casod and not casdo:
return True
if casod and not casdo:
return now_time >= casod
if casdo and not casod:
return now_time <= casdo
if casod <= casdo:
return casod <= now_time <= casdo
return now_time >= casod or now_time <= casdo
def _as_time(self, value):
value = str(value or "").strip()
if not value:
return None
try:
hh, mm = value[:5].split(":", 1)
return dt_time(int(hh), int(mm))
except Exception:
return None
def _day_ok(self, zlava, day_no):
flags = [int(getattr(zlava, f"den{i}", 0) or 0) for i in range(1, 8)]
if not any(flags):
return True
return flags[day_no - 1] != 0
def _month_ok(self, zlava, month_no):
flags = [int(getattr(zlava, f"mes{i:02}", 0) or 0) for i in range(1, 13)]
if not any(flags):
return True
return flags[month_no - 1] != 0
def _discount_button_text(self, zlava):
return f"{self._discount_name(zlava)}\n{self._discount_value_text(zlava)}"
def _discount_name(self, zlava):
name = str(getattr(zlava, "meno", "") or "").strip()
return name or f"Zľava {getattr(zlava, 'idriadok', '')}"
def _discount_value_text(self, zlava):
amount, pct = self._discount_amount_for(zlava)
typ = int(getattr(zlava, "typ_zlavy", 1) or 1)
zadanie = int(getattr(zlava, "zl_zadanie", 1) or 1)
if zadanie == 1:
if typ == 2:
return "Zadať cenovú hladinu"
return "Zadať %" if typ == 1 or typ == 4 else f"Zadať {self._currency()}"
if typ == 2:
return f"Cenová hladina {int(float(getattr(zlava, 'zl_koef', 0) or 0))}"
sign = "-" if amount >= 0 else "+"
shown = abs(amount)
if pct is not None:
return f"{pct:.2f}% ({sign}{shown:.2f} {self._currency()})"
return f"{sign}{shown:.2f} {self._currency()}"
def _discount_amount_for(self, zlava):
total = self._discount_base(zlava)
koef = float(getattr(zlava, "zl_koef", 0) or 0)
typ = int(getattr(zlava, "typ_zlavy", 1) or 1)
zadanie = int(getattr(zlava, "zl_zadanie", 1) or 1)
if typ == 2 or zadanie == 1:
return 0.0, None
if typ == 3:
amount = round(koef, 2)
if amount > 0:
amount = min(amount, total)
return amount, None
if typ == 5:
amount = round(-koef, 2)
return amount, None
pct = round(koef, 2)
amount = round(total * pct / 100.0, 2)
if typ == 4:
amount = -amount
if amount > 0:
amount = min(amount, total)
return amount, pct
def _select_discount(self, zlava):
if int(getattr(zlava, "zl_zadanie", 1) or 1) == 1:
self._ask_discount_value(zlava)
return
if int(getattr(zlava, "typ_zlavy", 1) or 1) == 2:
self._apply_price_level_discount(zlava)
return
amount, pct = self._discount_amount_for(zlava)
self._apply_selected_discount(zlava, amount, pct)
def _apply_price_level_discount(self, zlava):
try:
price_level = str(int(float(getattr(zlava, "zl_koef", 0) or 0)))
except Exception:
self._popup_info("Zľava", "Neplatná cenová hladina.")
return
if not price_level or price_level == "0":
self._popup_info("Zľava", "Neplatná cenová hladina.")
return
self._apply_selected_discount(zlava, 0.0, None, price_level=price_level)
def _make_discount_application(self, zlava, amount=0.0, pct=None, price_level="", source="account", payment_type=None):
typ = int(getattr(zlava, "typ_zlavy", 1) or 1)
if typ == 2 and not price_level:
price_level = str(int(float(getattr(zlava, "zl_koef", 0) or 0)))
line_ids = [
self._line_key(pol)
for pol in self._discountable_items(zlava)
if self._line_key(pol)
]
if not line_ids:
line_ids = [self._line_key(pol) for pol in self._current_line_items() if self._line_key(pol)]
if typ in (1, 4):
value = float(pct if pct is not None else getattr(zlava, "zl_koef", 0) or 0)
elif typ == 2:
value = float(price_level or 0)
else:
value = abs(float(amount or getattr(zlava, "zl_koef", 0) or 0))
return data.UcetDiscount(
id=self._new_adjustment_id(),
id_zlavy=getattr(zlava, "idriadok", None),
name=self._discount_name(zlava),
typ_zlavy=typ,
source=source,
value=value,
amount=round(float(amount or 0), 2),
pct=pct,
price_level=str(price_level or ""),
ibajedna=int(getattr(zlava, "ibajedna", 0) or 0),
line_ids=line_ids,
payment_code=str(getattr(payment_type, "code", "") or ""),
payment_name=str(getattr(payment_type, "name", "") or ""),
)
def _apply_selected_discount(self, zlava, amount, pct, price_level=""):
app = self._make_discount_application(
zlava,
amount=amount,
pct=pct,
price_level=price_level,
source="account",
)
self.applied_discounts.append(app)
self.recalculate(show_overpaid_warning=True)
self._refresh()
def _ask_discount_value(self, zlava):
typ = int(getattr(zlava, "typ_zlavy", 1) or 1)
if typ == 2:
def accept_level(val: str):
try:
price_level = int(float(str(val or "0").replace(",", ".")))
except Exception:
return
if price_level <= 0:
return
if hasattr(zlava, "model_copy"):
selected = zlava.model_copy(update={"zl_koef": float(price_level)})
else:
selected = zlava
selected.zl_koef = float(price_level)
self._apply_price_level_discount(selected)
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=0,
max_len=3,
initial_value=str(int(float(getattr(zlava, "zl_koef", 0) or 0)) or ""),
on_accept=accept_level,
).open()
return
base = self._discount_base(zlava)
def accept(val: str):
value = self._parse_amount(val)
if value <= 0:
return
if typ == 1:
pct = min(value, 100.0)
amount = round(base * pct / 100.0, 2)
self._apply_selected_discount(zlava, amount, pct)
return
if typ == 4:
pct = value
amount = -round(base * pct / 100.0, 2)
self._apply_selected_discount(zlava, amount, pct)
return
if typ == 5:
amount = -value
self._apply_selected_discount(zlava, amount, None)
return
amount = min(round(value, 2), base)
self._apply_selected_discount(zlava, amount, None)
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=2,
max_len=7,
initial_value="0.00",
on_accept=accept,
).open()
def _line_total(self, pol) -> float:
qty = float(getattr(pol, "pocet", 0) or 0) / max(int(getattr(pol, "delitel", 1) or 1), 1)
return round(qty * float(getattr(pol, "cena", 0) or 0), 2)
def _discount_base(self, zlava) -> float:
return round(sum(self._line_total(pol) for pol in self._discountable_items(zlava)), 2)
def _discountable_items(self, zlava):
return [
pol
for pol in (getattr(self.ucet, "poloz", []) or [])
if self._discount_applies_to_item(zlava, pol)
]
def _discount_applies_to_item(self, zlava, pol) -> bool:
if self._item_no_discount(pol):
return False
if int(getattr(zlava, "vs_druhy", 1) or 0) == 1:
return True
item_id = int(getattr(pol, "id_card", 0) or 0)
item_kind = int(getattr(pol, "c_druh", 0) or 0)
allowed_cards = {int(getattr(item, "c_karty", 0) or 0) for item in (getattr(zlava, "kalky", []) or [])}
allowed_kinds = {int(getattr(item, "c_druh", 0) or 0) for item in (getattr(zlava, "druhy", []) or [])}
return bool((item_id and item_id in allowed_cards) or (item_kind and item_kind in allowed_kinds))
def _item_no_discount(self, pol) -> bool:
cenpol = self.cenik_map.get(int(getattr(pol, "id_card", 0) or 0))
attrs = [str(attr).strip().lower() for attr in (getattr(cenpol, "atributes", []) or [])]
return any(attr in {"nodiscount", "no_discount", "bez_zlavy"} for attr in attrs)
def _has_nondefault_price_level(self) -> bool:
for pol in (getattr(self.ucet, "poloz", []) or []):
current = str(getattr(pol, "cenhlad", "") or "").strip()
default = str(
getattr(pol, "def_hlad", "")
or ""
).strip()
if default and current and current != default:
return True
return False
def _has_item_discount(self) -> bool:
for pol in (getattr(self, "_base_poloz", []) or []):
old_price = getattr(pol, "cena_puv", None)
if old_price is not None and abs(float(old_price or 0) - float(getattr(pol, "cena", 0) or 0)) > 0.004:
return True
return False
def _clear_discount(self):
self.applied_discounts = []
self.selected_discount = None
self.discount_abs = 0.0
self.discount_pct = None
self.recalculate(show_overpaid_warning=True)
self._refresh()
def _restore_price_level_discount(self):
self.recalculate()
def _clear_payments_after_total_change(self):
self.recalculate(show_overpaid_warning=True)
def _scan_loyalty_card(self):
NumberPad(
mode="code",
max_len=40,
allow_text=True,
auto_accept_scanner=True,
on_accept=self._set_loyalty_card,
).open()
def _open_loyalty_system(self):
system = getattr(self.setup, "loyalty_system", "")
self._popup_info(
"Vernostný systém",
f"Vyhľadávanie pre systém {system} zatiaľ nie je pripojené.\n"
"Kartu je možné načítať čítačkou alebo klávesnicou.",
)
self._scan_loyalty_card()
def _set_loyalty_card(self, value):
value = str(value or "").strip()
if not value:
return
self.loyalty_card = value
self.loyalty_name = f"Karta {value}"
self.loyalty_info = ""
self._refresh()
def _allowed_payment_types(self):
if not self.payments:
return self.payment_types
fiscal = bool(self.payments[0].ptype.fiscal)
return [p for p in self.payment_types if bool(p.fiscal) == fiscal]
def _begin_add_payment(self, ptype: data.PaymentType):
if ptype not in self._allowed_payment_types():
self._popup_info("Platba", "Po prvej platbe je možné doplniť iba platby rovnakého fiškálneho typu.")
return
if self._remaining() <= 0.009 and not getattr(ptype, "is_cash", False):
return
if not self._run_payment_handler(ptype):
return
self._ask_payment_amount(ptype)
def _run_payment_handler(self, ptype):
handler = getattr(ptype, "handler", None)
self._next_payment_note = None
self._next_payment_adjustment_ids = []
self._handler_ptype = ptype
if not handler:
return True
if not callable(self.handler_runner):
Logger.info(f"Payment handler not connected: {handler}")
self.handler_info = f"{handler}: nepripojené"
return True
try:
try:
result = self.handler_runner(ptype=ptype, ucet=self.ucet, dialog=self)
except TypeError:
result = self.handler_runner(ptype, self.ucet, self)
except Exception as e:
self._popup_info("Platba", f"Obslužný program platby zlyhal:\n{e}")
return False
if result is False:
return False
self._apply_handler_result(result)
return True
def _apply_handler_result(self, result):
if not isinstance(result, dict):
return
if "note" in result:
self._next_payment_note = str(result.get("note") or "")
if "hotel_charge" in result:
self._next_payment_hotel_charge = result.get("hotel_charge")
Logger.info(
"PAYMENT handler hotel target: payment=%s target=%s",
getattr(result.get("payment_type", None), "code", ""),
self._next_payment_hotel_charge.model_dump(mode="json")
if hasattr(self._next_payment_hotel_charge, "model_dump")
else self._next_payment_hotel_charge,
)
payment_type = self._handler_ptype
adjustment_added = False
if "price_level" in result:
level = str(result.get("price_level") or "").strip()
if level:
app = data.UcetDiscount(
id=self._new_adjustment_id(),
name=f"{getattr(payment_type, 'name', '')}: cenová hladina {level}",
typ_zlavy=2,
source="payment",
value=float(level) if str(level).replace(".", "", 1).isdigit() else 0.0,
price_level=level,
payment_code=str(getattr(payment_type, "code", "") or ""),
payment_name=str(getattr(payment_type, "name", "") or ""),
)
self.applied_discounts.append(app)
self._next_payment_adjustment_ids.append(app.id)
adjustment_added = True
if "discount_abs" in result:
amount = float(result.get("discount_abs") or 0)
if amount:
line_ids = [
self._line_key(pol)
for pol in self._current_line_items()
if self._line_key(pol) and not self._item_no_discount(pol)
]
app = data.UcetDiscount(
id=self._new_adjustment_id(),
name=f"{getattr(payment_type, 'name', '')}: zľava",
typ_zlavy=3 if amount > 0 else 5,
source="payment",
value=abs(amount),
amount=amount,
line_ids=line_ids,
payment_code=str(getattr(payment_type, "code", "") or ""),
payment_name=str(getattr(payment_type, "name", "") or ""),
)
self.applied_discounts.append(app)
self._next_payment_adjustment_ids.append(app.id)
adjustment_added = True
if "discount_pct" in result:
pct = float(result.get("discount_pct") or 0)
if pct:
line_ids = [
self._line_key(pol)
for pol in self._current_line_items()
if self._line_key(pol) and not self._item_no_discount(pol)
]
app = data.UcetDiscount(
id=self._new_adjustment_id(),
name=f"{getattr(payment_type, 'name', '')}: zľava {pct:g}%",
typ_zlavy=1 if pct > 0 else 4,
source="payment",
value=abs(pct),
pct=abs(pct),
line_ids=line_ids,
payment_code=str(getattr(payment_type, "code", "") or ""),
payment_name=str(getattr(payment_type, "name", "") or ""),
)
self.applied_discounts.append(app)
self._next_payment_adjustment_ids.append(app.id)
adjustment_added = True
if adjustment_added:
self.recalculate(show_overpaid_warning=True)
if "handler_info" in result:
self.handler_info = str(result.get("handler_info") or "")
elif "price_level" in result:
self.handler_info = f"Cenová hladina: {result.get('price_level')}"
elif self._next_payment_note:
self.handler_info = self._next_payment_note
self._refresh()
def _ask_payment_amount(self, ptype: data.PaymentType):
remaining_czk = float(self._remaining())
rate = float(ptype.rate or 1.0)
initial = remaining_czk / rate if rate != 1.0 else remaining_czk
if self.quick_complete:
if remaining_czk <= 0.009:
self._discard_pending_payment_adjustments()
return
suma_czk, rounding_delta = self._round_amount_for_payment(ptype, remaining_czk)
suma = round(suma_czk / rate, 2) if rate != 1.0 else suma_czk
self._add_payment_amount(
ptype,
suma,
suma_czk,
self._next_payment_note,
self._next_payment_hotel_charge,
list(self._next_payment_adjustment_ids),
rounding_delta=rounding_delta,
)
return
def accept(val: str):
suma = self._parse_amount(val)
if suma <= 0:
self._discard_pending_payment_adjustments()
return
suma_czk_raw = round(suma * rate, 2)
if not ptype.is_cash and suma_czk_raw > remaining_czk:
suma_czk_raw = round(remaining_czk, 2)
suma = round(suma_czk_raw / rate, 2)
elif suma_czk_raw > remaining_czk + 0.009:
self._popup_info("Platba", "Zadaná suma je vyššia ako zostávajúca hodnota účtu.")
self._discard_pending_payment_adjustments()
return
suma_czk, rounding_delta = self._round_amount_for_payment(ptype, suma_czk_raw)
suma = round(suma_czk / rate, 2) if rate != 1.0 else suma_czk
if not ptype.allow_partial and abs((suma_czk - rounding_delta) - remaining_czk) > 0.009:
self._popup_info("Platba", "Táto platba musí uhradiť celý zostávajúci zostatok.")
self._discard_pending_payment_adjustments()
return
if suma_czk <= 0:
self._discard_pending_payment_adjustments()
return
self._add_payment_amount(
ptype,
suma,
suma_czk,
self._next_payment_note,
self._next_payment_hotel_charge,
list(self._next_payment_adjustment_ids),
rounding_delta=rounding_delta,
)
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=2,
max_len=7,
initial_value=f"{initial:.2f}",
on_accept=accept,
on_cancel=lambda *_: self._discard_pending_payment_adjustments(),
).open()
def _parse_amount(self, value):
if not value:
return 0.0
text = str(value).strip().replace(" ", "").replace(",", ".")
try:
if "/" in text:
num, den = text.split("/", 1)
return float(num) / float(den)
return float(text)
except Exception:
return 0.0
def _add_payment_amount(self, ptype, suma, suma_czk, note=None, hotel_charge=None, adjustment_ids=None, rounding_delta: float = 0.0):
code = str(ptype.code or "").upper()
if code == "QR":
texture = make_qr_texture(
iban=self.setup.iban,
amount_czk=suma_czk,
message=f"Účet {self.ucet.ucislo or self.ucet.stul}",
vs=self.ucet.ucislo,
)
def confirmed(suma_=suma, suma_czk_=suma_czk, note_=note, hotel_charge_=hotel_charge, adjustment_ids_=adjustment_ids, rounding_delta_=rounding_delta):
self.payments.append(PaymentItem(
ptype=ptype,
suma=round(suma_, 2),
suma_czk=round(suma_czk_, 2),
note=note_,
hotel_charge=hotel_charge_,
adjustment_ids=adjustment_ids_,
rounding_delta=rounding_delta_,
))
self._next_payment_hotel_charge = None
self._next_payment_adjustment_ids = []
if self.quick_complete:
self._finish()
return
self._refresh()
QRPreviewDialog(texture=texture, on_confirm=confirmed).open()
return
self.payments.append(PaymentItem(
ptype=ptype,
suma=round(suma, 2),
suma_czk=round(suma_czk, 2),
note=note,
hotel_charge=hotel_charge,
adjustment_ids=adjustment_ids,
rounding_delta=rounding_delta,
))
Logger.info(
"PAYMENT add: code=%s amount=%s hotel=%s note=%s",
getattr(ptype, "code", ""),
round(suma_czk, 2),
bool(hotel_charge),
note or "",
)
self._next_payment_hotel_charge = None
self._next_payment_adjustment_ids = []
if self.quick_complete:
self._finish()
return
self._refresh()
def _selected_printer_terminal_id(self) -> str:
printer = self.printer_by_no.get(str(self.selected_printer or ""))
return str(getattr(printer, "id_term", "") or "").strip() if printer else ""
def _direct_bankterm_type(self) -> str:
term_type = self._selected_bankterm_type()
if term_type in {"", "AFS", "FISKAL", "FISCAL", "FISKALPRO", "EKASA"}:
return ""
return term_type
def _terminal_result_success(self, pay) -> bool:
result = getattr(pay, "terminal_result", {}) or {}
return isinstance(result, dict) and bool(result.get("success"))
def _requires_direct_terminal_payment(self, pay) -> bool:
if not bool(getattr(getattr(pay, "ptype", None), "is_bankterm", False)):
return False
if self._terminal_result_success(pay):
return False
if not self._selected_printer_terminal_id():
return False
return bool(self._direct_bankterm_type())
def _pending_direct_terminal_payments(self):
return [
pay for pay in self.payments
if self._requires_direct_terminal_payment(pay)
]
def _has_completed_terminal_payment(self) -> bool:
return any(self._terminal_result_success(pay) for pay in self.payments)
def _remove_payment(self, pid: str):
pay = self._find_payment_item(pid)
if pay and self._terminal_result_success(pay):
self._popup_info(
"Platba",
"Táto platba už prebehla cez bankový terminál a nie je možné ju len vymazať.",
)
return
removed = [p for p in self.payments if p.id == pid]
remove_ids = {
adj_id
for p in removed
for adj_id in (getattr(p, "adjustment_ids", []) or [])
}
if remove_ids:
self.applied_discounts = [
app for app in self.applied_discounts
if app.id not in remove_ids
]
self.recalculate()
self.payments = [p for p in self.payments if p.id != pid]
self._refresh()
def _find_payment_item(self, pid: str):
return next((pay for pay in self.payments if pay.id == pid), None)
def _ask_tip(self, pid: str):
pay = self._find_payment_item(pid)
if not pay:
return
def accept(val: str):
tip = self._parse_amount(val)
if tip < 0:
return
pay.tip = round(tip, 2)
self._refresh()
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=2,
max_len=7,
initial_value=f"{float(getattr(pay, 'tip', 0) or 0):.2f}",
on_accept=accept,
).open()
def _edit_receipt_copies(self, *_):
if not self.payments:
self._popup_info("Kópie", "Kópie sa nastavujú podľa zvolenej platby.")
return
def accept(val: str):
try:
copies = int(float(str(val or "0").replace(",", ".")))
except Exception:
return
copies = max(copies, 0)
for pay in self.payments:
pay.p_kopii = copies
self._refresh()
NumberPad(
mode="number",
allow_fraction=False,
decimal_places=0,
max_len=2,
initial_value=str(self._receipt_copies()),
on_accept=accept,
).open()
def _summary_action(self, *_):
if not self.payments:
self._print_preview_receipt()
return
self.send_receipt_email = not self.send_receipt_email
self._refresh()
def _print_preview_receipt(self):
try:
import kivy_printer
preview_ucet = self.ucet.model_copy(deep=True)
preview_ucet.bill_printer = self.selected_printer
receipt_text = self.controller._render_receipt_preview_text(
preview_ucet,
kind="prebill",
title="Preducet",
printer_no=self.selected_printer,
)
def print_prebill():
preview_ucet.bill_printer = self.selected_printer
self.controller._enqueue_receipt_print_jobs(
preview_ucet,
kind="prebill",
title="Preducet",
copies=1,
required=False,
)
kivy_printer.show_receipt_preview(
preview_ucet,
width=40,
txt="Preducet",
currencytxt=self._currency(),
kasutxt=self.kasutxt,
receipt_text=receipt_text,
on_print=print_prebill,
print_label="TLAC",
)
except Exception as e:
self._popup_info("Predúčet", f"Predúčet sa nepodarilo zobraziť:\n{e}")
def _refresh_printer_button(self):
selected = str(self.selected_printer or "").strip()
current = (
self.controller._client_setting_value("prn_no")
if hasattr(self.controller, "_client_setting_value")
else str((self.controller.client_settings or {}).get("prn_no", "") or "").strip()
)
if selected and current != selected:
room_name = (
self.controller._client_setting_value("room_name")
if hasattr(self.controller, "_client_setting_value")
else str((self.controller.client_settings or {}).get("room_name", "") or "").strip()
)
self.controller.client_settings = api_call.save_clientsettings_API(
self.controller.ctx,
prn_no=selected,
room_name=room_name,
)
if hasattr(self.controller, "_set_client_setting_value"):
self.controller._set_client_setting_value("prn_no", selected)
self.controller.default_printer = selected
name = self.printer_map.get(selected, selected)
self.btn_printer.text = f"Tlač: {name or '-'}"
def _open_printer_popup(self, *_):
if not self.printers:
self._popup_info("Tlačiareň", "Nie je dostupná žiadna tlačiareň.")
return
layout = BoxLayout(orientation="vertical", spacing=dp(5), padding=dp(8))
popup = Popup(
title="Tlačiareň",
content=layout,
size_hint=(0.4, 0.6),
)
for printer in self.printers:
btn = Button(text=getattr(printer, "prn_name", getattr(printer, "prn_no", "")))
def select(_btn, prn=printer):
self.selected_printer = getattr(prn, "prn_no", "")
if self.on_printer_change:
self.on_printer_change(self.selected_printer)
self._refresh_printer_button()
popup.dismiss()
btn.bind(on_release=select)
layout.add_widget(btn)
popup.open()
def _popup_info(self, title, text):
box = BoxLayout(orientation="vertical", padding=dp(10), spacing=dp(10))
box.add_widget(self._label(text))
btn = Button(text="OK", size_hint_y=None, height=dp(42))
popup = ModalView(size_hint=(None, None), size=(dp(360), dp(220)))
def _dismiss_after_touch(*_):
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
btn.bind(on_release=_dismiss_after_touch)
box.add_widget(btn)
popup.add_widget(box)
popup.open()
def _run_direct_terminal_payments_before_finish(self):
pending = self._pending_direct_terminal_payments()
if not pending:
return False
if self.card_payment_running:
self._popup_info("Platba", "Už prebieha komunikácia s bankovým terminálom.")
return True
self.card_payment_running = True
terminal_name = self._direct_bankterm_type() or "terminál"
popup = self._popup_wait(
"Platba kartou",
f"Prebieha komunikácia s bankovým terminálom ({terminal_name}).",
)
def worker():
failed_message = ""
failed_result = None
completed = []
for pay in pending:
result = self._call_terminal_api(pay.suma_czk)
if result.get("success"):
pay.terminal_result = result
completed.append(pay)
try:
self._extract_receipt(result.get("raw") or {})
except Exception:
Logger.exception("PAYMENT terminal receipt extraction failed")
continue
failed_result = result
failed_message = result.get("error") or "Terminál odmietol transakciu."
break
def done(_dt):
self.card_payment_running = False
popup.dismiss()
if failed_message:
if completed:
self._popup_info(
"Platba zamietnutá",
"Niektoré terminálové platby už prebehli, ale ďalšia transakcia zlyhala.\n"
"Účet nie je uzavretý. Opravte platby alebo skúste vykonať platbu znova.\n"
f"{failed_message}",
)
else:
self._popup_info("Platba zamietnutá", failed_message)
self._refresh()
return
self._finish_after_terminal()
Clock.schedule_once(done)
threading.Thread(target=worker, daemon=True).start()
return True
def _finish(self):
if self.card_payment_running:
self._popup_info("Platba", "Už prebieha komunikácia s bankovým terminálom.")
return
if not self._can_finish():
if self._has_overpayment():
self._warn_overpayment_if_needed()
return
self.recalculate()
if self._run_direct_terminal_payments_before_finish():
return
self._finish_after_terminal()
def _finish_after_terminal(self):
self.recalculate()
self.ucet.discount_abs = float(self._discount())
self.ucet.discounts_applied = []
self.ucet.discounts_prorated = True
self.ucet.discount_name = ", ".join(app.name for app in self.applied_discounts if app.name)
self.ucet.discount_id = self.applied_discounts[-1].id_zlavy if self.applied_discounts else None
self.ucet.loyalty_card = self.loyalty_card
self.ucet.loyalty_name = self.loyalty_name
self.ucet.loyalty_info = self.loyalty_info
self.ucet.bill_printer = self.selected_printer
self.ucet.send_receipt_email = bool(self.send_receipt_email)
self.ucet.round50 = float(self._account_round50())
self.ucet.platby = []
fallback_hotel_charge = getattr(self.ucet, "hotel_charge", None)
for pay in self.payments:
hotel_charge = pay.hotel_charge
if not hotel_charge and fallback_hotel_charge and len(self.payments) == 1:
hotel_charge = fallback_hotel_charge
self.ucet.platby.append(data.Platba(
code=pay.ptype.code,
nazev=pay.ptype.name,
suma=float(pay.suma),
suma_czk=float(pay.suma_czk),
unit=pay.ptype.unit,
rate=float(pay.ptype.rate or 1.0),
fiscal=bool(pay.ptype.fiscal),
is_bankterm=bool(getattr(pay.ptype, "is_bankterm", False)),
p_kopii=max(int(getattr(pay, "p_kopii", getattr(pay.ptype, "p_kopii", 1)) or 0), 0),
tip=float(getattr(pay, "tip", 0) or 0),
poznamka=pay.note,
hotel_charge=hotel_charge,
terminal_result=dict(getattr(pay, "terminal_result", {}) or {}),
))
Logger.info(
"PAYMENT finish: payments=%s hotel_payments=%s",
len(self.ucet.platby),
sum(1 for pay in self.ucet.platby if getattr(pay, "hotel_charge", None)),
)
self.on_done(self.ucet)
self.dismiss()
def _cancel_all(self):
if self._has_completed_terminal_payment():
self._popup_info(
"Platba",
"Nie je možné zrušiť platobné okno, pretože niektorá platba už prebehla cez bankový terminál.",
)
return
if self.card_payment_running:
self._abort_transaction()
if self.on_cancel:
self.on_cancel()
self.dismiss()
PaymentDialog = GuidedPaymentDialog
# =====================================================
# TEST
# =====================================================
class TestApp(App):
def build(self):
types = [
data.PaymentType(code="CASH", name="Hotove", unit="Kc", is_cash=True, is_default=True),
data.PaymentType(code="CARD", name="Karta", unit="Kc"),
data.PaymentType(code="QR", name="QR platba", unit="Kc"),
]
setup = data.PosSetup(platby=types)
return PaymentDialog(
ucet=data.Ucet(stul="1", poloz=[]),
payment_types=types,
setup=setup,
on_done=lambda *_: None,
)
if __name__ == "__main__":
TestApp().run()