Medevio/80 Pacienti: GUI pro správu pacientů mimo Medicus
- pacienti_gui.py: Tkinter aplikace se scrollovatelným seznamem pacientů ACTIVE v Medevio, kteří nejsou registrovaní v Medicusu; tlačítka VZP dotaz, Bod zlomu (binary search pojištění) a Označ neaktivní - _test_breakpoint.py: dočasný testovací skript pro binary search - NOTES.md: technická dokumentace adresáře Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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("<Configure>",
|
||||
lambda e: self.canvas.configure(
|
||||
scrollregion=self.canvas.bbox("all")))
|
||||
self.canvas.bind("<Configure>",
|
||||
lambda e: self.canvas.itemconfig(self._cwin, width=e.width))
|
||||
self.canvas.bind("<MouseWheel>",
|
||||
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()
|
||||
Reference in New Issue
Block a user