diff --git a/Medevio/80 Pacienti/NOTES.md b/Medevio/80 Pacienti/NOTES.md new file mode 100644 index 0000000..5d79bcb --- /dev/null +++ b/Medevio/80 Pacienti/NOTES.md @@ -0,0 +1,107 @@ +# 80 Pacienti — NOTES + +## Účel adresáře + +Synchronizace a správa pacientů mezi **Medevio** (MySQL) a **Medicus** (Firebird). +Hlavní výstup: GUI aplikace `pacienti_gui.py` pro ruční revizi a čištění. + +--- + +## Hlavní soubory + +| Soubor | Popis | +|--------|-------| +| `pacienti_gui.py` | **Hlavní GUI** — scrollovatelný seznam pacientů ACTIVE v Medevio mimo Medicus | +| `sync_patients_to_mysql.py` | Stahuje pacienty z Medevio GraphQL API do MySQL (multithreading, 5 threadů) | +| `bulk_set_removed.py` | Hromadně označí jako REMOVED pacienty bez účtu (user_id IS NULL) mimo Medicus | +| `check_sync_counts.py` | Porovná počty: Medevio ACTIVE vs. Medicus registrovaní | +| `create_table.sql` | DDL pro tabulku `medevio_pacient` | +| `_test_breakpoint.py` | Dočasný testovací skript pro binary search pojištění (lze smazat) | + +--- + +## GUI aplikace — pacienti_gui.py + +### Spuštění +``` +python "U:\OrdinaceProjekt\Medevio\80 Pacienti\pacienti_gui.py" +``` + +### Co zobrazuje +Pacienti `status = 'ACTIVE'` v MySQL tabulce `medevio_pacient`, kteří **nemají platnou registraci** v Medicusu (tabulka `registr`, ICP `09305001`, odb `001`). + +Sloupce: Příjmení Jméno · Rodné číslo · Pojišťovna · Narozen · Poslední dekurz (z LOG) + +### Tlačítka na každém řádku + +#### [VZP dotaz] +- Zavolá VZP B2B API `stavPojisteniB2B` + `RegistracePojistencePZSB2B` (odb 001) +- Zobrazí: pojišťovna, stav pojištění, jméno/ICP/ICZ registrujícího lékaře +- Běží v threadu — GUI nezamrzá + +#### [Bod zlomu] +- Binary search přes `stavPojisteniB2B` — hledá datum, kdy `stav` přešel z `'1'` (pojištěn) na `'X'` (nepojištěn/jiný) +- Okno: dnes −2 roky → dnes +- Přesnost: 1 den, ~12 API volání +- Delay mezi voláními: **1.5s** (VZP rate limit: max ~2 dotazy / 3s, jinak 503) +- Retry: při 503 čeká 3s a zkusí znovu (max 3 pokusy) + +#### [Označ neaktivní] +- Potvrzovací dialog +- Zavolá Medevio GraphQL mutation `updateClinicPatientStatus` → `REMOVED` +- Aktualizuje MySQL `medevio_pacient.status = 'REMOVED'` +- Řádek se zbarví zeleně, tlačítka se deaktivují + +--- + +## Datové zdroje + +### MySQL — tabulka `medevio_pacient` +- Host: `192.168.1.76:3306`, DB: `medevio` +- Klíčová pole: `patient_id`, `status` (ACTIVE/REMOVED), `user_id` (NULL = bez vlastního účtu), `identification_number` (RC bez lomítka) + +### Medicus (Firebird) +- Registrace: tabulka `registr` JOIN `icp`, WHERE `icp = '09305001'` AND `odb = '001'` +- Aktivní registrace: `datum <= CURRENT_DATE AND (datum_zruseni IS NULL OR datum_zruseni >= CURRENT_DATE)` +- Poslední dekurz: tabulka `LOG` (miliony řádků — vždy filtrovat přes `idpac IN (...)`) + +### Medevio GraphQL API +- URL: `https://api.medevio.cz/graphql` +- Clinic slug: `mudr-buzalkova` +- Token: `Medevio/token.txt` + +### VZP B2B API +- Certifikát: `Insurance/Certificates/picka.pfx` +- Heslo: `Vlado7309208104+` +- ICZ: `09305000` +- Produkční endpoint: `https://prod.b2b.vzp.cz/B2BProxy/HttpProxy/` +- Rate limit: **max 2 dotazy / 3s** → delay 1.5s + +--- + +## Stav pojištění — kódy + +| stav | Význam | +|------|--------| +| `'1'` | Pojištěn (aktivní) | +| `'X'` | Nepojištěn / zánik pojištění | +| `None` | Nenalezen v systému | + +Pojišťovna kód `111` = VZP. Kód zůstává i při stav `'X'` (pacient je vedený u VZP ale pojištění zaniklo). + +--- + +## Známé problémy + +- **503 od VZP API** — při příliš rychlých dotazech. Řešení: delay 1.5s + retry 3× po 3s. +- **bulk_set_removed.py** má chybnou logiku — filtruje jen `user_id IS NULL`. Pro kompletní cleanup je třeba opravit na všechny ACTIVE mimo Medicus bez ohledu na user_id. + +--- + +## Počty (stav 2026-05-15 → aktualizovat po každém běhu) + +| Zdroj | Počet | +|-------|-------| +| Medevio ACTIVE (MySQL) | ~1746 | +| Medicus registrovaní | ~1624 (k 2026-05-20) | +| ACTIVE v Medevio mimo Medicus | ~66–130 | diff --git a/Medevio/80 Pacienti/_test_breakpoint.py b/Medevio/80 Pacienti/_test_breakpoint.py new file mode 100644 index 0000000..c5ba30a --- /dev/null +++ b/Medevio/80 Pacienti/_test_breakpoint.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Dočasný test binary search pojištění pro jedno RC.""" + +import sys, time +sys.stdout.reconfigure(encoding="utf-8") +sys.stderr.reconfigure(encoding="utf-8") +from datetime import date, timedelta +from pathlib import Path + +sys.path.insert(0, "U:/OrdinaceProjekt") +from Knihovny.vzpb2b_client import VZPB2BClient + +CERT_PATH = Path("U:/OrdinaceProjekt/Insurance/Certificates/picka.pfx") +CERT_PASSWORD = "Vlado7309208104+" +ICZ = "09305000" +RC = "485221756" +DELAY = 1.50 # s + +client = VZPB2BClient("prod", str(CERT_PATH), CERT_PASSWORD, icz=ICZ) + +call_count = 0 + +def check(d: date) -> tuple[dict, bool]: + global call_count + call_count += 1 + print(f" [{call_count:02d}] Testuji {d.isoformat()} ...", end=" ", flush=True) + for attempt in range(3): + try: + xml = client.stav_pojisteni(RC, k_datu=d.isoformat()) + status = client.parse_stav_pojisteni(xml) + break + except Exception as e: + print(f"\n CHYBA (pokus {attempt+1}/3): {e} — čekám 3s ...", end=" ", flush=True) + time.sleep(3) + else: + print("SELHALO po 3 pokusech, končím.") + sys.exit(1) + stav = status.get("stav") + poj = status.get("kodPojistovny") + active = (status.get("stavVyrizeni") == 1 and stav == "1") + print(f"stav={stav!r:4s} pojišťovna={poj!r} aktivní={active}") + time.sleep(DELAY) + return status, active + +today = date.today() +start_date = today - timedelta(days=730) + +print("=" * 60) +print(f"RC: {RC}") +print(f"Okno: {start_date} -> {today}") +print("=" * 60) + +print("\n[1] Kontrola krajů:") +status_start, active_start = check(start_date) +status_end, active_end = check(today) + +print() +if not active_start: + print("✗ Na začátku (−2 roky) již stav != '1' — rozšiřte okno nebo jiná situace.") + sys.exit(0) + +if active_end: + print("✗ Dnes stále stav '1' — v tomto okně zlom není.") + sys.exit(0) + +print("✓ Levý okraj aktivní, pravý neaktivní → spouštím binary search.\n") +print("[2] Binary search:") + +left, right = start_date, today +left_status, right_status = status_start, status_end + +while (right - left).days > 1: + mid = left + timedelta(days=(right - left).days // 2) + mid_status, mid_active = check(mid) + if mid_active: + left, left_status = mid, mid_status + direction = "→ posunuji LEFT doprava" + else: + right, right_status = mid, mid_status + direction = "← posunuji RIGHT doleva" + print(f" {direction} (interval: {left} — {right}, {(right-left).days} dní)") + +print() +print("=" * 60) +print("VÝSLEDEK:") +print(f" Poslední den pojištění (stav=1) : {left}") +print(f" pojišťovna: {left_status.get('nazevPojistovny')} / stav: {left_status.get('stav')}") +print(f" První den se změnou : {right}") +print(f" pojišťovna: {right_status.get('nazevPojistovny')} / stav: {right_status.get('stav')}") +print(f" Celkem API volání: {call_count}") +print("=" * 60) diff --git a/Medevio/80 Pacienti/pacienti_gui.py b/Medevio/80 Pacienti/pacienti_gui.py new file mode 100644 index 0000000..5407c36 --- /dev/null +++ b/Medevio/80 Pacienti/pacienti_gui.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +GUI: Pacienti ACTIVE v Medevio, kteří nejsou registrovaní v Medicusu. + +Sloupce: Jméno, RC, Pojišťovna, Narozen, Poslední dekurz +Tlačítka: [VZP dotaz] [Označ neaktivní] +Spodní panel: výsledek VZP dotazu (pojišťovna + registrující lékař) +""" + +import sys +import threading +import time +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from datetime import date, timedelta + +sys.path.insert(0, "U:/OrdinaceProjekt") + +import pymysql +import requests +from Knihovny.medicus_db import get_medicus_db +from Knihovny.vzpb2b_client import VZPB2BClient + +# ── CONFIG ────────────────────────────────────────────────────────────────── +GRAPHQL_URL = "https://api.medevio.cz/graphql" +CLINIC_SLUG = "mudr-buzalkova" +TOKEN_PATH = Path("U:/OrdinaceProjekt/Medevio/token.txt") +CERT_PATH = Path("U:/OrdinaceProjekt/Insurance/Certificates/picka.pfx") +CERT_PASSWORD = "Vlado7309208104+" +ICZ = "09305000" +DELAY_VZP = 1.5 # s mezi VZP voláními — max 2 dotazy / 3 s + +DB_CONFIG = { + "host": "192.168.1.76", + "port": 3306, + "user": "root", + "password": "Vlado9674+", + "database": "medevio", + "charset": "utf8mb4", + "cursorclass": pymysql.cursors.DictCursor, +} + +MUTATION_REMOVE = """ +mutation ClinicPatientEditStatusModal_UpdateClinicPatientStatus( + $clinicSlug: String!, $patientId: String!, $status: ClinicPatientStatus! +) { + updated: updateClinicPatientStatus( + clinicSlug: $clinicSlug + patientId: $patientId + status: $status + ) +} +""" + +# ── HELPERS ────────────────────────────────────────────────────────────────── + +def load_token() -> str: + t = TOKEN_PATH.read_text(encoding="utf-8").strip() + return t.removeprefix("Bearer ").strip() + + +def medevio_headers(token: str) -> dict: + return { + "content-type": "application/json", + "authorization": f"Bearer {token}", + "origin": "https://my.medevio.cz", + "referer": "https://my.medevio.cz/", + } + + +# ── DATA LOADING ───────────────────────────────────────────────────────────── + +def load_patients() -> list[dict]: + """ + Vrátí pacienty ACTIVE v Medevio (MySQL), kteří nejsou registrovaní + v Medicusu (Firebird). Přidá datum posledního záznamu v dekurzu. + """ + # 1) Medicus — registrovaná rodná čísla + db = get_medicus_db() + rows = db.get_active_registered_patients() + medicus_rc: set[str] = {r[0].strip() for r in rows if r[0]} + db.close() + + # 2) MySQL — všichni ACTIVE s RC + conn = pymysql.connect(**DB_CONFIG) + with conn.cursor() as cur: + cur.execute(""" + SELECT patient_id, name, surname, identification_number, + insurance_name, insurance_code, user_id, dob + FROM medevio_pacient + WHERE status = 'ACTIVE' + AND identification_number IS NOT NULL + AND identification_number <> '' + AND surname NOT LIKE '%%\\%%%' + ORDER BY surname, name + """) + all_active = cur.fetchall() + conn.close() + + # 3) Filtruj mimo Medicus + extras = [p for p in all_active if p["identification_number"] not in medicus_rc] + + if not extras: + return extras + + # 4) Datum posledního záznamu v dekurzu (LOG tabulka) + rc_list = [p["identification_number"] for p in extras] + + # Bezpečné inline — RC jsou vždy jen číslice + rc_sql_in = ",".join(f"'{rc}'" for rc in rc_list) + + db2 = get_medicus_db() + try: + # Nejdřív idpac → rodcis mapping + idpac_rows = db2.query( + f"SELECT idpac, rodcis FROM kar WHERE rodcis IN ({rc_sql_in})" + ) + rc_to_idpac: dict[str, int] = {} + idpac_set: list[str] = [] + for r in idpac_rows: + rc_to_idpac[r[1].strip()] = r[0] + idpac_set.append(str(r[0])) + + # Datum posledního záznamu v LOG pro tyto idpac + dekurz_map: dict[int, str] = {} + if idpac_set: + idpac_sql_in = ",".join(idpac_set) + log_rows = db2.query( + f"SELECT idpac, MAX(datum) FROM log " + f"WHERE idpac IN ({idpac_sql_in}) GROUP BY idpac" + ) + dekurz_map = {r[0]: str(r[1])[:10] if r[1] else None for r in log_rows} + except Exception: + rc_to_idpac = {} + dekurz_map = {} + finally: + db2.close() + + for p in extras: + idpac = rc_to_idpac.get(p["identification_number"]) + p["posledni_dekurz"] = dekurz_map.get(idpac) if idpac else None + + return extras + + +def api_set_removed(token: str, patient_id: str) -> tuple[bool, str | None]: + """Volá Medevio GraphQL API — nastaví status REMOVED.""" + resp = requests.post( + GRAPHQL_URL, + headers=medevio_headers(token), + json={ + "operationName": "ClinicPatientEditStatusModal_UpdateClinicPatientStatus", + "query": MUTATION_REMOVE, + "variables": { + "clinicSlug": CLINIC_SLUG, + "patientId": patient_id, + "status": "REMOVED", + }, + }, + timeout=20, + ) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + return False, str(data["errors"]) + return bool(data.get("data", {}).get("updated")), None + + +def mysql_set_removed(patient_id: str) -> None: + """Aktualizuje status v MySQL tabulce.""" + conn = pymysql.connect(**DB_CONFIG) + with conn.cursor() as cur: + cur.execute( + "UPDATE medevio_pacient SET status='REMOVED' WHERE patient_id = %s", + (patient_id,), + ) + conn.commit() + conn.close() + + +# ── BINARY SEARCH — BOD ZLOMU POJIŠTĚNÍ ───────────────────────────────────── + +def _get_insurance_status(vzp_client: VZPB2BClient, rc: str, k_datu: str) -> dict: + """Vrátí parsed stav pojištění k danému datu — s retry na 503.""" + for attempt in range(3): + try: + xml = vzp_client.stav_pojisteni(rc, k_datu=k_datu) + return vzp_client.parse_stav_pojisteni(xml) + except Exception: + if attempt < 2: + time.sleep(3) + raise RuntimeError(f"API selhalo 3× pro {k_datu}") + + +def _is_active(status: dict) -> bool: + """Pojistěnec je 'aktivní' pokud API vrátilo stav '1' (pojištěn).""" + return status.get("stavVyrizeni") == 1 and status.get("stav") == "1" + + +def find_insurance_breakpoint( + vzp_client: VZPB2BClient, + rc: str, + years_back: int = 2, + progress_cb=None, +) -> dict: + """ + Binary search: hledá datum, kdy pacient přešel ze stavu 'pojištěn' + na jiný stav (jiná pojišťovna / nenalezen). + + Vrátí dict s klíči: + found bool — zda byl zlom nalezen + date_last str — poslední datum kdy byl stav 'aktivní' + date_first_x str — první datum kdy byl stav jiný + status_start dict — stav na začátku (dnes −years_back) + status_end dict — stav dnes + iterations int — počet API volání + message str — popis výsledku + """ + today = date.today() + start_date = today - timedelta(days=years_back * 365) + + iterations = 0 + + def check(d: date) -> tuple[dict, bool]: + nonlocal iterations + iterations += 1 + if progress_cb: + progress_cb(f"Testuju {d.isoformat()} (volání č. {iterations})…") + status = _get_insurance_status(vzp_client, rc, d.isoformat()) + time.sleep(1.5) + return status, _is_active(status) + + status_start, active_start = check(start_date) + status_end, active_end = check(today) + + # Pokud ani na začátku nebyl aktivní — nemáme levý okraj + if not active_start: + return { + "found": False, + "date_last": None, + "date_first_x": None, + "status_start": status_start, + "status_end": status_end, + "iterations": iterations, + "message": ( + f"Před {years_back} lety ({start_date.isoformat()}) již nebyl pojištěn " + f"nebo byl u jiné pojišťovny.\n" + f" Stav tehdy: {status_start.get('stav')} / " + f"pojišťovna: {status_start.get('nazevPojistovny')}" + ), + } + + # Pokud je dnes stále aktivní — zlom nenalezen v daném okně + if active_end: + return { + "found": False, + "date_last": today.isoformat(), + "date_first_x": None, + "status_start": status_start, + "status_end": status_end, + "iterations": iterations, + "message": ( + f"Pacient je stále pojištěn (i dnes).\n" + f" Pojišťovna dnes: {status_end.get('nazevPojistovny')} / " + f"stav: {status_end.get('stav')}" + ), + } + + # Binary search mezi start_date (aktivní) a today (neaktivní) + left = start_date + right = today + left_status = status_start + right_status = status_end + + while (right - left).days > 1: + mid = left + timedelta(days=(right - left).days // 2) + mid_status, mid_active = check(mid) + if mid_active: + left = mid + left_status = mid_status + else: + right = mid + right_status = mid_status + + return { + "found": True, + "date_last": left.isoformat(), + "date_first_x": right.isoformat(), + "status_start": status_start, + "status_end": status_end, + "iterations": iterations, + "message": ( + f"Bod zlomu nalezen:\n" + f" Poslední den jako pojištěnec : {left.isoformat()}\n" + f" Pojišťovna: {left_status.get('nazevPojistovny')} " + f"(kód {left_status.get('kodPojistovny')}) / " + f"stav: {left_status.get('stav')}\n" + f" První den se změnou : {right.isoformat()}\n" + f" Pojišťovna: {right_status.get('nazevPojistovny')} " + f"(kód {right_status.get('kodPojistovny')}) / " + f"stav: {right_status.get('stav')}\n" + f" Celkem API volání: {iterations}" + ), + } + + +# ── GUI ─────────────────────────────────────────────────────────────────────── + +COL_WIDTHS = { + "name": 28, # chars + "rc": 13, + "poj": 32, + "dob": 10, + "dek": 12, +} + +BG_EVEN = "#ffffff" +BG_ODD = "#f4f6f8" +BG_REMOVED = "#d5f5e3" +BG_HEADER = "#2c3e50" +FG_HEADER = "#ffffff" +BG_BTN_VZP = "#2980b9" +BG_BTN_REM = "#c0392b" + + +class PacientApp(tk.Tk): + def __init__(self): + super().__init__() + self.title("Pacienti Medevio · mimo Medicus") + self.geometry("1320x780") + self.minsize(900, 600) + self.configure(bg="#f0f0f0") + + self.token: str = "" + self.vzp_client: VZPB2BClient | None = None + self.patients: list[dict] = [] + + self._build_ui() + self._start_load() + + # ── UI CONSTRUCTION ────────────────────────────────────────────────────── + + def _build_ui(self): + self._build_topbar() + self._build_colheaders() + self._build_list_area() + self._build_bottom_panel() + + def _build_topbar(self): + bar = tk.Frame(self, bg=BG_HEADER, pady=8) + bar.pack(fill="x") + + tk.Label(bar, text="Pacienti ACTIVE v Medevio · mimo Medicus", + bg=BG_HEADER, fg=FG_HEADER, + font=("Segoe UI", 13, "bold")).pack(side="left", padx=14) + + self.lbl_status = tk.Label(bar, text="Načítám…", + bg=BG_HEADER, fg="#bdc3c7", + font=("Segoe UI", 10)) + self.lbl_status.pack(side="left", padx=8) + + tk.Button(bar, text="⟳ Obnovit", command=self._start_load, + bg="#3498db", fg="white", activebackground="#2980b9", + relief="flat", padx=12, pady=2, + cursor="hand2").pack(side="right", padx=14) + + def _build_colheaders(self): + hdr = tk.Frame(self, bg="#dfe6e9", pady=5, padx=10) + hdr.pack(fill="x") + cols = [ + ("Příjmení, Jméno", COL_WIDTHS["name"]), + ("Rodné číslo", COL_WIDTHS["rc"]), + ("Pojišťovna", COL_WIDTHS["poj"]), + ("Narozen", COL_WIDTHS["dob"]), + ("Posl. dekurz", COL_WIDTHS["dek"]), + ("", 12), + ("", 14), + ("", 18), + ] + for text, w in cols: + tk.Label(hdr, text=text, bg="#dfe6e9", + font=("Segoe UI", 9, "bold"), + width=w, anchor="w").pack(side="left") + + def _build_list_area(self): + wrapper = tk.Frame(self, bg="#e0e0e0") + wrapper.pack(fill="both", expand=True, padx=4, pady=(0, 4)) + + self.canvas = tk.Canvas(wrapper, bg="#ffffff", highlightthickness=0) + vsb = ttk.Scrollbar(wrapper, orient="vertical", command=self.canvas.yview) + self.canvas.configure(yscrollcommand=vsb.set) + + vsb.pack(side="right", fill="y") + self.canvas.pack(side="left", fill="both", expand=True) + + self.list_frame = tk.Frame(self.canvas, bg="#ffffff") + self._cwin = self.canvas.create_window((0, 0), window=self.list_frame, anchor="nw") + + self.list_frame.bind("", + lambda e: self.canvas.configure( + scrollregion=self.canvas.bbox("all"))) + self.canvas.bind("", + lambda e: self.canvas.itemconfig(self._cwin, width=e.width)) + self.canvas.bind("", + lambda e: self.canvas.yview_scroll( + int(-1 * (e.delta / 120)), "units")) + + def _build_bottom_panel(self): + pnl = tk.LabelFrame(self, text=" Výsledek VZP dotazu ", + font=("Segoe UI", 9, "bold"), + bg="#f0f0f0", pady=6, padx=8) + pnl.pack(fill="x", padx=6, pady=(0, 6)) + + self.vzp_text = tk.Text(pnl, height=13, + font=("Consolas", 9), + bg="#1e1e1e", fg="#d4d4d4", + insertbackground="#d4d4d4", + relief="flat", wrap="word", + state="disabled") + self.vzp_text.pack(fill="x") + self._vzp_write("Vyberte pacienta a stiskněte [VZP dotaz].") + + # ── DATA FLOW ──────────────────────────────────────────────────────────── + + def _start_load(self): + self.lbl_status.config(text="Načítám…") + for w in self.list_frame.winfo_children(): + w.destroy() + threading.Thread(target=self._bg_load, daemon=True).start() + + def _bg_load(self): + try: + self.token = load_token() + patients = load_patients() + self.after(0, lambda: self._populate(patients)) + except Exception as exc: + self.after(0, lambda: ( + self.lbl_status.config(text="Chyba při načítání"), + messagebox.showerror("Chyba načítání", str(exc)), + )) + + def _populate(self, patients: list[dict]): + self.patients = patients + n = len(patients) + self.lbl_status.config(text=f"Celkem: {n} pacientů") + + for i, p in enumerate(patients): + bg = BG_EVEN if i % 2 == 0 else BG_ODD + self._add_row(i, p, bg) + + def _add_row(self, idx: int, p: dict, bg: str): + row = tk.Frame(self.list_frame, bg=bg, pady=4) + row.pack(fill="x", padx=6) + + name = f"{p.get('surname', '')} {p.get('name', '')}" + rc = p.get("identification_number", "") + poj = p.get("insurance_name") or "—" + dob = str(p.get("dob") or "")[:10] or "—" + dek = p.get("posledni_dekurz") or "—" + + dek_fg = "#e74c3c" if dek == "—" else "#2c3e50" + + def lbl(text, width, fg="#2c3e50", font=("Segoe UI", 9)): + tk.Label(row, text=text, bg=bg, fg=fg, + font=font, width=width, anchor="w").pack(side="left", padx=2) + + lbl(name, COL_WIDTHS["name"]) + lbl(rc, COL_WIDTHS["rc"], font=("Consolas", 9)) + lbl(poj, COL_WIDTHS["poj"]) + lbl(dob, COL_WIDTHS["dob"]) + lbl(dek, COL_WIDTHS["dek"], fg=dek_fg) + + btn_vzp = tk.Button(row, text="VZP dotaz", + command=lambda pat=p: self._query_vzp(pat), + bg=BG_BTN_VZP, fg="white", + activebackground="#1a6fa8", + relief="flat", padx=8, cursor="hand2", + font=("Segoe UI", 9)) + btn_vzp.pack(side="left", padx=6) + + btn_zlom = tk.Button(row, text="Bod zlomu", + command=lambda pat=p: self._find_breakpoint(pat), + bg="#7d3c98", fg="white", + activebackground="#6c3483", + relief="flat", padx=8, cursor="hand2", + font=("Segoe UI", 9)) + btn_zlom.pack(side="left", padx=2) + + btn_rem = tk.Button(row, text="Označ neaktivní", + command=lambda pat=p, r=row: self._mark_removed(pat, r), + bg=BG_BTN_REM, fg="white", + activebackground="#922b21", + relief="flat", padx=8, cursor="hand2", + font=("Segoe UI", 9)) + btn_rem.pack(side="left", padx=4) + + # uložíme reference na tlačítka kvůli pozdějšímu deaktivování + row._btn_vzp = btn_vzp + row._btn_zlom = btn_zlom + row._btn_rem = btn_rem + + # ── VZP DOTAZ ──────────────────────────────────────────────────────────── + + def _query_vzp(self, p: dict): + name = f"{p.get('surname')} {p.get('name')}" + rc = p.get("identification_number", "") + self._vzp_write(f"Načítám VZP data pro {name} (RC: {rc})…") + + def worker(): + try: + if not self.vzp_client: + self.vzp_client = VZPB2BClient( + "prod", str(CERT_PATH), CERT_PASSWORD, icz=ICZ + ) + + # Stav pojištění + xml_poj = self.vzp_client.stav_pojisteni(rc) + poj_data = self.vzp_client.parse_stav_pojisteni(xml_poj) + + time.sleep(DELAY_VZP) + + # Registrující lékař v odbornosti 001 + xml_lek = self.vzp_client.registrace_lekare(rc, odbornosti=["001"]) + lek_list = self.vzp_client.parse_registrace_lekare(xml_lek) + + lines = [ + f"Pacient : {name}", + f"RC : {rc}", + "─" * 60, + ] + + # pojisteni + if poj_data and poj_data.get("nazevPojistovny"): + lines += [ + f"Pojišťovna : {poj_data['nazevPojistovny']} " + f"(kód {poj_data.get('kodPojistovny', '?')})", + f"Stav pojist: {poj_data.get('stav', '?')}", + ] + else: + lines.append("Pojišťovna : (nepodařilo se načíst)") + + lines.append("") + + # lékař + if lek_list: + for lek in lek_list: + do = lek.get("datum_ukonceni") or "dosud" + lines += [ + f"Lékař (001): {lek.get('nazev_lekare', '?')}", + f" ICP : {lek.get('ICP', '?')}", + f" ICZ : {lek.get('ICZ', '?')}", + f" Zařízení : {lek.get('nazev_zzz', '?')}", + f" Registrace: {lek.get('datum_registrace', '?')} — {do}", + ] + else: + lines.append("Lékař (001): NEMÁ REGISTRUJÍCÍHO PRAKTIKA") + + self.after(0, lambda: self._vzp_write("\n".join(lines))) + + except Exception as exc: + self.after(0, lambda: self._vzp_write(f"CHYBA: {exc}")) + + threading.Thread(target=worker, daemon=True).start() + + # ── BOD ZLOMU POJIŠTĚNÍ ────────────────────────────────────────────────── + + def _find_breakpoint(self, p: dict): + name = f"{p.get('surname')} {p.get('name')}" + rc = p.get("identification_number", "") + self._vzp_write( + f"Hledám bod zlomu pojištění pro {name} (RC: {rc})…\n" + f" Začínám od dnes −2 roky, binary search (~10 API volání)." + ) + + def worker(): + try: + if not self.vzp_client: + self.vzp_client = VZPB2BClient( + "prod", str(CERT_PATH), CERT_PASSWORD, icz=ICZ + ) + + result = find_insurance_breakpoint( + self.vzp_client, rc, + years_back=2, + progress_cb=lambda msg: self.after( + 0, lambda m=msg: self._vzp_write( + f"Hledám bod zlomu pro {name}…\n {m}" + ) + ), + ) + + lines = [ + f"Pacient : {name}", + f"RC : {rc}", + "─" * 60, + result["message"], + "", + f"Stav na začátku ({date.today() - timedelta(days=730)}):", + f" pojišťovna: {result['status_start'].get('nazevPojistovny')} " + f"/ stav: {result['status_start'].get('stav')}", + f"Stav dnes ({date.today()}):", + f" pojišťovna: {result['status_end'].get('nazevPojistovny')} " + f"/ stav: {result['status_end'].get('stav')}", + ] + self.after(0, lambda: self._vzp_write("\n".join(lines))) + + except Exception as exc: + self.after(0, lambda: self._vzp_write(f"CHYBA: {exc}")) + + threading.Thread(target=worker, daemon=True).start() + + # ── OZNAČENÍ NEAKTIVNÍ ─────────────────────────────────────────────────── + + def _mark_removed(self, p: dict, row: tk.Frame): + name = f"{p.get('surname')} {p.get('name')}" + rc = p.get("identification_number", "") + if not messagebox.askyesno( + "Potvrdit akci", + f"Označit pacienta jako NEAKTIVNÍ?\n\n{name}\nRC: {rc}", + ): + return + + row._btn_rem.config(state="disabled", text="…") + + def worker(): + try: + ok, err = api_set_removed(self.token, p["patient_id"]) + if not ok: + self.after(0, lambda: ( + row._btn_rem.config(state="normal", text="Označ neaktivní"), + messagebox.showerror("Chyba Medevio API", str(err)), + )) + return + + mysql_set_removed(p["patient_id"]) + self.after(0, lambda: self._row_done(row, name)) + + except Exception as exc: + self.after(0, lambda: ( + row._btn_rem.config(state="normal", text="Označ neaktivní"), + messagebox.showerror("Chyba", str(exc)), + )) + + threading.Thread(target=worker, daemon=True).start() + + def _row_done(self, row: tk.Frame, name: str): + row.configure(bg=BG_REMOVED) + for w in row.winfo_children(): + if isinstance(w, tk.Label): + w.configure(bg=BG_REMOVED) + elif isinstance(w, tk.Button): + w.configure(state="disabled", relief="flat") + self._vzp_write(f"✓ Pacient označen jako NEAKTIVNÍ: {name}") + + # ── HELPERS ────────────────────────────────────────────────────────────── + + def _vzp_write(self, text: str): + self.vzp_text.config(state="normal") + self.vzp_text.delete("1.0", "end") + self.vzp_text.insert("end", text) + self.vzp_text.config(state="disabled") + + +# ── ENTRY POINT ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + app = PacientApp() + app.mainloop()