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

648 lines
25 KiB
Python

import calendar
from datetime import datetime, date
from kivy.clock import Clock
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.checkbox import CheckBox
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.screenmanager import Screen
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
from kivy.logger import Logger
from kivy.core.window import Window
import data
from numberpad import NumberPad
class ClosedReceiptsScreen(Screen):
def __init__(self, *, controller, back_screen="account_select", **kwargs):
super().__init__(**kwargs)
self.controller = controller
self.back_screen = back_screen
self.receipts: list[data.UcetSelect] = []
self.filtered: list[data.UcetSelect] = []
self.selected_summary: data.UcetSelect | None = None
self.selected_ucet: data.Ucet | None = None
self.selected_quantities: dict[str, float] = {}
self.current_only = True
self._keyboard_bound = False
root = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8))
top = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(6))
btn_back = Button(text="Zavriet", size_hint_x=None, width=dp(120))
btn_back.bind(on_press=lambda *_: self._go_back())
self.search = TextInput(
hint_text="Hladat ucet, stol, platbu, casnika...",
multiline=False,
font_size=dp(16),
padding=(dp(10), dp(10), dp(10), dp(8)),
)
self.search.bind(text=lambda *_: self._apply_filters())
self.date_filter = TextInput(
hint_text="Den",
multiline=False,
size_hint_x=None,
width=dp(120),
readonly=True,
font_size=dp(16),
padding=(dp(10), dp(10), dp(10), dp(8)),
)
self.date_filter.bind(text=lambda *_: self._apply_filters())
btn_date = Button(text="...", size_hint_x=None, width=dp(48))
btn_date.bind(on_press=lambda *_: self._open_calendar())
btn_clear_date = Button(text="X", size_hint_x=None, width=dp(42))
btn_clear_date.bind(on_press=lambda *_: setattr(self.date_filter, "text", ""))
current_wrap = BoxLayout(size_hint_x=None, width=dp(170), spacing=dp(4))
self.chk_current = CheckBox(active=True, size_hint_x=None, width=dp(36))
self.chk_current.bind(active=lambda _w, active: self._set_current_only(active))
current_wrap.add_widget(self.chk_current)
current_wrap.add_widget(Label(text="Aktualne ucty", halign="left"))
btn_refresh = Button(text="Obnovit", size_hint_x=None, width=dp(120))
btn_refresh.bind(on_press=lambda *_: self.refresh())
top.add_widget(btn_back)
top.add_widget(self.search)
top.add_widget(self.date_filter)
top.add_widget(btn_date)
top.add_widget(btn_clear_date)
top.add_widget(current_wrap)
top.add_widget(btn_refresh)
root.add_widget(top)
body = BoxLayout(spacing=dp(8))
left = BoxLayout(orientation="vertical", size_hint_x=0.62, spacing=dp(4))
header = GridLayout(cols=7, size_hint_y=None, height=dp(34), spacing=dp(2))
for title in ("Stav", "Ucet", "Datum", "Stol", "Platby", "Suma", "Casnik"):
header.add_widget(Label(text=f"[b]{title}[/b]", markup=True, halign="left"))
left.add_widget(header)
self.list_grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None)
self.list_grid.bind(minimum_height=self.list_grid.setter("height"))
list_scroll = ScrollView(do_scroll_x=False, do_scroll_y=True, bar_width=dp(14))
list_scroll.add_widget(self.list_grid)
left.add_widget(list_scroll)
body.add_widget(left)
right = BoxLayout(orientation="vertical", size_hint_x=0.38, spacing=dp(6))
self.detail_title = Label(
text="Vyber ucet",
size_hint_y=None,
height=dp(36),
font_size=dp(20),
bold=True,
)
self.detail_subtitle = Label(text="", size_hint_y=None, height=dp(28))
right.add_widget(self.detail_title)
right.add_widget(self.detail_subtitle)
self.detail_grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None)
self.detail_grid.bind(minimum_height=self.detail_grid.setter("height"))
detail_scroll = ScrollView(do_scroll_x=False, do_scroll_y=True, bar_width=dp(14))
detail_scroll.add_widget(self.detail_grid)
right.add_widget(detail_scroll)
actions = GridLayout(cols=2, spacing=dp(6), size_hint_y=None, height=dp(148))
self.btn_storno = self._action_button("Stornovat ucet", self._storno)
self.btn_return_table = self._action_button("Storno a presun na stol", self._return_to_table)
self.btn_copy = self._action_button("Tlac kopie", self._print_copy)
self.btn_change_pay = self._action_button("Zmenit platby", self._change_payment)
self.btn_tip = self._action_button("Zadanie TIPu", self._edit_tip)
self.btn_select_all = self._action_button("Vybrat vsetko", self._select_all_items)
for btn in (
self.btn_storno,
self.btn_return_table,
self.btn_copy,
self.btn_change_pay,
self.btn_tip,
self.btn_select_all,
):
actions.add_widget(btn)
right.add_widget(actions)
body.add_widget(right)
root.add_widget(body)
self.add_widget(root)
self._refresh_actions()
def on_enter(self):
self._bind_keyboard()
self.refresh()
def on_leave(self):
self._unbind_keyboard()
def _bind_keyboard(self):
if self._keyboard_bound:
return
Window.unbind(on_key_down=self._on_key_down)
Window.bind(on_key_down=self._on_key_down)
self._keyboard_bound = True
def _unbind_keyboard(self):
Window.unbind(on_key_down=self._on_key_down)
self._keyboard_bound = False
def _normalize_key(self, keycode, text):
if text:
return text
if keycode == 27:
return "ESC"
key = keycode[1] if isinstance(keycode, tuple) else keycode
return {
"escape": "ESC",
"esc": "ESC",
}.get(key, key)
def _on_key_down(self, window, keycode, scancode, codepoint, modifiers):
key = self._normalize_key(keycode, codepoint)
if key == "ESC":
self._go_back()
return True
return False
def _action_button(self, text, callback):
btn = Button(text=text)
btn.bind(on_press=lambda *_: callback())
return btn
def _set_current_only(self, active: bool):
self.current_only = bool(active)
self.refresh()
def _go_back(self):
if self.manager:
self.manager.current = self.back_screen
def refresh(self):
try:
self.receipts = self.controller.load_closed_ucty(
limit=2000,
onlynonclsrep=self.current_only,
)
except Exception as exc:
Logger.exception("ClosedReceipts: load failed")
self.controller._popup_info("Ucty", f"Ucty sa nepodarilo nacitat:\n{exc}")
self.receipts = []
self.selected_summary = None
self.selected_ucet = None
self.selected_quantities = {}
self._apply_filters()
self._render_detail()
def _apply_filters(self):
text = (self.search.text or "").strip().lower()
date_text = (self.date_filter.text or "").strip()
result = []
for row in self.receipts:
if not self.current_only and not getattr(row, "c_uzaverka", None):
continue
if self.current_only and getattr(row, "c_uzaverka", None):
continue
if date_text and self._date_key(getattr(row, "closed_at", "")) != self._normalize_date(date_text):
continue
if text and text not in self._summary_search_text(row):
continue
result.append(row)
self.filtered = result
self._render_list()
def _summary_search_text(self, row) -> str:
fields = [
getattr(row, "ucislo", ""),
getattr(row, "closed_at", ""),
getattr(row, "stul", ""),
getattr(row, "payments_text", ""),
getattr(row, "status_text", ""),
getattr(row, "autor", ""),
getattr(row, "origin", ""),
f"{getattr(row, 'total_base_currency', 0) or 0:.2f}",
]
return " ".join(str(x or "") for x in fields).lower()
def _normalize_date(self, value: str) -> str:
value = (value or "").strip()
if not value:
return ""
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d", "%y%m%d"):
try:
return datetime.strptime(value, fmt).strftime("%Y-%m-%d")
except Exception:
pass
return value
def _date_key(self, value: str) -> str:
text = str(value or "").strip()
if not text:
return ""
candidates = [
text,
text[:19],
text[:16],
text[:15],
text[:13],
text[:10],
text[:8],
text[:6],
]
formats = (
"%y%m%d %H:%M:%S",
"%y%m%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%d.%m.%Y %H:%M:%S",
"%d.%m.%Y %H:%M",
"%Y-%m-%d",
"%d.%m.%Y",
"%y%m%d",
)
for candidate in candidates:
for fmt in formats:
try:
return datetime.strptime(candidate, fmt).strftime("%Y-%m-%d")
except Exception:
pass
if len(text) >= 6 and text[:6].isdigit():
try:
return datetime.strptime(text[:6], "%y%m%d").strftime("%Y-%m-%d")
except Exception:
pass
return text[:10]
def _open_calendar(self):
selected = self._parse_date_filter() or date.today()
month = date(selected.year, selected.month, 1)
popup = Popup(title="Vyber datumu", size_hint=(None, None), size=(dp(430), dp(430)))
root = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8))
def render(target_month):
root.clear_widgets()
top = BoxLayout(size_hint_y=None, height=dp(46), spacing=dp(6))
btn_prev = Button(text="<", size_hint_x=None, width=dp(54))
title = Label(text=target_month.strftime("%m/%Y"), bold=True)
btn_next = Button(text=">", size_hint_x=None, width=dp(54))
top.add_widget(btn_prev)
top.add_widget(title)
top.add_widget(btn_next)
root.add_widget(top)
days = GridLayout(cols=7, spacing=dp(3), size_hint_y=None, height=dp(34))
for name in ("Po", "Ut", "St", "Stv", "Pi", "So", "Ne"):
days.add_widget(Label(text=name, bold=True))
root.add_widget(days)
grid = GridLayout(cols=7, spacing=dp(3))
cal = calendar.Calendar(firstweekday=0)
for week in cal.monthdayscalendar(target_month.year, target_month.month):
for day in week:
if not day:
grid.add_widget(Label(text=""))
continue
current = date(target_month.year, target_month.month, day)
btn = Button(text=str(day))
if current == selected:
btn.background_color = (0.20, 0.55, 0.75, 1)
btn.bind(on_press=lambda _btn, d=current: self._set_calendar_date(popup, d))
grid.add_widget(btn)
root.add_widget(grid)
bottom = BoxLayout(size_hint_y=None, height=dp(46), spacing=dp(6))
btn_today = Button(text="Dnes")
btn_clear = Button(text="Bez datumu")
btn_cancel = Button(text="Zrusit")
btn_today.bind(on_press=lambda *_: self._set_calendar_date(popup, date.today()))
btn_clear.bind(on_press=lambda *_: self._clear_calendar_date(popup))
btn_cancel.bind(on_press=lambda *_: popup.dismiss())
bottom.add_widget(btn_today)
bottom.add_widget(btn_clear)
bottom.add_widget(btn_cancel)
root.add_widget(bottom)
prev_month = date(target_month.year - (1 if target_month.month == 1 else 0), 12 if target_month.month == 1 else target_month.month - 1, 1)
next_month = date(target_month.year + (1 if target_month.month == 12 else 0), 1 if target_month.month == 12 else target_month.month + 1, 1)
btn_prev.bind(on_press=lambda *_: render(prev_month))
btn_next.bind(on_press=lambda *_: render(next_month))
popup.content = root
render(month)
popup.open()
def _parse_date_filter(self):
text = (self.date_filter.text or "").strip()
if not text:
return None
key = self._normalize_date(text)
try:
return datetime.strptime(key, "%Y-%m-%d").date()
except Exception:
return None
def _set_calendar_date(self, popup, value: date):
self.date_filter.text = value.strftime("%d.%m.%Y")
popup.dismiss()
def _clear_calendar_date(self, popup):
self.date_filter.text = ""
popup.dismiss()
def _render_list(self):
self.list_grid.clear_widgets()
for row in self.filtered:
btn = Button(
text=self._row_text(row),
size_hint_y=None,
height=dp(54),
halign="left",
valign="middle",
)
btn.text_size = (dp(1100), None)
if self.selected_summary and getattr(row, "ucislo", "") == getattr(self.selected_summary, "ucislo", ""):
btn.background_color = (0.20, 0.55, 0.75, 1)
else:
status = str(getattr(row, "status_text", "") or "").upper()
if status == "STORNO":
btn.background_color = (0.52, 0.18, 0.18, 1)
elif status == "STORNOVANY":
btn.background_color = (0.36, 0.20, 0.20, 1)
elif status in ("CIAST. STORNO", "VYSTORNOVANY"):
btn.background_color = (0.55, 0.42, 0.18, 1)
btn.bind(on_press=lambda _btn, item=row: self._select_receipt(item))
self.list_grid.add_widget(btn)
def _row_text(self, row) -> str:
return (
f"{getattr(row, 'status_text', '') or 'UCET'} "
f"# {getattr(row, 'ucislo', '')} "
f"{getattr(row, 'closed_at', '')} "
f"stol {getattr(row, 'stul', '') or '-'} "
f"{getattr(row, 'payments_text', '') or '-'} "
f"{self._money(getattr(row, 'total_base_currency', 0))} "
f"{getattr(row, 'autor', '') or '-'}"
)
def _money(self, value) -> str:
return f"{float(value or 0):.2f} {self.controller._currency()}"
def _select_receipt(self, row):
self.selected_summary = row
self.selected_quantities = {}
try:
self.selected_ucet = self.controller.load_closed_ucet_detail(row.ucislo)
except Exception as exc:
Logger.exception("ClosedReceipts: detail failed")
self.controller._popup_info("Ucty", f"Ucet {row.ucislo} sa nepodarilo nacitat:\n{exc}")
self.selected_ucet = None
self._render_list()
self._render_detail()
def _render_detail(self):
self.detail_grid.clear_widgets()
ucet = self.selected_ucet
if not ucet:
self.detail_title.text = "Vyber ucet"
self.detail_subtitle.text = ""
self._refresh_actions()
return
self.detail_title.text = f"Ucet {ucet.ucislo or ''}"
self.detail_subtitle.text = f"Stol {ucet.stul or '-'} | {ucet.closed_at or ''} | {self._money(ucet.total_czk())}"
for pol in getattr(ucet, "poloz", []) or []:
self.detail_grid.add_widget(self._item_row(pol))
self._refresh_actions()
def _item_row(self, pol):
line_id = self._line_id(pol)
available = self._storno_available_units(pol)
selected = self.selected_quantities.get(line_id, 0.0)
partial = self._is_partially_storned(pol)
row = BoxLayout(
orientation="horizontal",
spacing=dp(4),
size_hint_y=None,
height=dp(78),
)
bg = (0.30, 0.30, 0.30, 1)
if available <= 0:
bg = (0.35, 0.35, 0.35, 1)
elif partial:
bg = (0.55, 0.42, 0.18, 1)
elif selected:
bg = (0.20, 0.55, 0.75, 1)
qty_btn = Button(
text=self._left_qty_text(pol),
markup=True,
size_hint=(None, None),
width=dp(74),
height=dp(78),
background_color=bg,
background_normal="",
background_down="",
halign="center",
valign="top",
disabled=available <= 0,
)
qty_btn.bind(on_press=lambda *_: self._select_quantity_with_numberpad(pol))
qty_btn.bind(size=lambda inst, *_: setattr(inst, "text_size", inst.size))
name_btn = Button(
text=self._item_text(pol),
markup=True,
size_hint=(1, None),
height=dp(78),
background_color=bg,
background_normal="",
background_down="",
halign="left",
valign="top",
disabled=available <= 0,
)
name_btn.bind(on_press=lambda *_: self._toggle_all_units(pol))
name_btn.bind(size=lambda inst, *_: setattr(inst, "text_size", (inst.width - dp(8), None)))
row.add_widget(qty_btn)
row.add_widget(name_btn)
return row
def _item_text(self, pol) -> str:
units = abs(float(getattr(pol, "pocet", 0) or 0))
qty = units / max(int(getattr(pol, "delitel", 1) or 1), 1)
total = qty * float(getattr(pol, "cena", 0) or 0)
available = self._storno_available_units(pol)
return (
f"{getattr(pol, 'nazev', '')}\n"
f"Pocet: {qty:g} Stornovat: {self._qty_text(available, pol)} "
f"{float(getattr(pol, 'cena', 0) or 0):.2f} = {total:.2f}"
)
def _line_id(self, pol) -> str:
return str(getattr(pol, "line_id", "") or "")
def _storno_available_units(self, pol) -> float:
units = abs(float(getattr(pol, "pocet", 0) or 0))
raw = getattr(pol, "kstornu", None)
if raw is None:
return units
try:
return max(min(float(raw or 0), units), 0.0)
except Exception:
return units
def _qty_text(self, units, pol) -> str:
units = float(units or 0)
den = max(int(getattr(pol, "delitel", 1) or 1), 1)
if den != 1:
num = int(units) if float(units).is_integer() else units
return f"{num:g}/{den}"
return f"{int(units) if float(units).is_integer() else units:g}"
def _left_qty_text(self, pol) -> str:
line_id = self._line_id(pol)
available = self._storno_available_units(pol)
selected = self.selected_quantities.get(line_id, 0.0)
available_txt = self._qty_text(available, pol)
if selected and selected < available:
return f"{self._qty_text(selected, pol)}\n/{available_txt}"
return available_txt
def _select_quantity_with_numberpad(self, pol):
available = self._storno_available_units(pol)
if available <= 0:
return
current = self.selected_quantities.get(self._line_id(pol), 0.0)
initial = str(int(current or available))
def accept(value: str):
try:
qty = int(str(value or "0").strip())
except Exception:
return
self._set_selected_units(pol, min(max(qty, 0), available))
NumberPad(
mode="number",
allow_fraction=False,
show_dot=False,
decimal_places=0,
max_len=4,
initial_value=initial,
on_accept=accept,
).open()
def _toggle_all_units(self, pol):
line_id = self._line_id(pol)
available = self._storno_available_units(pol)
if not line_id or available <= 0:
return
selected = self.selected_quantities.get(line_id, 0.0)
if selected >= available:
self._set_selected_units(pol, 0)
else:
self._set_selected_units(pol, available)
def _is_partially_storned(self, pol) -> bool:
raw = getattr(pol, "kstornu", None)
if raw is None:
return False
units = abs(float(getattr(pol, "pocet", 0) or 0))
try:
available = float(raw or 0)
except Exception:
return False
return 0 <= available < units
def _change_selected_units(self, pol, delta: int):
line_id = self._line_id(pol)
if not line_id:
return
available = self._storno_available_units(pol)
current = self.selected_quantities.get(line_id, 0.0)
self._set_selected_units(pol, current + float(delta))
def _set_selected_units(self, pol, value):
line_id = self._line_id(pol)
if not line_id:
return
available = self._storno_available_units(pol)
value = max(min(float(value or 0), available), 0.0)
if value <= 0:
self.selected_quantities.pop(line_id, None)
else:
self.selected_quantities[line_id] = value
Clock.schedule_once(lambda *_: self._render_detail(), 0)
def _select_all_items(self):
ucet = self.selected_ucet
if not ucet:
return
all_quantities = {
self._line_id(pol): self._storno_available_units(pol)
for pol in (getattr(ucet, "poloz", []) or [])
if self._line_id(pol) and self._storno_available_units(pol) > 0
}
if self.selected_quantities == all_quantities:
self.selected_quantities = {}
else:
self.selected_quantities = all_quantities
self._render_detail()
def _refresh_actions(self):
has_ucet = bool(self.selected_ucet)
has_selected = bool(self.selected_quantities)
is_storno = bool(getattr(self.selected_ucet, "is_storno", None)) if self.selected_ucet else False
is_limit = bool(getattr(self.selected_ucet, "limit_id", None)) if self.selected_ucet else False
has_storno_available = self._has_storno_available()
self.btn_storno.text = "Storno vybraneho" if has_selected else "Stornovat ucet"
for btn in (self.btn_storno, self.btn_return_table, self.btn_copy, self.btn_change_pay, self.btn_tip, self.btn_select_all):
btn.disabled = not has_ucet
if has_ucet and is_storno:
self.btn_storno.disabled = True
self.btn_return_table.disabled = True
self.btn_change_pay.disabled = True
self.btn_tip.disabled = True
if has_ucet and is_limit:
self.btn_return_table.disabled = True
self.btn_select_all.disabled = True
if has_selected:
self.btn_storno.disabled = True
self.btn_storno.text = "Stornovat ucet"
if has_ucet and not has_storno_available:
self.btn_storno.disabled = True
self.btn_select_all.disabled = True
def _has_storno_available(self) -> bool:
ucet = self.selected_ucet
if not ucet:
return False
return any(
self._storno_available_units(pol) > 0
for pol in (getattr(ucet, "poloz", []) or [])
)
def _print_copy(self):
if self.selected_ucet:
self.controller.closed_receipt_print_copy(self.selected_ucet)
def _storno(self):
if self.selected_ucet:
if getattr(self.selected_ucet, "limit_id", None):
self.selected_quantities = {}
self.controller.closed_receipt_storno_full(self.selected_ucet)
return
if self.selected_quantities:
self.controller.closed_receipt_storno_items(self.selected_ucet, self.selected_quantities)
self.selected_quantities = {}
self.refresh()
else:
self.controller.closed_receipt_storno_full(self.selected_ucet)
def _return_to_table(self):
if self.selected_ucet:
self.controller.closed_receipt_storno_return_to_table(self.selected_ucet)
def _change_payment(self):
if self.selected_ucet:
self.controller.closed_receipt_change_payment(self.selected_ucet)
def _edit_tip(self):
if self.selected_ucet:
self.controller.closed_receipt_edit_tip(self.selected_ucet)