Compare commits

..

36 Commits

Author SHA1 Message Date
Vladimir Buzalka e981659621 notebookvb 2026-06-19 11:30:52 +02:00
administrator e5315b821e z230 2026-06-18 12:15:57 +02:00
Vladimir Buzalka 19036b58cc notebookvb 2026-06-18 05:32:36 +02:00
administrator 0beaffec45 z230 2026-06-17 15:06:06 +02:00
administrator 26e44fc721 Merge remote-tracking branch 'origin/master' 2026-06-17 12:06:53 +02:00
administrator dc07e19179 z230 2026-06-17 11:53:54 +02:00
Vladimir Buzalka 45c32a37c4 notebookvb 2026-06-17 05:22:34 +02:00
Vladimir Buzalka 39d33b76f3 notebookvb 2026-06-16 19:38:33 +02:00
Vladimir Buzalka 6e4305e182 notebookvb 2026-06-16 19:38:25 +02:00
administrator 9edfddae95 z230 2026-06-16 17:56:47 +02:00
Vladimir Buzalka 9b6f89f437 notebookvb 2026-06-16 10:21:19 +02:00
Vladimir Buzalka 672ee26357 notebookvb 2026-06-16 06:48:35 +02:00
Vladimir Buzalka e23d61de84 Přejmenování Insurance/KdoJeLékař → KdoJeLekar (bez diakritiky)
Adresář přejmenován bez diakritiky kvůli problémům s kódováním cesty.
Historie zachována přes git mv. Nadpis v NOTES.md sjednocen s názvem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:10:59 +02:00
Vladimir Buzalka 79216dfbdb notebookvb 2026-06-15 18:07:32 +02:00
administrator 8142de5216 z230 2026-06-15 16:10:24 +02:00
Vladimir Buzalka 2bdac59676 notebookvb 2026-06-14 12:07:35 +02:00
Vladimir Buzalka 9133fe9497 notebookvb 2026-06-14 08:22:25 +02:00
Vladimir Buzalka 2346ad7739 notebookvb 2026-06-13 21:46:11 +02:00
administrator ca39622ddd z230 2026-06-12 16:28:02 +02:00
administrator bed5576efa z230 2026-06-12 15:32:22 +02:00
administrator 51ee67c7f3 z230 2026-06-10 09:25:19 +02:00
administrator f595e60d40 z230 2026-06-10 09:11:30 +02:00
administrator a3b1e58a71 Merge remote-tracking branch 'origin/master' 2026-06-10 08:53:30 +02:00
administrator 2028532eff z230 2026-06-10 08:53:19 +02:00
Vladimir Buzalka a7f33afb66 notebookvb 2026-06-10 08:53:01 +02:00
Vladimir Buzalka 4723f9b174 notebookvb 2026-06-09 15:45:22 +02:00
administrator 178b0e4164 z230 2026-06-08 15:54:39 +02:00
administrator 914452a96d z230 2026-06-08 15:50:13 +02:00
administrator c9f94de286 z230 2026-06-05 15:48:09 +02:00
administrator d850486eb9 z230 2026-06-05 14:17:52 +02:00
administrator 592e6cd2a2 z230 2026-06-05 14:12:02 +02:00
administrator 76e9427901 z230 2026-06-04 12:08:50 +02:00
administrator d16038d09c z230 2026-06-03 09:32:25 +02:00
administrator a29a6845a1 z230 2026-06-02 10:43:23 +02:00
administrator e79458d670 z230 2026-06-02 09:40:05 +02:00
Vladimir Buzalka d5f2dc3925 notebookvb 2026-06-02 07:03:58 +02:00
140 changed files with 20487 additions and 354 deletions
+1
View File
@@ -0,0 +1 @@
OPENAI_API_KEY=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
+12
View File
@@ -12,6 +12,10 @@ __pycache__/
.claude/worktrees/ .claude/worktrees/
.claude/settings.local.json .claude/settings.local.json
# Secrets (.env s API klíči - nikdy do gitu!)
.env
**/.env
# Certifikáty (soukromé klíče - nikdy do gitu!) # Certifikáty (soukromé klíče - nikdy do gitu!)
**/*.pfx **/*.pfx
**/*.p12 **/*.p12
@@ -26,9 +30,17 @@ __pycache__/
# Logy # Logy
*.log *.log
# Dočasné zámkové soubory MS Office (vznikají při otevřeném Excelu/Wordu)
~$*
**/~$*
# Chrome profily (Playwright) — nikdy do gitu # Chrome profily (Playwright) — nikdy do gitu
**/chrome_profile/ **/chrome_profile/
# Cookies (session tokeny) # Cookies (session tokeny)
**/vozp_cookies.json **/vozp_cookies.json
**/vzp_cookies.json **/vzp_cookies.json
# Telegram user session (Telethon) — drží přihlášení, nikdy do gitu!
**/*.session
**/*.session-journal
Binary file not shown.
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
# OrdinaceProjekt
## DŮLEŽITÉ — pracovní adresář
Hlavní projekt je **adresář obsahující tento soubor AGENTS.md** (kořen projektu OrdinaceProjekt).
Výsledné soubory (skripty, knihovny, data) vždy ukládej do tohoto kořenového adresáře nebo jeho podadresářů.
Worktree (`.Codex/worktrees/*`) slouží jen pro interní práci Codex, ne jako výstup.
## Přečti na začátku každé konverzace
Každý adresář se skriptem má vlastní `NOTES.md` s technickými detaily. Přečti relevantní NOTES.md podle toho, čeho se konverzace týká.
## Anthropic API klíč
Uložen v `Medevio/.env` jako `ANTHROPIC_API_KEY=sk-ant-...`.
Skripty, které volají Codex API, si ho načítají samy — vzor:
```python
def _load_env():
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
_load_env()
```
## Sdílené knihovny (`Knihovny/`)
Před psaním nového kódu vždy zkontroluj, zda existuje vhodná sdílená funkce.
Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
| Modul | Klíčová funkce / třída | Popis |
|-------|------------------------|-------|
| `najdi_dropbox.py` | `get_dropbox_root() → str` | Zjistí cestu k Dropboxu z registru nebo info.json — **používej místo pevných cest** |
| `EmailMessagingGraph.py` | — | Odesílání e-mailů přes Microsoft Graph API |
| `mysql_db.py` | — | Připojení a operace s MySQL databází |
| `medicus_db.py` | — | Připojení k databázi Medicus (Firebird) |
| `vzpb2b_client.py` | — | Klient pro VZP B2B API (stav pojištění) |
## Přehled skriptů
| Skript | Adresář | Popis |
|--------|---------|-------|
| `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) |
| `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) |
+5
View File
@@ -41,6 +41,8 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
| `mysql_db.py` | — | Připojení a operace s MySQL databází | | `mysql_db.py` | — | Připojení a operace s MySQL databází |
| `medicus_db.py` | — | Připojení k databázi Medicus (Firebird) | | `medicus_db.py` | — | Připojení k databázi Medicus (Firebird) |
| `vzpb2b_client.py` | — | Klient pro VZP B2B API (stav pojištění) | | `vzpb2b_client.py` | — | Klient pro VZP B2B API (stav pojištění) |
| `telegram_notify.py` | `posli_telegram()`, `zeptej_se_telegram()` | Notifikace a obousměrná komunikace přes Telegram **bota** (@Vlado_Claude_Bot) |
| `telegram_user.py` | `posli_jako_ja()`, `zeptej_se_jako()` | Komunikace přes plnohodnotný **user účet** agenta (Telethon, víc agentů = víc sessions) |
## Přehled skriptů ## Přehled skriptů
@@ -48,3 +50,6 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
|--------|---------|-------| |--------|---------|-------|
| `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) | | `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) |
| `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) | | `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) |
| `watcher.py` | `Webináře/` | Hlídá nové webináře na praktickylekar.online, přes Telegram potvrdí a přihlásí Buzalkovi — viz [NOTES.md](Webináře/NOTES.md) |
| `stahni_video.py` | `Video/` | Stahuje videa (Vimeo, YouTube…) přes yt-dlp; soukromá/nedostupná sám přeskočí — viz [NOTES.md](Video/NOTES.md) |
| `euni_stahni.py`, `euni_db.py`, `euni_report.py` | `Euni/` | Stahování kurzů z euni.cz (PDF + videa) s trackingem v MongoDB EUNI (idempotentní) — viz [NOTES.md](Euni/NOTES.md) |
+408
View File
@@ -0,0 +1,408 @@
# -*- coding: utf-8 -*-
"""
Načte DASTA XML soubory a uloží je do PostgreSQL databáze `ordinace`
do tabulek s prefixem `dasta_`.
Vše čistě v Pythonu přes psycopg (v3). Skript je IDEMPOTENTNÍ:
- databázi `ordinace` založí, jen pokud neexistuje
- tabulky vytvoří přes CREATE TABLE IF NOT EXISTS
- každý soubor se nahrává podle klíče = název souboru (bez přípony);
při opakovaném běhu se zpráva UPSERTne a její výsledky/diagnózy
se smažou a vloží znovu → výsledek je vždy stejný, žádné duplicity.
Připojení se bere z Medevio/.env (PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DB).
Použití:
python nahraj_do_postgres.py # zdroj = U:\\DASTA (výchozí)
python nahraj_do_postgres.py D:\\jine\\dasta # jiný zdrojový adresář
python nahraj_do_postgres.py U:\\DASTA --limit 50 # jen prvních 50 (test)
python nahraj_do_postgres.py --recreate # zahodí dasta_ tabulky a založí znovu
Tabulky:
dasta_pacient (rodne_cislo PK)
dasta_zprava (soubor PK) → pacient
dasta_vysledek (id PK) → zprava [jednotlivé analyty]
dasta_diagnoza (id PK) → zprava
"""
from __future__ import annotations
import os
import re
import sys
from datetime import date, datetime
from pathlib import Path
from xml.etree import ElementTree as ET
import psycopg
ZDROJ_VYCHOZI = Path(r"U:\DASTA")
# ---------------------------------------------------------------------------
# .env
# ---------------------------------------------------------------------------
def _load_env() -> None:
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
_load_env()
PG = dict(
host=os.environ.get("PG_HOST", "localhost"),
port=os.environ.get("PG_PORT", "5432"),
user=os.environ.get("PG_USER"),
password=os.environ.get("PG_PASSWORD"),
)
PG_DB = os.environ.get("PG_DB", "ordinace")
# ---------------------------------------------------------------------------
# Konverze hodnot
# ---------------------------------------------------------------------------
def num(s: str | None) -> float | None:
"""Český zápis čísla ('7,5') → float. Nečíselné vrací None."""
if s is None:
return None
t = s.strip().replace("\xa0", "").replace(" ", "").replace(",", ".")
try:
return float(t)
except ValueError:
return None
def ts(s: str | None) -> datetime | None:
"""'2016-06-20T11:15:18' nebo '2017-05-18T07:30' → datetime."""
if not s:
return None
try:
return datetime.fromisoformat(s.strip())
except ValueError:
return None
def dat(s: str | None) -> date | None:
if not s:
return None
try:
return date.fromisoformat(s.strip()[:10])
except ValueError:
return None
def _text(el, tag):
if el is None:
return None
c = el.find(tag)
return c.text.strip() if (c is not None and c.text) else None
# ---------------------------------------------------------------------------
# Schéma
# ---------------------------------------------------------------------------
DDL = """
CREATE TABLE IF NOT EXISTS dasta_pacient (
rodne_cislo text PRIMARY KEY,
jmeno text,
prijmeni text,
dat_narozeni date,
sex text
);
CREATE TABLE IF NOT EXISTS dasta_zprava (
soubor text PRIMARY KEY,
id_soubor text,
ozn_soub text,
dat_vytvoreni timestamp,
verze_ds text,
typ_odesm text,
zdroj_prog text,
zdroj_verze text,
odesilatel_icp text,
odesilatel_ico text,
odesilatel_nazev text,
prijemce_icp text,
prijemce_nazev text,
rodne_cislo text REFERENCES dasta_pacient(rodne_cislo)
);
CREATE TABLE IF NOT EXISTS dasta_vysledek (
id bigserial PRIMARY KEY,
soubor text NOT NULL REFERENCES dasta_zprava(soubor) ON DELETE CASCADE,
klic_nclp text,
nazev text,
jednotka text,
hodnota_raw text,
hodnota_num double precision,
dat_odber timestamp,
dat_odber_typ text,
dat_vydani timestamp,
autor text,
stav text,
typ_kvant text,
ref_low double precision,
ref_high double precision,
mimo_normu smallint
);
CREATE TABLE IF NOT EXISTS dasta_diagnoza (
id bigserial PRIMARY KEY,
soubor text NOT NULL REFERENCES dasta_zprava(soubor) ON DELETE CASCADE,
poradi int,
kod text
);
CREATE INDEX IF NOT EXISTS ix_dasta_vysledek_soubor ON dasta_vysledek(soubor);
CREATE INDEX IF NOT EXISTS ix_dasta_vysledek_nclp ON dasta_vysledek(klic_nclp);
CREATE INDEX IF NOT EXISTS ix_dasta_vysledek_odber ON dasta_vysledek(dat_odber);
CREATE INDEX IF NOT EXISTS ix_dasta_zprava_rc ON dasta_zprava(rodne_cislo);
"""
DROP = """
DROP TABLE IF EXISTS dasta_vysledek CASCADE;
DROP TABLE IF EXISTS dasta_diagnoza CASCADE;
DROP TABLE IF EXISTS dasta_zprava CASCADE;
DROP TABLE IF EXISTS dasta_pacient CASCADE;
"""
def ensure_database() -> None:
"""Založí DB `ordinace`, pokud neexistuje (mimo transakci)."""
with psycopg.connect(dbname="postgres", autocommit=True, connect_timeout=10, **PG) as c:
exists = c.execute(
"SELECT 1 FROM pg_database WHERE datname = %s", (PG_DB,)
).fetchone()
if not exists:
# TEMPLATE template0 obchází collation version mismatch u template1
c.execute(f'CREATE DATABASE "{PG_DB}" TEMPLATE template0')
print(f"Databáze {PG_DB} vytvořena.")
else:
print(f"Databáze {PG_DB} už existuje.")
# ---------------------------------------------------------------------------
# Parsování jednoho souboru → (pacient, zprava, vysledky, diagnozy)
# ---------------------------------------------------------------------------
_RE_ENC = re.compile(r"encoding=['\"][^'\"]+['\"]", re.I)
def _nacti_root(raw: bytes):
"""Naparsuje XML; když selže (špatně deklarované kódování), zkusí UTF-8."""
try:
return ET.fromstring(raw)
except ET.ParseError:
# Některé soubory deklarují Windows-1250, ale jsou v UTF-8.
text = raw.decode("utf-8", errors="replace")
text = _RE_ENC.sub("", text, count=1) # odstraň chybnou deklaraci
return ET.fromstring(text)
def parse_file(cesta: Path):
root = _nacti_root(cesta.read_bytes())
soubor = cesta.stem
zdroj = root.find("zdroj_is")
pm = root.find("pm")
is_el = root.find("is")
pm_a = pm.find("a") if pm is not None else None
is_a = is_el.find("a") if is_el is not None else None
ip = is_el.find("ip") if is_el is not None else None
# pacient
rodne_cislo = _text(ip, "rodcis") if ip is not None else None
pacient = None
if rodne_cislo:
pacient = (
rodne_cislo,
_text(ip, "jmeno"),
_text(ip, "prijmeni"),
dat(_text(ip, "dat_dn")),
_text(ip, "sex"),
)
# pojišťovna se sem nedává (lze doplnit), držíme se zadaného rozsahu
zprava = (
soubor,
root.get("id_soubor"),
root.get("ozn_soub"),
ts(root.get("dat_vb")),
root.get("verze_ds"),
root.get("typ_odesm"),
zdroj.get("kod_prog") if zdroj is not None else None,
zdroj.get("verze_prog") if zdroj is not None else None,
is_el.get("icp") if is_el is not None else None,
is_el.get("ico") if is_el is not None else None,
_text(is_a, "jmeno") if is_a is not None else None,
pm.get("icp") if pm is not None else None,
_text(pm_a, "jmeno") if pm_a is not None else None,
rodne_cislo,
)
vysledky = []
diagnozy = []
if ip is not None:
dg = ip.find("dg")
if dg is not None:
for i, diag in enumerate(dg.iter("diag"), 1):
if diag.text:
diagnozy.append((soubor, i, diag.text.strip()))
v = ip.find("v")
if v is not None:
for vr in v.findall("vr"):
vrn = vr.find("vrn")
nazvy = vrn.find("nazvy") if vrn is not None else None
skala = vrn.find("skala") if vrn is not None else None
dat_du = vr.find("dat_du")
ref_low = ref_high = None
if skala is not None:
ref_low = num(_text(skala, "s4"))
ref_high = num(_text(skala, "s5"))
hodnota_raw = _text(vrn, "hodnota") if vrn is not None else None
hodnota_num = num(hodnota_raw)
mimo = None
if hodnota_num is not None and (ref_low is not None or ref_high is not None):
mimo = 0
if ref_low is not None and hodnota_num < ref_low:
mimo = 1
if ref_high is not None and hodnota_num > ref_high:
mimo = 1
vysledky.append((
soubor,
vr.get("klic_nclp"),
_text(vr, "nazev_lclp"),
nazvy.get("jednotka") if nazvy is not None else None,
hodnota_raw,
hodnota_num,
ts(dat_du.text if dat_du is not None else None),
dat_du.get("typ") if dat_du is not None else None,
ts(_text(vr, "dat_vv")),
_text(vr, "autor"),
vr.get("stav_vys"),
vrn.get("priznak_kvant") if vrn is not None else None,
ref_low,
ref_high,
mimo,
))
return pacient, zprava, vysledky, diagnozy
# ---------------------------------------------------------------------------
# Zápis (idempotentní)
# ---------------------------------------------------------------------------
UPSERT_PACIENT = """
INSERT INTO dasta_pacient (rodne_cislo, jmeno, prijmeni, dat_narozeni, sex)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (rodne_cislo) DO UPDATE SET
jmeno=EXCLUDED.jmeno, prijmeni=EXCLUDED.prijmeni,
dat_narozeni=EXCLUDED.dat_narozeni, sex=EXCLUDED.sex;
"""
UPSERT_ZPRAVA = """
INSERT INTO dasta_zprava (soubor,id_soubor,ozn_soub,dat_vytvoreni,verze_ds,typ_odesm,
zdroj_prog,zdroj_verze,odesilatel_icp,odesilatel_ico,odesilatel_nazev,
prijemce_icp,prijemce_nazev,rodne_cislo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (soubor) DO UPDATE SET
id_soubor=EXCLUDED.id_soubor, ozn_soub=EXCLUDED.ozn_soub,
dat_vytvoreni=EXCLUDED.dat_vytvoreni, verze_ds=EXCLUDED.verze_ds,
typ_odesm=EXCLUDED.typ_odesm, zdroj_prog=EXCLUDED.zdroj_prog,
zdroj_verze=EXCLUDED.zdroj_verze, odesilatel_icp=EXCLUDED.odesilatel_icp,
odesilatel_ico=EXCLUDED.odesilatel_ico, odesilatel_nazev=EXCLUDED.odesilatel_nazev,
prijemce_icp=EXCLUDED.prijemce_icp, prijemce_nazev=EXCLUDED.prijemce_nazev,
rodne_cislo=EXCLUDED.rodne_cislo;
"""
INS_VYSLEDEK = """
INSERT INTO dasta_vysledek (soubor,klic_nclp,nazev,jednotka,hodnota_raw,hodnota_num,
dat_odber,dat_odber_typ,dat_vydani,autor,stav,typ_kvant,ref_low,ref_high,mimo_normu)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s);
"""
INS_DIAGNOZA = "INSERT INTO dasta_diagnoza (soubor,poradi,kod) VALUES (%s,%s,%s);"
def main() -> None:
args = sys.argv[1:]
recreate = "--recreate" in args
limit = None
if "--limit" in args:
limit = int(args[args.index("--limit") + 1])
pozicni = [a for a in args if not a.startswith("--")]
# odfiltruj hodnotu za --limit
if limit is not None and pozicni and pozicni[0] == str(limit):
pozicni = pozicni[1:]
zdroj = Path(pozicni[0]) if pozicni else ZDROJ_VYCHOZI
print(f"Zdroj: {zdroj}")
print(f"Cíl: postgresql://{PG['host']}:{PG['port']}/{PG_DB} (tabulky dasta_*)")
ensure_database()
soubory = sorted(zdroj.glob("*.xml"))
if limit:
soubory = soubory[:limit]
print(f"Souborů ke zpracování: {len(soubory)}")
print("-" * 60)
ok = chyb = 0
chyby = []
# autocommit=True → každý soubor je samostatná transakce (conn.transaction),
# takže chyba u jednoho souboru nikdy neovlivní ostatní.
with psycopg.connect(dbname=PG_DB, autocommit=True, connect_timeout=10, **PG) as conn:
with conn.cursor() as cur:
if recreate:
cur.execute(DROP)
print("Tabulky dasta_* zahozeny.")
cur.execute(DDL)
cur = conn.cursor()
for i, src in enumerate(soubory, 1):
try:
pacient, zprava, vysledky, diagnozy = parse_file(src)
with conn.transaction(): # savepoint pro tento soubor
if pacient:
cur.execute(UPSERT_PACIENT, pacient)
cur.execute(UPSERT_ZPRAVA, zprava)
cur.execute("DELETE FROM dasta_vysledek WHERE soubor=%s", (src.stem,))
cur.execute("DELETE FROM dasta_diagnoza WHERE soubor=%s", (src.stem,))
if vysledky:
cur.executemany(INS_VYSLEDEK, vysledky)
if diagnozy:
cur.executemany(INS_DIAGNOZA, diagnozy)
ok += 1
except Exception as e:
chyb += 1
chyby.append(f"{src.name}: {type(e).__name__} {e}")
continue
if i % 500 == 0:
print(f" ... {i}/{len(soubory)}")
print("-" * 60)
print(f"Hotovo. Zpráv OK: {ok} Chyb: {chyb}")
if chyby:
print("Chyby:")
for c in chyby[:20]:
print(" " + c)
# Souhrn
with psycopg.connect(dbname=PG_DB, **PG) as conn:
for t in ("dasta_pacient", "dasta_zprava", "dasta_vysledek", "dasta_diagnoza"):
n = conn.execute(f"SELECT count(*) FROM {t}").fetchone()[0]
print(f" {t:18}: {n}")
if __name__ == "__main__":
main()
+260
View File
@@ -0,0 +1,260 @@
# -*- coding: utf-8 -*-
"""
Parser DASTA XML (Datový standard MZ ČR, verze DS 03.01.01).
Rozebírá laboratorní zprávy (typ odesílatele LB) do strukturovaných dat.
Soubory jsou v kódování windows-1250 (uvedeno v XML deklaraci) a obsahují
DOCTYPE odkaz na lokální DTD (ds030101.dtd), který při parsování ignorujeme.
Struktura DASTA dávky (zjednodušeně):
dasta kořen, hlavička dávky (datum, verze, odesílatel)
zdroj_is informační systém, který dávku vytvořil
pm (icp) příjemce zprávy (ordinace) + adresa (a typ="P")
is (icp, ico) odesílatel = laboratoř + adresa (a typ="O")
ip (id_pac) pacient: rodné číslo, jméno, dat. narození, sex
pv / p pojišťovna (kodpoj, typpoj)
dg / dgz / diag diagnózy
v blok výsledků
vr (klic_nclp...) jeden laboratorní výsledek (analyt)
nazev_lclp název položky (WBC, RBC, ...)
dat_du/dat_pl/dat_vv odběr / příjem / vydání výsledku
autor validující lékař
vrn číselný výsledek
hodnota naměřená hodnota
nazvy@jednotka měrná jednotka
skala s1..s8 referenční pásma (meze)
interpret_g_z grafická interpretace | | * | |
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from xml.etree import ElementTree as ET
# ---------------------------------------------------------------------------
# Datové třídy
# ---------------------------------------------------------------------------
@dataclass
class Vysledek:
klic_nclp: str # kód NČLP (Národní číselník laboratorních položek)
nazev: str # lokální název položky (WBC, RBC, ...)
hodnota: str | None
jednotka: str | None
dat_odber: str | None # dat_du datum a čas odběru
dat_vydani: str | None # dat_vv datum a čas vydání výsledku
autor: str | None
stav: str | None # stav_vys (A = definitivní)
typ_kvant: str | None # priznak_kvant (R = reálné číslo)
interpret: str | None # grafická interpretace mezí
meze: list[str] = field(default_factory=list) # s1..s8
@property
def referencni_mez(self) -> str | None:
"""Klinicky relevantní referenční rozmezí = s4 (dolní) až s5 (horní)."""
if len(self.meze) >= 5:
return f"{self.meze[3]} {self.meze[4]}"
return None
@dataclass
class Pacient:
id_pac: str | None
rodne_cislo: str | None
jmeno: str | None
prijmeni: str | None
datum_narozeni: str | None
sex: str | None
pojistovna: str | None
typ_pojisteni: str | None
diagnozy: list[str] = field(default_factory=list)
vysledky: list[Vysledek] = field(default_factory=list)
@dataclass
class Adresa:
jmeno: str | None = None
radky: list[str] = field(default_factory=list)
psc: str | None = None
mesto: str | None = None
@dataclass
class DastaZprava:
ozn_soub: str | None
id_soubor: str | None
datum_vytvoreni: str | None
verze_ds: str | None
typ_odesilatele: str | None
zdroj_program: str | None
zdroj_verze: str | None
prijemce_icp: str | None
prijemce: Adresa | None
odesilatel_icp: str | None
odesilatel_ico: str | None
odesilatel: Adresa | None
pacienti: list[Pacient] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Pomocné funkce
# ---------------------------------------------------------------------------
def _text(el, tag: str) -> str | None:
"""Vrátí text potomka `tag` nebo None."""
if el is None:
return None
child = el.find(tag)
return child.text.strip() if (child is not None and child.text) else None
def _parse_adresa(el) -> Adresa | None:
if el is None:
return None
a = el.find("a")
if a is None:
return None
radky = [v for v in (_text(a, "adr"), _text(a, "dop1"), _text(a, "dop2")) if v]
return Adresa(
jmeno=_text(a, "jmeno"),
radky=radky,
psc=_text(a, "psc"),
mesto=_text(a, "mesto"),
)
# ---------------------------------------------------------------------------
# Hlavní parser
# ---------------------------------------------------------------------------
def parse_dasta(cesta: str | Path) -> DastaZprava:
cesta = Path(cesta)
# XML obsahuje DOCTYPE s odkazem na DTD; vypneme načítání externích entit
# tím, že parsujeme bez resolveru. ElementTree DTD ignoruje automaticky.
raw = cesta.read_bytes()
# ElementTree si kódování přečte z XML deklarace (<?xml ... encoding=...?>).
root = ET.fromstring(raw)
zdroj = root.find("zdroj_is")
pm = root.find("pm")
is_el = root.find("is")
zprava = DastaZprava(
ozn_soub=root.get("ozn_soub"),
id_soubor=root.get("id_soubor"),
datum_vytvoreni=root.get("dat_vb"),
verze_ds=root.get("verze_ds"),
typ_odesilatele=root.get("typ_odesm"),
zdroj_program=zdroj.get("kod_prog") if zdroj is not None else None,
zdroj_verze=zdroj.get("verze_prog") if zdroj is not None else None,
prijemce_icp=pm.get("icp") if pm is not None else None,
prijemce=_parse_adresa(pm),
odesilatel_icp=is_el.get("icp") if is_el is not None else None,
odesilatel_ico=is_el.get("ico") if is_el is not None else None,
odesilatel=_parse_adresa(is_el),
)
if is_el is None:
return zprava
for ip in is_el.findall("ip"):
# pojišťovna bere se z elementu <p> (přímo nebo uvnitř <pv>)
p = ip.find("p")
if p is None:
pv = ip.find("pv")
p = pv.find("p") if pv is not None else None
pacient = Pacient(
id_pac=ip.get("id_pac"),
rodne_cislo=_text(ip, "rodcis"),
jmeno=_text(ip, "jmeno"),
prijmeni=_text(ip, "prijmeni"),
datum_narozeni=_text(ip, "dat_dn"),
sex=_text(ip, "sex"),
pojistovna=_text(p, "kodpoj") if p is not None else None,
typ_pojisteni=_text(p, "typpoj") if p is not None else None,
)
# diagnózy
dg = ip.find("dg")
if dg is not None:
for diag in dg.iter("diag"):
if diag.text:
pacient.diagnozy.append(diag.text.strip())
# výsledky
v = ip.find("v")
if v is not None:
for vr in v.findall("vr"):
vrn = vr.find("vrn")
nazvy = vrn.find("nazvy") if vrn is not None else None
skala = vrn.find("skala") if vrn is not None else None
meze = []
interpret = None
if skala is not None:
for i in range(1, 9):
meze.append(_text(skala, f"s{i}") or "")
interpret = _text(skala, "interpret_g_z")
pacient.vysledky.append(Vysledek(
klic_nclp=vr.get("klic_nclp"),
nazev=_text(vr, "nazev_lclp"),
hodnota=_text(vrn, "hodnota") if vrn is not None else None,
jednotka=nazvy.get("jednotka") if nazvy is not None else None,
dat_odber=_text(vr, "dat_du"),
dat_vydani=_text(vr, "dat_vv"),
autor=_text(vr, "autor"),
stav=vr.get("stav_vys"),
typ_kvant=vrn.get("priznak_kvant") if vrn is not None else None,
interpret=interpret,
meze=meze,
))
zprava.pacienti.append(pacient)
return zprava
# ---------------------------------------------------------------------------
# Výpis přehledu
# ---------------------------------------------------------------------------
def vypis_prehled(z: DastaZprava) -> None:
print("=" * 70)
print(f"DASTA dávka {z.ozn_soub} (DS {z.verze_ds}, typ {z.typ_odesilatele})")
print(f"Vytvořeno: {z.datum_vytvoreni}")
print(f"Program: {z.zdroj_program} {z.zdroj_verze}")
print(f"ID souboru: {z.id_soubor}")
print("-" * 70)
if z.odesilatel:
print(f"Odesílatel (laboratoř) IČP {z.odesilatel_icp} IČO {z.odesilatel_ico}")
print(f" {z.odesilatel.jmeno} | {', '.join(z.odesilatel.radky)}")
print(f" {z.odesilatel.psc} {z.odesilatel.mesto}")
if z.prijemce:
print(f"Příjemce (ordinace) IČP {z.prijemce_icp}")
print(f" {z.prijemce.jmeno} | {', '.join(z.prijemce.radky)}")
print("=" * 70)
for pac in z.pacienti:
print(f"\nPacient: {pac.prijmeni} {pac.jmeno} r.č. {pac.rodne_cislo}"
f" nar. {pac.datum_narozeni} {pac.sex}")
print(f" Pojišťovna {pac.pojistovna} (typ {pac.typ_pojisteni})"
f" Diagnózy: {', '.join(pac.diagnozy) or ''}")
print(f" Výsledků: {len(pac.vysledky)}")
print()
print(f" {'Položka':<14}{'Hodnota':>10} {'Jedn.':<9}"
f"{'Ref. mez':<18}{'Interpr.':<12}NČLP")
print(" " + "-" * 75)
for vys in pac.vysledky:
ref = vys.referencni_mez or ""
interp = (vys.interpret or "").strip()
print(f" {vys.nazev or '':<14}{vys.hodnota or '':>10} "
f"{vys.jednotka or '':<9}{ref:<18}{interp:<12}{vys.klic_nclp}")
if __name__ == "__main__":
if len(sys.argv) > 1:
cesta = sys.argv[1]
else:
cesta = r"u:\Dropbox\Ordinace\pomoc\DASTA\RLB05E6T.xml"
zprava = parse_dasta(cesta)
vypis_prehled(zprava)
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""
Roztřídí DASTA XML soubory do adresářové struktury podle DATA ODBĚRU.
Zdroj: u:\\Dropbox\\Ordinace\\pomoc\\DASTA\\*.xml
Cíl: U:\\DASTA_SUBGROUPS\\RRRR\\MM\\DD\\<soubor>.xml (kopie, originál zůstává)
Datum odběru = první element <dat_du> v souboru (první potomek prvního <vr>).
Hodnota má formát DTS (2016-06-20T08:00:00) nebo DT (2017-05-18T07:30)
v obou případech začíná YYYY-MM-DD, takže rok/měsíc/den čteme z prvních znaků.
Speciální případy:
_BEZ_DATUMU soubor neobsahuje žádný <dat_du>
_CHYBY soubor se nepodařilo naparsovat
Použití:
python roztrid_dle_odberu.py # zdroj = výchozí (Dropbox)
python roztrid_dle_odberu.py U:\\DASTA # jiný zdrojový adresář
python roztrid_dle_odberu.py U:\\DASTA --dry-run # jen vypíše, co by udělal
"""
from __future__ import annotations
import re
import shutil
import sys
from collections import Counter
from pathlib import Path
from xml.etree import ElementTree as ET
ZDROJ_VYCHOZI = Path(r"u:\dasta")
CIL = Path(r"U:\DASTA_SUBGROUPS")
# Záchytný regex pro případ, že ElementTree selže (poškozená hlavička apod.)
_RE_DAT_DU = re.compile(rb"<dat_du[^>]*>\s*(\d{4})-(\d{2})-(\d{2})")
def datum_odberu(cesta: Path) -> tuple[str, str, str] | None:
"""Vrátí (rok, měsíc, den) z prvního <dat_du>, nebo None když chybí."""
raw = cesta.read_bytes()
# Rychlá a robustní cesta: najdi první <dat_du> v bytech.
m = _RE_DAT_DU.search(raw)
if m:
return m.group(1).decode(), m.group(2).decode(), m.group(3).decode()
# Záloha přes ElementTree (kdyby byl dat_du formátovaný jinak)
try:
root = ET.fromstring(raw)
el = root.find(".//dat_du")
if el is not None and el.text:
d = el.text.strip()
return d[0:4], d[5:7], d[8:10]
except ET.ParseError:
raise
return None
def main() -> None:
dry = "--dry-run" in sys.argv
pozicni = [a for a in sys.argv[1:] if not a.startswith("--")]
zdroj = Path(pozicni[0]) if pozicni else ZDROJ_VYCHOZI
soubory = sorted(zdroj.glob("*.xml"))
print(f"Zdroj: {zdroj}")
print(f"Cíl: {CIL}")
print(f"Nalezeno souborů: {len(soubory)}"
f"{' [DRY-RUN]' if dry else ''}")
print("-" * 60)
if not dry:
CIL.mkdir(parents=True, exist_ok=True)
stat = Counter()
roky = Counter()
chyby: list[str] = []
for src in soubory:
try:
d = datum_odberu(src)
except ET.ParseError as e:
d = None
cilovy_dir = CIL / "_CHYBY"
chyby.append(f"{src.name}: parse error {e}")
stat["chyba"] += 1
else:
if d is None:
cilovy_dir = CIL / "_BEZ_DATUMU"
stat["bez_datumu"] += 1
else:
rok, mes, den = d
cilovy_dir = CIL / rok / mes / den
stat["ok"] += 1
roky[rok] += 1
dst = cilovy_dir / src.name
if dry:
continue
cilovy_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
print("Hotovo:")
print(f" zařazeno dle data odběru : {stat['ok']}")
print(f" bez data (_BEZ_DATUMU) : {stat['bez_datumu']}")
print(f" chyby parsování (_CHYBY) : {stat['chyba']}")
if roky:
print("\nRozložení podle roku:")
for rok in sorted(roky):
print(f" {rok}: {roky[rok]}")
if chyby:
print("\nDetail chyb:")
for c in chyby:
print(f" {c}")
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
# EmailAgent — agent na přijaté faktury
Hlídá schránku **ordinace@buzalkova.cz** a ukládá PDF přílohy přijatých faktur.
## Ochrana proti duplicitám (2 vrstvy)
1. **`state.json`** — `message_id` zpracovaných mailů. Stejný mail se podruhé
vůbec nestahuje (hlavní pojistka).
2. **sha256 obsahu** — při startu se načtou hashe všech PDF v cílové složce;
stažená příloha se porovná hned po stažení. Když je **stejný obsah** už ve
složce (i pod jiným názvem), soubor se přeskočí (`[DUPLIKÁT]`) a ušetří se
i AI volání za pojmenování. Řeší případ, kdy se `state.json` ztratí nebo
dodavatel pošle fakturu dvakrát. Porovnává se obsah, ne název — AI název se
mezi běhy může v části `[popis]` lehce lišit.
## Nasazení na unraid (Tower, 24/7)
Cílový adresář: `/mnt/user/Scripts/StahovaniFaktur/` (= `/scripts/StahovaniFaktur/`
v kontejneru `python-runner`). Běh: `docker exec python-runner python3
/scripts/StahovaniFaktur/faktury_agent.py`.
- `.env` na serveru má `STORAGE=dropbox` + `ANTHROPIC_API_KEY` + `DROPBOX_*`
(na serveru není `Medevio/.env` ani Dropbox mount).
- Závislosti v kontejneru: `dropbox msal pymupdf requests` (`_ensure_deps()` je
doinstaluje i samo).
- **Plánování** přes unraid **User Scripts plugin** (ne cron v dockeru):
- wrapper `/boot/config/plugins/user.scripts/scripts/StahovaniFaktur/script`
(`flock` + `docker exec`, loguje do `/mnt/user/Scripts/logs/stahovani_faktur.log`),
- rozvrh `0 6,18 * * *` v `schedule.json``customSchedule.cron``update_cron`
`/etc/cron.d/root`.
- Pozn.: `/boot` je FAT32, skript NELZE spustit přímým execem — plugin ho pouští
přes `startCustom.php` (`bash`), proto to funguje.
- **Pozor na dvojí běh:** spouštět jen ze serveru, ne zároveň lokálně (oddělené
`state.json`).
## Spuštění
```powershell
cd U:\OrdinaceProjekt\EmailAgent
python faktury_agent.py
```
Skript je **idempotentní** — opakované spuštění nestáhne nic dvakrát
(viz `state.json`). Lze přidat do Windows Task Scheduleru.
## Co dělá (tok)
1. **Graph API** (`graph_mail.py`) — načte nové maily s přílohou ze složek
`Inbox` + přímé podsložky (vynechává Junk/Deleted/Sent/Drafts),
od posledního běhu (`state.json``last_run`, s překryvem 1 den).
2. **Levný předfiltr (Python, zdarma)** — propustí jen maily, kde se slovo
`faktur*` vyskytuje v předmětu, těle, nebo v názvu přílohy.
*Maily, které neprojdou, se rovnou označí jako zpracované.*
3. **AI klasifikace (Claude, placené)** — jen na propuštěné maily. Model
`claude-haiku-4-5` rozhodne `je_faktura: true/false` a vybere správnou
**.pdf** přílohu (ne ISDOC/XML/obrázek/VOP/dodací list/objednávku).
4. **Stažení** vybraného PDF přes Graph.
5. **Ověření obsahu PDF (Python, zdarma)** — přečte text PDF (`fitz`/PyMuPDF)
a hledá slovo `faktur*`:
- `ano` → potvrzeno, uloží se.
- `ne` → text fakturu neobsahuje (AI nejspíš vybrala špatnou přílohu) →
**neukládá se**, jen zaloguje `[PDF NEPOTVRZENO]`.
- `bez_textu` → PDF nemá textovou vrstvu (skenovaná faktura) → uloží se,
ale zaloguje `[PDF BEZ TEXTU]` k ruční kontrole.
6. **Návrh názvu (Claude nad textem PDF)**`propose_filename()` z textu faktury
vytěží datum/typ/dodavatele/číslo/popis/částku a vrátí jednotný název
`YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf`. Pravidla převzata
z `Faktury/FakturyRenameOpenAI.py` (např. Distribuce CZ → Ptáček). Při chybě
nebo u skenu bez textu se použije původní název přílohy.
7. **Uložení** pod navrženým názvem. Konflikty řeší přípona ` (2)`, ` (3)`
8. **Kategorie + přesun (Graph, destruktivní)** — mail se označí kategorií
`ClaudeProcessed` (zelená) a přesune do `Inbox/ProcessedByAgent/Invoices`.
Děje se i u duplikátu (úklid Inboxu). Vyžaduje **Mail.ReadWrite**.
9. Zápis `state.json` + log `_log_faktury.txt`.
10. **Summary e-mail** — po každém běhu se z `SUMMARY_FROM` (reports@buzalka.cz)
pošle souhrn na `SUMMARY_TO` (vladimir.buzalka@buzalka.cz) přes Graph
(`graph_mail.send_mail`, vyžaduje **Mail.Send**). Tělo = log běhu.
Na konci běhu skript vypíše **cenu AI** za běh — počet volání, tokeny a částku
v USD i Kč (kurz `USD_TO_CZK`, ceník modelů v `PRICING`). Orientačně ~0,1 Kč
na fakturu (klasifikace + pojmenování).
## Konfigurace (`faktury_agent.py`, sekce NASTAVENÍ)
| Konstanta | Význam |
|-----------|--------|
| `MAILBOX` | sledovaná schránka |
| `TARGET_SUBPATH` | podsložka v Dropboxu — root přes `Knihovny/najdi_dropbox.py` |
| `FIRST_RUN_DAYS` | kolik dní dozadu při prvním běhu (prázdný state) |
| `ANTHROPIC_MODEL` | model pro klasifikaci faktura ano/ne |
| `ANTHROPIC_NAMING_MODEL` | model pro návrh názvu souboru |
| `FAKTUR_RE` | regex předfiltru (`faktur`) |
| `CATEGORY` / `CATEGORY_COLOR` | kategorie přidaná zpracovanému mailu + barva |
| `PROCESSED_FOLDER_PARTS` | cílová podsložka přesunu (pod Inbox) |
| `SKIP_FOLDERS` | složky vynechané při skenování |
## Úložiště — lokální vs. Dropbox API (`storage.py`)
Cíl je stejná složka, ale dvěma cestami podle prostředí (`STORAGE`):
| `STORAGE` | Backend | Kdy |
|-----------|---------|-----|
| `local` (default) | `LocalStorage` — filesystem přes `najdi_dropbox.get_dropbox_root()` + `TARGET_SUBPATH` | Windows (je tu Dropbox mount) |
| `dropbox` | `DropboxStorage` — Dropbox HTTP API, cesta `DROPBOX_TARGET_PATH` | unraid/server (bez Dropbox mountu) |
Společné rozhraní: `load_hashes()`, `hash_bytes(data)`, `save(name, data)`, `describe()`.
Dedup: lokálně **sha256** obsahu, přes API **Dropbox content_hash** (blokový sha256
z metadat — nestahuje soubory). Listing v Dropboxu **stránkuje**.
**Cílová složka:**
`<Dropbox>\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté\`
(Dropbox API: `/Ordinace/.../#040 Faktury přijaté`, Full Dropbox app).
**Po zpracování (destruktivní):** `CATEGORY` (= `ClaudeProcessed`, barva
`CATEGORY_COLOR`) + přesun do `Inbox/` + `PROCESSED_FOLDER_PARTS`
(= `ProcessedByAgent/Invoices`). Tato složka se při skenování přeskakuje
(`SKIP_FOLDERS`).
## Autentizace
- **Microsoft Graph** — app registrace (application permissions) sdílená s
`Knihovny/EmailMessagingGraph.py`. Vyžaduje granty **Mail.Read** (čtení/přílohy)
a **Mail.ReadWrite** (kategorie + přesun mailů) a **Mail.Send** (summary e-mail).
Credentials jsou natvrdo v `graph_mail.py` (tenant/client/secret).
- **Anthropic** — klíč `ANTHROPIC_API_KEY` z `Medevio/.env`.
- **Dropbox API** (jen `STORAGE=dropbox`) — `DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`
/ `DROPBOX_APP_REFRESH_TOKEN` z `EmailAgent/.env` (gitignored). Full Dropbox app,
účet vladimir.buzalka@buzalka.cz. Refresh token = trvalý, access token se obnovuje
sám.
## Závislosti
`msal`, `requests`, `fitz` (PyMuPDF), a pro `STORAGE=dropbox` navíc **`dropbox`**
(`pip install dropbox`). Na unraid/python-runner je nutné `dropbox` doinstalovat.
## Soubory
| Soubor | Popis |
|--------|-------|
| `faktury_agent.py` | hlavní skript |
| `graph_mail.py` | vrstva nad Graphem (čtení/zápis zpráv, stahování příloh) |
| `storage.py` | úložiště faktur — `LocalStorage` / `DropboxStorage` |
| `.env` | Dropbox credentials + volitelně `STORAGE` (gitignored) |
| `state.json` | ID zpracovaných mailů + `last_run` |
| `_log_faktury.txt` | log běhů |
## Poznámky / TODO
- **Destruktivní** — zpracovaný mail se kategorizuje a přesouvá z Inboxu.
Maily se nemažou. Přesun mění `message_id`; idempotenci hlídá `state.json`
(původní id) i to, že přesunuté maily jsou v nesnímané podsložce.
- Maily, které předfiltr **nepropustí**, se uloží do `processed_ids` jako
vyřízené — když se prompt/pravidla později změní, znovu se nepřehodnotí.
Pro re-test smaž příslušné ID ze `state.json`.
- Graph nezvládne `$orderby` spolu s filtrem `hasAttachments` (`InefficientFilter`)
— proto se na serveru neřadí.
+23
View File
@@ -0,0 +1,23 @@
# EmailAgent — TODO
## Plánované
- [ ] **OCR nad skeny** — faktury bez textové vrstvy (skenovaná PDF) dnes
`pdf_faktur_check()` vrací `bez_textu` a soubor se uloží jen s varováním
`[PDF BEZ TEXTU]`, bez ověření obsahu. Doplnit OCR (např. Tesseract /
`ocrmypdf`, nebo render stránky přes `fitz` → OCR), aby se i u skenů ověřilo
slovo `faktur*` a případně vytěžil text pro klasifikaci.
## Hotovo
- [x] Cílová složka přepnuta na ostrou `#040 Faktury přijaté`.
- [x] Po zpracování: kategorie `ClaudeProcessed` + přesun do
`Inbox/ProcessedByAgent/Invoices` (vyžaduje Mail.ReadWrite).
## Možná rozšíření (až se výsledky odladí)
- [ ] Plánované spouštění přes Windows Task Scheduler.
- [ ] Přísnější režim: ukládat jen když text PDF potvrdí `faktur` (tvrdá brána)
— možné až po doplnění OCR, jinak hrozí ztráta skenovaných faktur.
- [ ] Zvážit dedup i proti podsložce `NamedInvoicesbyOpenAI` v cílové složce
(dnes se hashuje jen top-level `*.pdf`).
+135
View File
@@ -0,0 +1,135 @@
======================================================================
START 2026-06-10 06:42:23 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:42:23Z
======================================================================
START 2026-06-10 06:42:57 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:42:57Z
======================================================================
START 2026-06-10 06:43:13 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:43:13Z
======================================================================
START 2026-06-10 06:43:51 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:43:51Z
[ULOŽENO] Faktura - Daňový doklad číslo_ 202604570 .pdf <- 'Faktura'
[ULOŽENO] Faktura_261103225.pdf <- 'Faktura 261103225'
[ULOŽENO] Faktura č.110606255.pdf <- 'Faktura č. :110606255'
[NE] 'Faktura vydaná č. 96260214' — Jediná dostupná PDF příloha je FV96260214-isdoc.pdf, což je formát *.isdoc (strukturovaný dokument), nikoliv čitelné PDF. Pravidla preferují lidsky čitelné PDF. Zbývající příloha image001.png je obrázek.
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 3, uloženo 3 souborů.
======================================================================
START 2026-06-10 06:44:54 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-06-09T04:44:28Z
HOTOVO: prošlo 0 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
======================================================================
START 2026-06-10 06:49:23 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:49:23Z
[ULOŽENO] Faktura - Daňový doklad číslo_ 202604570 .pdf <- 'Faktura'
[ULOŽENO] Faktura_261103225.pdf <- 'Faktura 261103225'
[ULOŽENO] Faktura č.110606255.pdf <- 'Faktura č. :110606255'
[ULOŽENO] FV96260214-isdoc.pdf <- 'Faktura vydaná č. 96260214'
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů.
======================================================================
START 2026-06-10 06:54:36 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:54:36Z
[ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura'
[ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225'
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
[ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214'
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů.
======================================================================
START 2026-06-10 06:57:14 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:57:14Z
[ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura'
[ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225'
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
[ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214'
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů.
CENA AI: 8 volání, tokeny input=10087 output=738, $0.0138 ≈ 0.34 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 06:59:54 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T04:59:54Z
[DUPLIKÁT] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf už existuje (shodný obsah), přeskakuji
[DUPLIKÁT] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf už existuje (shodný obsah), přeskakuji
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
[DUPLIKÁT] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf už existuje (shodný obsah), přeskakuji
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 1 souborů.
CENA AI: 8 volání, tokeny input=10087 output=766, $0.0139 ≈ 0.35 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 07:02:33 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T05:02:33Z
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf)
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf)
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf)
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf)
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 0 souborů.
CENA AI: 8 volání, tokeny input=10087 output=663, $0.0134 ≈ 0.34 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 07:04:11 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
Hledám maily od: 2026-05-27T05:04:11Z
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura - Daňový doklad číslo_ 202604570 .pdf')
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura_261103225.pdf')
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura č.110606255.pdf')
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('FV96260214-isdoc.pdf')
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 0 souborů.
CENA AI: 4 volání, tokeny input=2662 output=513, $0.0052 ≈ 0.13 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 07:18:23 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
Hledám maily od: 2026-05-27T05:18:20Z
[ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura'
[ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225'
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
[ULOŽENO] 2026-06-10 Faktura Ptáček 202604906 [vakcína ADACEL] [1214.08 CZK].pdf <- 'Faktura'
[ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214'
HOTOVO: prošlo 16 mailů, předfiltrem 5, faktur 5, uloženo 5 souborů.
CENA AI: 10 volání, tokeny input=12362 output=883, $0.0168 ≈ 0.42 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 08:14:11 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
Hledám maily od: 2026-05-27T06:14:08Z
HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 08:14:37 schránka=ordinace@buzalkova.cz
Cíl: Dropbox:/Ordinace/!!MUDr. Michaela Buzalková s.r.o/Prosek/#040 Faktury přijaté
Hledám maily od: 2026-05-27T06:14:35Z
HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 08:18:18 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
Hledám maily od: 2026-05-27T06:18:15Z
HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
======================================================================
START 2026-06-10 08:30:02 schránka=ordinace@buzalkova.cz
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
Hledám maily od: 2026-06-09T06:18:26Z
HOTOVO: prošlo 0 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
+527
View File
@@ -0,0 +1,527 @@
"""
faktury_agent.py
----------------
Agent, který ve schránce ordinace@buzalkova.cz hledá PŘIJATÉ FAKTURY
a ukládá jejich PDF přílohy do Dropbox složky ke kontrole.
Tok:
1. Microsoft Graph: načti nové maily s přílohou (od posledního běhu).
2. LEVNÝ PŘEDFILTR (Python, zdarma): nech jen maily, kde se slovo "faktur*"
vyskytuje kdekoliv v textu (předmět/tělo) NEBO v názvu přílohy.
3. AI KLASIFIKACE (Claude, placené): jen na propuštěné maily — model rozhodne,
zda jde o přijatou fakturu, a vybere správnou PDF přílohu (ne ISDOC,
ne dodací list, ne VOP, ne objednávku).
4. Stáhni vybranou přílohu přes Graph a ulož do cílové složky.
5. Zapiš stav (idempotence) a log.
Spouštěj opakovaně — už zpracované maily se přeskakují (state.json) a existující
soubory se nepřepisují.
"""
import html
import importlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
def _ensure_deps():
"""Doinstaluje chybějící balíčky třetích stran (běh na čistém serveru)."""
needed = {"requests": "requests", "msal": "msal",
"fitz": "PyMuPDF", "dropbox": "dropbox"}
missing = []
for mod, pkg in needed.items():
try:
importlib.import_module(mod)
except ImportError:
missing.append(pkg)
if missing:
print(f"Instaluji chybějící balíčky: {', '.join(missing)}")
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet", *missing]
)
_ensure_deps()
import fitz # PyMuPDF # noqa: E402
import requests # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import graph_mail # noqa: E402
import storage as storage_mod # noqa: E402
# =========================
# NASTAVENÍ
# =========================
MAILBOX = "ordinace@buzalkova.cz"
# Cílová složka pro PDF faktur.
# - LOCAL backend: podsložka pod Dropbox rootem (najdi_dropbox).
# - DROPBOX backend: cesta od kořene Dropboxu (Full Dropbox app).
TARGET_SUBPATH = [
"Ordinace", "!!MUDr. Michaela Buzalková s.r.o", "Prosek", "#040 Faktury přijaté"
]
DROPBOX_TARGET_PATH = "/" + "/".join(TARGET_SUBPATH)
# Po zpracování: označit mail kategorií a přesunout do podsložky Inboxu.
CATEGORY = "ClaudeProcessed"
CATEGORY_COLOR = "preset4" # zelená (Outlook preset paleta)
PROCESSED_FOLDER_PARTS = ["ProcessedByAgent", "Invoices"] # pod Inbox
# Tuto složku při skenování přeskoč (jsou v ní už zpracované maily).
SKIP_FOLDERS = {"ProcessedByAgent"}
# Summary e-mail po každém běhu (přes Graph, app má Mail.Send).
SUMMARY_FROM = "reports@buzalka.cz"
SUMMARY_TO = "vladimir.buzalka@buzalka.cz"
# Při prvním běhu (prázdný state) se prohledá posledních N dní.
FIRST_RUN_DAYS = 14
# Claude model pro klasifikaci (levný, na text stačí).
ANTHROPIC_MODEL = "claude-haiku-4-5"
# Claude model pro návrh názvu souboru (vytěžení datumu/dodavatele/částky
# z textu faktury). Lze zvednout na silnější model, pokud názvy nesedí.
ANTHROPIC_NAMING_MODEL = "claude-haiku-4-5"
# Předfiltr: slovo "faktur" kdekoliv (faktura, faktury, fakturace, faktuře...).
FAKTUR_RE = re.compile(r"faktur", re.IGNORECASE)
# Cena Claude API — USD za 1M tokenů (input, output). Kurz pro přepočet.
USD_TO_CZK = 25.0
PRICING = {
"claude-haiku-4-5": (1.00, 5.00),
"claude-sonnet-4-6": (3.00, 15.00),
"claude-opus-4-8": (5.00, 25.00),
}
# Akumulátor nákladů aktuálního běhu (plní _claude_json).
_cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0}
HERE = Path(__file__).resolve().parent
STATE_FILE = HERE / "state.json"
LOG_FILE = HERE / "_log_faktury.txt"
# =========================
# ENV (Anthropic klíč)
# =========================
def _load_env_file(env_path: Path):
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip().strip('"').strip("'")
def _load_env():
# Medevio/.env (ANTHROPIC_API_KEY) + EmailAgent/.env (DROPBOX_*, STORAGE).
_load_env_file(Path(__file__).resolve().parent.parent / "Medevio" / ".env")
_load_env_file(Path(__file__).resolve().parent / ".env")
_load_env()
# =========================
# POMOCNÉ
# =========================
_email_lines = [] # řádky aktuálního běhu pro summary e-mail
def _now_str(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Aktuální čas v pražském pásmu (i když server běží v UTC)."""
try:
from zoneinfo import ZoneInfo
dt = datetime.now(ZoneInfo("Europe/Prague"))
except Exception:
dt = datetime.now() # fallback: lokální čas stroje
return dt.strftime(fmt)
def log(msg: str) -> None:
print(msg)
_email_lines.append(msg)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
return {"processed_ids": [], "last_run": None}
def save_state(state: dict) -> None:
STATE_FILE.write_text(
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
)
def since_iso(state: dict) -> str:
if state.get("last_run"):
# malý překryv -1 den pro jistotu (idempotence to pohlídá)
dt = datetime.fromisoformat(state["last_run"]) - timedelta(days=1)
else:
dt = datetime.now(timezone.utc) - timedelta(days=FIRST_RUN_DAYS)
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sanitize(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', " ", name)
name = re.sub(r"\s+", " ", name).strip().rstrip(" .")
return name
def sanitize_pdf_name(name: str) -> str:
"""Očistí navržený název pro Windows a zajistí příponu .pdf."""
name = sanitize(name)
if not name.lower().endswith(".pdf"):
name += ".pdf"
return name
# =========================
# PŘEDFILTR (zdarma)
# =========================
def passes_prefilter(msg: dict, attachments: list[dict]) -> bool:
subject = msg.get("subject") or ""
body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or ""
if FAKTUR_RE.search(subject) or FAKTUR_RE.search(body):
return True
for a in attachments:
if FAKTUR_RE.search(a.get("name") or ""):
return True
return False
# =========================
# OVĚŘENÍ OBSAHU PDF (Python, zdarma)
# =========================
def extract_pdf_text(data: bytes) -> str:
"""Vrátí text z PDF (prázdný řetězec, pokud nelze — sken/chyba)."""
try:
doc = fitz.open(stream=data, filetype="pdf")
text = "".join(page.get_text() for page in doc)
doc.close()
return text
except Exception:
return ""
def faktur_status(pdf_text: str) -> str:
"""
"ano" (text obsahuje faktur), "ne" (text bez faktur),
"bez_textu" (PDF nemá extrahovatelný text — nejspíš sken).
"""
if not pdf_text.strip():
return "bez_textu"
return "ano" if FAKTUR_RE.search(pdf_text) else "ne"
# =========================
# AI KLASIFIKACE (Claude)
# =========================
PROMPT = """Jsi asistent ordinace praktického lékaře. Rozhoduješ, zda e-mail obsahuje \
PŘIJATOU FAKTURU (daňový doklad k zaplacení), a pokud ano, vybíráš PDF přílohu, \
která tu fakturu obsahuje.
Pravidla:
- "je_faktura": true POUZE pokud jde o skutečnou přijatou fakturu / daňový doklad.
- NENÍ faktura: objednávka, dodací list, předávací protokol, zálohová faktura bez \
plnění, obchodní podmínky (VOP), upomínka, newsletter, zdravotní zpráva, žádanka.
- "soubor_faktury": přesný název přílohy s fakturou, která má příponu .pdf. \
Rozhoduje POUZE skutečná přípona souboru: soubor končící na ".pdf" je platný, i když \
má v názvu slovo "isdoc" (např. "FV123-isdoc.pdf" je běžné PDF faktury — vyber ho). \
NEVybírej soubory s příponou .isdoc / .xml / .png / .jpg / .zip ani VOP/obchodní podmínky.
- Pokud faktura není, vrať "je_faktura": false a "soubor_faktury": null.
Vrať POUZE JSON:
{"je_faktura": true/false, "soubor_faktury": "nazev.pdf"|null, "duvod": "krátké zdůvodnění"}
E-MAIL:
Odesílatel: %(sender)s
Předmět: %(subject)s
Přílohy: %(attachments)s
Tělo (zkráceno):
%(body)s
"""
def _claude_json(prompt: str, model: str, max_tokens: int) -> dict:
"""Zavolá Claude a vrátí JSON objekt z odpovědi."""
r = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
timeout=60,
)
r.raise_for_status()
data = r.json()
# Náklady: posbírej tokeny a přičti cenu podle modelu.
usage = data.get("usage", {})
in_tok = usage.get("input_tokens", 0)
out_tok = usage.get("output_tokens", 0)
price_in, price_out = PRICING.get(model, PRICING["claude-haiku-4-5"])
_cost["input_tokens"] += in_tok
_cost["output_tokens"] += out_tok
_cost["usd"] += in_tok / 1_000_000 * price_in + out_tok / 1_000_000 * price_out
_cost["calls"] += 1
text = data["content"][0]["text"].strip()
m = re.search(r"\{.*\}", text, re.DOTALL)
if not m:
raise ValueError(f"Claude nevrátil JSON: {text}")
return json.loads(m.group(0))
def classify(msg: dict, attachments: list[dict]) -> dict:
sender = (msg.get("from") or {}).get("emailAddress", {})
body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or ""
prompt = PROMPT % {
"sender": f"{sender.get('name','')} <{sender.get('address','')}>",
"subject": msg.get("subject") or "",
"attachments": ", ".join(a.get("name", "") for a in attachments) or "(žádné)",
"body": body[:4000],
}
return _claude_json(prompt, ANTHROPIC_MODEL, 300)
# =========================
# NÁVRH NÁZVU SOUBORU (Claude nad textem faktury)
# =========================
# Pravidla převzatá z Faktury/FakturyRenameOpenAI.py, upravená na vstup = text.
NAMING_RULES = """Jsi pomocník pro pojmenování PDF faktur a dokladů MUDr. Michaely Buzalkové.
ÚKOL:
Z TEXTU faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
Vrať POUZE JSON s polem "filename".
CÍLOVÝ FORMÁT:
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
PŘÍKLADY:
2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf
2026-06-01 Faktura MEDIPOS 10195703 [CRP, kapiláry, písty, rukavice, nádoba] [5578.97 CZK].pdf
2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf
2026-05-29 Faktura Poliklinika Prosek 91260763 [lékárna] [16165.40 CZK].pdf
2026-06-01 Dodací list QuickSeal 200609058 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf
DŮLEŽITÁ PRAVIDLA:
1. Prefix [POHODA] nikdy nepřidávej.
2. Používej datum vystavení dokladu, ne datum splatnosti.
3. Typ dokladu vyber podle dokumentu: Faktura, Dobropis, Paragon, Dodací list, Zálohová faktura, Smlouva, Platba, Poplatek, Výdajový pokladní doklad.
4. Pokud je v dokumentu "Dodací list není daňový doklad - nehraďte", typ je "Dodací list", ne "Faktura".
5. Dodavatel zapisuj krátce a konzistentně: MEDIPOS, MEDEVIO, MEDATRON, ASKER, QuickSeal, Poliklinika Prosek, Alza, Microsoft, OpenAI, Ptáček.
6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel "Distribuce CZ", použij dodavatele "Ptáček".
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo variabilní symbol nebo hlavní číslo faktury bez mezer (např. 10195703), ne interní evidenční číslo typu FV-5703/2026.
8. Částku piš vždy s desetinnou tečkou a měnou (např. [5578.97 CZK]).
9. Když je částka v Kč, měna je CZK.
10. Popis drž krátký, praktický a česky, v hranatých závorkách.
11. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy.
12. Pokud si nejsi jistý popisem, použij obecný popis typu [materiál do ordinace], [lékárna], [vakcíny], [testy].
13. Výstup musí být POUZE validní JSON, nic jiného.
JSON FORMÁT:
{"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"}
TEXT FAKTURY:
%(text)s
"""
def propose_filename(pdf_text: str) -> str | None:
"""Navrhne název souboru podle textu faktury. None při selhání/prázdném."""
prompt = NAMING_RULES % {"text": pdf_text[:15000]}
obj = _claude_json(prompt, ANTHROPIC_NAMING_MODEL, 300)
filename = (obj.get("filename") or "").strip()
return sanitize_pdf_name(filename) if filename else None
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
# Úložiště: STORAGE=dropbox -> Dropbox API, jinak lokální Dropbox mount.
use_dropbox = os.getenv("STORAGE", "local").lower() == "dropbox"
if use_dropbox:
local_dir = None
else:
# najdi_dropbox potřebujeme jen lokálně (na serveru Knihovny nejsou).
from Knihovny.najdi_dropbox import get_dropbox_root
local_dir = Path(get_dropbox_root(), *TARGET_SUBPATH)
storage = storage_mod.get_storage(local_dir, DROPBOX_TARGET_PATH)
# Otisky souborů už v cíli -> dedup podle obsahu, ne názvu. Faktur je málo.
existing_hashes = storage.load_hashes()
state = load_state()
processed = set(state.get("processed_ids", []))
since = since_iso(state)
# Příprava cílové kategorie a složky pro přesun (idempotentní).
graph_mail.ensure_category(MAILBOX, CATEGORY, CATEGORY_COLOR)
processed_folder_id = graph_mail.ensure_folder_path(MAILBOX, PROCESSED_FOLDER_PARTS)
def finalize(message_id: str, subject: str) -> None:
"""Označ mail kategorií a přesuň do složky zpracovaných."""
try:
graph_mail.add_category(MAILBOX, message_id, CATEGORY)
graph_mail.move_message(MAILBOX, message_id, processed_folder_id)
except Exception as e:
log(f" [KATEGORIE/PŘESUN CHYBA] {subject!r}: {e}")
log("\n" + "=" * 70)
log(f"START {_now_str()} schránka={MAILBOX}")
log(f"Cíl: {storage.describe()}")
log(f"Hledám maily od: {since}")
saved = scanned = prefiltered = invoices = 0
for folder_id, folder_name in graph_mail.inbox_folder_ids(MAILBOX):
if folder_name in SKIP_FOLDERS:
continue
for msg in graph_mail.list_messages(MAILBOX, folder_id, since):
mid = msg["id"]
if mid in processed:
continue
scanned += 1
atts = graph_mail.list_attachments(MAILBOX, mid)
if not passes_prefilter(msg, atts):
processed.add(mid) # nezajímavé, už neřeš
continue
prefiltered += 1
subj = (msg.get("subject") or "")[:60]
try:
verdict = classify(msg, atts)
except Exception as e:
log(f" [AI CHYBA] {subj!r}: {e}")
continue # zkusíme příště
if not verdict.get("je_faktura"):
log(f" [NE] {subj!r}{verdict.get('duvod','')}")
processed.add(mid)
continue
invoices += 1
want = verdict.get("soubor_faktury")
chosen = next((a for a in atts if a.get("name") == want), None)
if chosen is None:
# fallback: první PDF příloha
chosen = next(
(a for a in atts if (a.get("name") or "").lower().endswith(".pdf")),
None,
)
if chosen is None:
log(f" [FAKTURA bez PDF] {subj!r} — přílohy: {[a.get('name') for a in atts]}")
continue
try:
data = graph_mail.download_attachment(MAILBOX, mid, chosen["id"])
except Exception as e:
log(f" [DOWNLOAD CHYBA] {subj!r}: {e}")
continue
# Dedup podle OBSAHU napříč složkou (ne podle názvu — AI název se
# může lehce lišit). Kontrola hned po stažení -> u duplikátu ušetří
# AI volání za pojmenování i extrakci textu.
digest = storage.hash_bytes(data)
if digest in existing_hashes:
log(f" [DUPLIKÁT] obsah už ve složce je, přeskakuji ('{chosen['name']}')")
processed.add(mid)
finalize(mid, subj) # i duplikát je zpracovaná faktura -> ukliď z Inboxu
continue
# Ověření obsahu PDF: musí v textu obsahovat slovo "faktur".
pdf_text = extract_pdf_text(data)
check = faktur_status(pdf_text)
if check == "ne":
# AI mail označila za fakturu, ale text PDF slovo "faktur"
# neobsahuje -> nejspíš špatně vybraná příloha. Neukládám.
log(f" [PDF NEPOTVRZENO] {subj!r}'{chosen['name']}' "
f"text neobsahuje 'faktur', přeskakuji")
continue
if check == "bez_textu":
log(f" [PDF BEZ TEXTU] {subj!r}'{chosen['name']}' "
f"(sken?) ukládám i tak, ověř ručně")
# Návrh názvu podle obsahu faktury (jen pokud máme text).
out_name = sanitize(chosen["name"])
if pdf_text.strip():
try:
proposed = propose_filename(pdf_text)
if proposed:
out_name = proposed
except Exception as e:
log(f" [POJMENOVÁNÍ CHYBA] {subj!r}: {e} — původní název")
# Nový obsah. Backend vyřeší kolizi názvu (lokálně "(2)", Dropbox autorename).
saved_name = storage.save(out_name, data)
existing_hashes.add(digest)
saved += 1
processed.add(mid)
log(f" [ULOŽENO] {saved_name} <- {subj!r}")
finalize(mid, subj) # označ kategorií + přesuň z Inboxu
state["processed_ids"] = sorted(processed)
state["last_run"] = datetime.now(timezone.utc).isoformat()
save_state(state)
log(
f"HOTOVO: prošlo {scanned} mailů, předfiltrem {prefiltered}, "
f"faktur {invoices}, uloženo {saved} souborů."
)
log(
f"CENA AI: {_cost['calls']} volání, "
f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, "
f"${_cost['usd']:.4f}{_cost['usd'] * USD_TO_CZK:.2f}"
f"(kurz 1 USD = {USD_TO_CZK:.0f} Kč)"
)
send_summary(saved, invoices)
def send_summary(saved: int, invoices: int) -> None:
"""Po každém běhu pošle summary e-mail z reports@buzalka.cz."""
subject = (
f"Faktury agent — uloženo {saved}, faktur {invoices} "
f"({_now_str('%Y-%m-%d %H:%M')})"
)
body = (
"<pre style=\"font-family:Consolas,monospace;font-size:13px;"
"white-space:pre-wrap\">"
+ html.escape("\n".join(_email_lines))
+ "</pre>"
)
try:
graph_mail.send_mail(SUMMARY_FROM, SUMMARY_TO, subject, body)
print(f"Summary odeslán na {SUMMARY_TO}")
except Exception as e:
print(f"[SUMMARY EMAIL CHYBA] {type(e).__name__}: {e}")
if __name__ == "__main__":
main()
+198
View File
@@ -0,0 +1,198 @@
"""
graph_mail.py
-------------
Tenká vrstva nad Microsoft Graph API pro ČTENÍ schránky a STAHOVÁNÍ příloh.
Používá stejnou app registraci (application permissions) jako
Knihovny/EmailMessagingGraph.py. Pro čtení cizí schránky a příloh musí mít
ta app registrace grant **Mail.Read** (Application). Pokud chybí, Graph vrátí
403 a je potřeba oprávnění doplnit v Azure portálu.
"""
import base64
import msal
import requests
from functools import lru_cache
from typing import Iterator
# =========================
# CONFIG (sdíleno s EmailMessagingGraph.py)
# =========================
TENANT_ID = "7d269944-37a4-43a1-8140-c7517dc426e9"
CLIENT_ID = "4b222bfd-78c9-4239-a53f-43006b3ed07f"
CLIENT_SECRET = "Txg8Q~MjhocuopxsJyJBhPmDfMxZ2r5WpTFj1dfk"
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPE = ["https://graph.microsoft.com/.default"]
GRAPH = "https://graph.microsoft.com/v1.0"
@lru_cache(maxsize=1)
def _token() -> str:
app = msal.ConfidentialClientApplication(
CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET
)
tok = app.acquire_token_for_client(scopes=SCOPE)
if "access_token" not in tok:
raise RuntimeError(f"Graph auth failed: {tok}")
return tok["access_token"]
def _headers() -> dict:
return {"Authorization": f"Bearer {_token()}"}
def _get(url: str, params: dict | None = None) -> dict:
r = requests.get(url, headers=_headers(), params=params, timeout=60)
r.raise_for_status()
return r.json()
def _post(url: str, body: dict) -> dict:
r = requests.post(
url, headers={**_headers(), "Content-Type": "application/json"},
json=body, timeout=60,
)
r.raise_for_status()
return r.json() if r.content else {}
def _patch(url: str, body: dict) -> dict:
r = requests.patch(
url, headers={**_headers(), "Content-Type": "application/json"},
json=body, timeout=60,
)
r.raise_for_status()
return r.json() if r.content else {}
def inbox_folder_ids(mailbox: str) -> list[tuple[str, str]]:
"""
Vrátí [(folder_id, display_name), ...] pro Inbox a jeho přímé podsložky.
Záměrně vynechává Junk, Deleted, Sent, Drafts.
"""
inbox = _get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox")
folders = [(inbox["id"], inbox.get("displayName", "Inbox"))]
data = _get(
f"{GRAPH}/users/{mailbox}/mailFolders/{inbox['id']}/childFolders",
{"$top": 100, "$select": "id,displayName"},
)
for f in data.get("value", []):
folders.append((f["id"], f.get("displayName", "")))
return folders
def list_messages(mailbox: str, folder_id: str, since_iso: str) -> Iterator[dict]:
"""
Vrací zprávy s přílohou ve složce přijaté od `since_iso` (ISO 8601 UTC, Z).
Stránkuje přes @odata.nextLink. Tělo se vrací jako text (Prefer header).
"""
url = f"{GRAPH}/users/{mailbox}/mailFolders/{folder_id}/messages"
params = {
"$filter": f"hasAttachments eq true and receivedDateTime ge {since_iso}",
"$select": "id,subject,from,receivedDateTime,bodyPreview,body,hasAttachments",
"$top": 50,
}
headers = {**_headers(), "Prefer": 'outlook.body-content-type="text"'}
while url:
r = requests.get(url, headers=headers, params=params, timeout=60)
r.raise_for_status()
data = r.json()
yield from data.get("value", [])
url = data.get("@odata.nextLink")
params = None # nextLink už obsahuje všechny parametry
def list_attachments(mailbox: str, message_id: str) -> list[dict]:
"""Metadata příloh (id, name, contentType, size, @odata.type)."""
data = _get(
f"{GRAPH}/users/{mailbox}/messages/{message_id}/attachments",
{"$top": 50},
)
return data.get("value", [])
def download_attachment(mailbox: str, message_id: str, attachment_id: str) -> bytes:
"""Stáhne bajty jedné fileAttachment."""
data = _get(
f"{GRAPH}/users/{mailbox}/messages/{message_id}/attachments/{attachment_id}"
)
content = data.get("contentBytes")
if not content:
raise RuntimeError(f"Příloha nemá contentBytes (typ {data.get('@odata.type')})")
return base64.b64decode(content)
# ---------------------------------------------------------------------------
# Zápisové operace (vyžadují Mail.ReadWrite Application)
# ---------------------------------------------------------------------------
def ensure_category(mailbox: str, name: str, color: str = "preset4") -> None:
"""Zajistí kategorii v master-listu schránky (s barvou). Idempotentní."""
data = _get(f"{GRAPH}/users/{mailbox}/outlook/masterCategories", {"$top": 200})
if any(c.get("displayName") == name for c in data.get("value", [])):
return
_post(
f"{GRAPH}/users/{mailbox}/outlook/masterCategories",
{"displayName": name, "color": color},
)
def add_category(mailbox: str, message_id: str, name: str) -> None:
"""Přidá kategorii ke zprávě (zachová stávající)."""
msg = _get(
f"{GRAPH}/users/{mailbox}/messages/{message_id}", {"$select": "categories"}
)
cats = msg.get("categories") or []
if name not in cats:
_patch(
f"{GRAPH}/users/{mailbox}/messages/{message_id}",
{"categories": cats + [name]},
)
def ensure_folder_path(mailbox: str, parts: list[str]) -> str:
"""
Zajistí cestu složek pod Inboxem (vytvoří chybějící). `parts` jsou názvy
podsložek, např. ["ProcessedByAgent", "Invoices"]. Vrátí id poslední složky.
"""
parent_id = _get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox")["id"]
for name in parts:
children = _get(
f"{GRAPH}/users/{mailbox}/mailFolders/{parent_id}/childFolders",
{"$top": 200, "$select": "id,displayName"},
)
match = next(
(f for f in children.get("value", []) if f.get("displayName") == name), None
)
if match is None:
match = _post(
f"{GRAPH}/users/{mailbox}/mailFolders/{parent_id}/childFolders",
{"displayName": name},
)
parent_id = match["id"]
return parent_id
def move_message(mailbox: str, message_id: str, dest_folder_id: str) -> str:
"""Přesune zprávu do složky. Vrací NOVÉ id (move id mění)."""
res = _post(
f"{GRAPH}/users/{mailbox}/messages/{message_id}/move",
{"destinationId": dest_folder_id},
)
return res.get("id", message_id)
def send_mail(sender: str, to, subject: str, html_body: str) -> None:
"""Odešle HTML e-mail přes Graph (vyžaduje Mail.Send Application)."""
to_list = [to] if isinstance(to, str) else list(to)
_post(
f"{GRAPH}/users/{sender}/sendMail",
{
"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": html_body},
"toRecipients": [{"emailAddress": {"address": a}} for a in to_list],
},
"saveToSentItems": True,
},
)
+16
View File
@@ -0,0 +1,16 @@
{
"processed_ids": [
"AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAAACZtAAA=",
"AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAAACZvAAA=",
"AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAReEmkAAA=",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4tgAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4uwAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4vwAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4wAAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINL5QAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINL5gAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINMDQAAAA==",
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXxY_4AAAAA=="
],
"last_run": "2026-06-10T06:30:04.195527+00:00"
}
+128
View File
@@ -0,0 +1,128 @@
"""
storage.py
----------
Abstrakce úložiště pro faktury. Dva backendy se stejným rozhraním:
- LocalStorage — lokální filesystem (Windows, Dropbox mount přes najdi_dropbox).
- DropboxStorage — Dropbox HTTP API (unraid/server, kde není Dropbox mount).
Výběr přes proměnnou STORAGE: "local" (default) | "dropbox".
Rozhraní (oba backendy):
load_hashes() -> set[str] # otisky souborů už v cíli (dedup baseline)
hash_bytes(data) -> str # otisk vstupních bajtů (stejný algoritmus)
save(name, data) -> str # ulož, vrať finální název (řeší kolizi názvu)
describe() -> str # popis cíle do logu
Pozn.: každý backend je vnitřně konzistentní (load_hashes i hash_bytes používají
týž algoritmus). Local = sha256 obsahu. Dropbox = Dropbox "content_hash"
(blokový sha256, viz dokumentace Dropboxu) — bere se přímo z metadat souboru,
takže není potřeba nic stahovat.
"""
import hashlib
import os
from pathlib import Path
_DROPBOX_BLOCK = 4 * 1024 * 1024 # 4 MiB
def _dropbox_content_hash(data: bytes) -> str:
"""Dropbox content_hash: sha256 z konkatenace sha256 jednotlivých 4MiB bloků."""
h = hashlib.sha256()
for i in range(0, len(data), _DROPBOX_BLOCK):
h.update(hashlib.sha256(data[i:i + _DROPBOX_BLOCK]).digest())
return h.hexdigest()
# =========================
# LOKÁLNÍ FILESYSTEM
# =========================
class LocalStorage:
def __init__(self, target_dir: Path):
self.dir = Path(target_dir)
self.dir.mkdir(parents=True, exist_ok=True)
def hash_bytes(self, data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_hashes(self) -> set:
return {self.hash_bytes(p.read_bytes()) for p in self.dir.glob("*.pdf")}
def save(self, name: str, data: bytes) -> str:
out = self.dir / name
stem, suffix = Path(name).stem, Path(name).suffix
i = 2
while out.exists():
out = self.dir / f"{stem} ({i}){suffix}"
i += 1
out.write_bytes(data)
return out.name
def describe(self) -> str:
return str(self.dir)
# =========================
# DROPBOX HTTP API
# =========================
class DropboxStorage:
def __init__(self, folder: str, app_key: str, app_secret: str, refresh_token: str):
import dropbox # lazy import — potřeba jen pro tento backend
self._dbx_mod = dropbox
self.dbx = dropbox.Dropbox(
app_key=app_key,
app_secret=app_secret,
oauth2_refresh_token=refresh_token,
)
self.folder = "/" + folder.strip("/")
def hash_bytes(self, data: bytes) -> str:
return _dropbox_content_hash(data)
def load_hashes(self) -> set:
hashes = set()
try:
res = self.dbx.files_list_folder(self.folder)
except Exception:
return hashes # složka ještě nemusí existovat
while True:
for e in res.entries:
ch = getattr(e, "content_hash", None)
if ch:
hashes.add(ch)
if not res.has_more:
break
res = self.dbx.files_list_folder_continue(res.cursor)
return hashes
def save(self, name: str, data: bytes) -> str:
# autorename=True -> při kolizi názvu (jiný obsah) Dropbox přidá " (1)".
md = self.dbx.files_upload(
data,
f"{self.folder}/{name}",
mode=self._dbx_mod.files.WriteMode.add,
autorename=True,
)
return md.name
def describe(self) -> str:
return f"Dropbox:{self.folder}"
# =========================
# VÝBĚR BACKENDU
# =========================
def get_storage(local_dir: Path, dropbox_path: str):
"""
STORAGE=dropbox -> DropboxStorage (klíče DROPBOX_APP_KEY/SECRET/REFRESH_TOKEN
z prostředí), jinak LocalStorage(local_dir).
"""
if os.getenv("STORAGE", "local").lower() == "dropbox":
return DropboxStorage(
dropbox_path,
os.environ["DROPBOX_APP_KEY"],
os.environ["DROPBOX_APP_SECRET"],
os.environ["DROPBOX_APP_REFRESH_TOKEN"],
)
return LocalStorage(local_dir)
+4
View File
@@ -0,0 +1,4 @@
# Přihlašovací údaje k euni.cz — zkopíruj do souboru .env a vyplň.
# (.env je v .gitignore, do gitu se nedostane.)
EUNI_USERNAME=tvoje_prihlasovaci_jmeno
EUNI_PASSWORD=tvoje_heslo
+3
View File
@@ -0,0 +1,3 @@
# stažený obsah a inventura — do gitu nepatří
stazeno/
euni_kurzy.json
+120
View File
@@ -0,0 +1,120 @@
# Euni — stahování a tracking kurzů z euni.cz
Přihlásí se na euni.cz, projde kurzy, vytěží odkazy + metadata a stahuje obsah
(PDF/prezentace a videa Vimeo/YouTube). Vše se trackuje v **MongoDB EUNI**, takže
stahování je idempotentní — skript ví, co už má, a netahá dvakrát.
## Soubory
| Soubor | Popis |
|--------|-------|
| `euni_stahni.py` | hlavní pipeline: login → scrape → ingest do Mongo → stahování → záloha do SeaweedFS |
| `euni_db.py` | připojení a operace nad MongoDB EUNI (kolekce, indexy, upserty) |
| `euni_seaweed.py` | nahrávání/stahování souborů do SeaweedFS (filer HTTP API) |
| `euni_restore.py` | obnova všech souborů ze SeaweedFS na disk (na jakémkoli PC) |
| `euni_report.py` | dashboard: přehled stavu (kolik staženo/čeká/přeskočeno) |
| `.env` | `EUNI_USERNAME`, `EUNI_PASSWORD` (v .gitignore) |
| `euni_kurzy.json` | poslední inventura (záloha; primární zdroj je Mongo) |
| `stazeno/` | stažený obsah, `stazeno/<id>-<slug>/{dokumenty,videa}/` |
## Závislosti
```bat
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg pymongo
```
Video stahuje sdílený modul `../Video/stahni_video.py` (yt-dlp + static-ffmpeg,
soukromá videa sám přeskočí).
## MongoDB EUNI
Server `mongodb://192.168.1.76:27017` (bez hesla), DB `EUNI`. Lze přepsat env
proměnnou `EUNI_MONGO_URI`.
### Kolekce `kurzy` (1 dokument na kurz)
`_id` = euni ID kurzu. Pole: `slug, nazev, url, profese[], autor,
autor_medailonek_url, datum_publikace, revidovano, akreditace, kredity,
pocet_videi, pocet_dokumentu, first_seen, updated_at`.
### Kolekce `materialy` (1 dokument na soubor)
Unikátní index `{kurz_id, klic}`. Pole: `kurz_id, kurz_nazev, druh
(video|dokument), platforma (vimeo|youtube), klic (vimeo:ID / youtube:ID /
doc:hash), zdroj_url, watch_url, popis, pripona, stav, duvod, soubor,
velikost_b, pokusy, posledni_chyba, first_seen, updated_at, stazeno_at`.
**Stavy:** `ceka``stazeno` / `preskoceno` (soukromé video) / `chyba`.
**SeaweedFS reference** (po nahrání kopie): `seaweed_path` (cesta ve filer =
identifikátor pro vyžádání, např. `euni/5618-.../dokumenty/x.pdf`),
`seaweed_fids` (fid chunků = čísla souborů v SeaweedFS), `seaweed_md5`,
`seaweed_size`, `seaweed_at`.
## SeaweedFS záloha + obnova
Každý stažený soubor se nahraje do **SeaweedFS** (filer na Unraidu,
default `http://192.168.1.50:8888`, přepíše env `EUNI_FILER`). Do Mongo se k
materiálu uloží `seaweed_path` + `seaweed_fids`, takže soubor lze kdykoli vyžádat.
- Strukturu na disku zrcadlí cesta: `euni/<id>-<slug>/<typ>/<soubor>`.
- Filer metadata (mapa cesta→chunky) jsou v Mongo DB `seaweedfs` na 192.168.1.76;
bloby na poli Unraidu. (Setup: `U:\\PythonProject\\Janssen\\SeaweedFS\\`.)
- Pozn.: přímý přístup přes raw fid/volume zvenčí nefunguje (volume se uvnitř
Dockeru jmenuje `seaweed-volume`); proto se čte/zapisuje přes filer.
**Obnova kdekoliv** (stačí síť na Mongo + filer):
```bat
python euni_restore.py # vše → ./obnoveno
python euni_restore.py --out D:\Euni # jiný cíl
python euni_restore.py --kurz 5618 # jen jeden kurz
python euni_restore.py --dry-run # jen výpis
```
**Backfill** (dohrát do SeaweedFS soubory stažené dřív):
```bat
python euni_stahni.py --seaweed-backfill --from-json
```
### Idempotence
- Scrape dělá *upsert*: nový materiál → `ceka`; existující si **drží stav**
(nepřepíše stažené). Lze tedy bez obav scrapovat opakovaně.
- Stahování bere jen `stav: ceka` (a volitelně `chyba` pro retry).
## Použití
Nejjednodušší: **`python euni_menu.py`** — interaktivní menu s volbami 19
(test / dokumenty / vše / 720p / dashboard / obnova / backfill / re-scrape).
Po doběhnutí akce se vrátí do menu, `Ctrl+C` přeruší jen aktuální akci.
Ručně přes CLI:
```bat
python euni_stahni.py --scrape-only # jen inventura → Mongo + JSON
python euni_stahni.py --no-videos # scrape + stáhne jen dokumenty
python euni_stahni.py # scrape + dokumenty + videa
python euni_stahni.py --from-json --no-videos # přeskočí scrape, stáhne z Mongo/JSON
python euni_stahni.py --professions all # všechny profese (2,4,5,6,7)
python euni_stahni.py --limit 3 # jen prvních 3 kurzy (test)
python euni_stahni.py --no-mongo # bez zápisu do Mongo
python euni_stahni.py --frags 20 # víc paralelních HLS fragmentů (rychlejší)
python euni_stahni.py --video-format "bestvideo[height<=720]+bestaudio/best" # 720p
python euni_report.py # přehled stavu
python euni_report.py --soukroma # seznam přeskočených videí
```
## Jak to funguje (ověřeno)
- **Login** `/sign/` — formulář se parsuje (kopírují se skrytá Nette pole `_do`).
- **Seznam kurzů** — signál `studyAreaList-nextPage` vrací JSON snippet, stránkuje
se dokud přibývají kurzy (profese: 2=Lékař, 4=Farmaceut, 5/6=studenti, 7=NLZP).
- **Detail kurzu** — server-rendered HTML; videa z `<iframe>` (u Vimea se zachová
`?h=` hash), dokumenty z přímých odkazů i `/redirect/<base64>`.
- Metadata z bloků `lecture-info-label``lecture-info-mark`.
## Úskalí
- **Vimeo** dává oddělené video/audio HLS → nutný ffmpeg (řeší static-ffmpeg).
Domain-restricted videa se stahují s referer `https://www.euni.cz/`.
- **Soukromá videa** (autor je zamkl) nejdou stáhnout — skript je označí
`preskoceno` s důvodem, nepadá.
- Anotace kurzu na stránce není (jen obecný text webu) → neukládá se.
- Diakritika v názvech: v konzoli cp1250 OK; výpis má pojistku proti pádu.
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
euni_db.py — připojení a operace nad MongoDB databází EUNI.
Server: mongodb://192.168.1.76:27017 (bez hesla), databáze "EUNI".
Kolekce:
kurzy — 1 dokument na kurz (metadata + počty)
materialy — 1 dokument na stahovatelný soubor (video/dokument) + stav stahování
Idempotence: materialy mají unikátní index {kurz_id, klic}. Upsert nový soubor
založí jako "ceka"; u existujícího NEPŘEPÍŠE stav stahování (jen popisná pole).
"""
import os
from datetime import datetime, timezone
import pymongo
MONGO_URI = os.environ.get("EUNI_MONGO_URI", "mongodb://192.168.1.76:27017")
DB_NAME = "EUNI"
# stavy materiálu
CEKA = "ceka"
STAZENO = "stazeno"
PRESKOCENO = "preskoceno"
CHYBA = "chyba"
def now():
return datetime.now(timezone.utc)
def get_db():
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=4000)
client.admin.command("ping")
return client[DB_NAME]
def ensure_indexes(db=None):
if db is None:
db = get_db()
db.materialy.create_index([("kurz_id", 1), ("klic", 1)], unique=True,
name="uniq_kurz_klic")
db.materialy.create_index("stav", name="stav")
db.materialy.create_index([("druh", 1), ("stav", 1)], name="druh_stav")
db.kurzy.create_index("profese", name="profese")
return db
# ----------------------------------------------------------------- kurzy ------
def upsert_kurz(db, kurz: dict):
"""Vloží/aktualizuje kurz. Zachová first_seen, profese sjednotí."""
_id = kurz["id"]
sets = {
"slug": kurz.get("slug"),
"nazev": kurz.get("nazev") or kurz.get("title"),
"url": kurz.get("url"),
"autor": kurz.get("autor"),
"autor_medailonek_url": kurz.get("autor_medailonek_url"),
"datum_publikace": kurz.get("datum_publikace"),
"revidovano": kurz.get("revidovano"),
"akreditace": kurz.get("akreditace"),
"kredity": kurz.get("kredity"),
"pocet_videi": kurz.get("pocet_videi"),
"pocet_dokumentu": kurz.get("pocet_dokumentu"),
"updated_at": now(),
}
profese = kurz.get("profese") or []
db.kurzy.update_one(
{"_id": _id},
{
"$set": sets,
"$setOnInsert": {"first_seen": now()},
"$addToSet": {"profese": {"$each": profese}} if profese else {},
} if profese else {
"$set": sets,
"$setOnInsert": {"first_seen": now()},
},
upsert=True,
)
# -------------------------------------------------------------- materialy -----
def upsert_material(db, mat: dict):
"""Idempotentní upsert souboru. Nepřepíše stav existujícího záznamu."""
klic_filter = {"kurz_id": mat["kurz_id"], "klic": mat["klic"]}
popisne = {
"kurz_nazev": mat.get("kurz_nazev"),
"druh": mat.get("druh"),
"platforma": mat.get("platforma"),
"zdroj_url": mat.get("zdroj_url"),
"watch_url": mat.get("watch_url"),
"popis": mat.get("popis"),
"pripona": mat.get("pripona"),
"updated_at": now(),
}
db.materialy.update_one(
klic_filter,
{
"$set": popisne,
"$setOnInsert": {
"stav": CEKA,
"duvod": None,
"soubor": None,
"velikost_b": None,
"pokusy": 0,
"posledni_chyba": None,
"stazeno_at": None,
"first_seen": now(),
},
},
upsert=True,
)
def set_status(db, kurz_id, klic, stav, soubor=None, velikost_b=None,
duvod=None, chyba=None):
"""Nastaví výsledek stahování jednoho materiálu."""
sets = {"stav": stav, "updated_at": now()}
if stav == STAZENO:
sets.update({"soubor": soubor, "velikost_b": velikost_b,
"duvod": None, "posledni_chyba": None, "stazeno_at": now()})
elif stav == PRESKOCENO:
sets.update({"duvod": duvod})
elif stav == CHYBA:
sets.update({"posledni_chyba": chyba})
upd = {"$set": sets}
if stav in (STAZENO, CHYBA):
upd["$inc"] = {"pokusy": 1}
db.materialy.update_one({"kurz_id": kurz_id, "klic": klic}, upd)
def set_seaweed(db, kurz_id, klic, path, fids=None, md5=None, size=None):
"""Uloží referenci na kopii v SeaweedFS (cesta + fid chunků)."""
db.materialy.update_one(
{"kurz_id": kurz_id, "klic": klic},
{"$set": {
"seaweed_path": path,
"seaweed_fids": fids or [],
"seaweed_md5": md5,
"seaweed_size": size,
"seaweed_at": now(),
"updated_at": now(),
}},
)
def materialy_bez_seaweed(db):
"""Stažené materiály, které ještě nemají kopii v SeaweedFS (pro backfill)."""
return list(db.materialy.find({
"stav": STAZENO,
"soubor": {"$ne": None},
"$or": [{"seaweed_path": {"$exists": False}}, {"seaweed_path": None}],
}))
def materialy_v_seaweed(db):
"""Materiály s kopií v SeaweedFS (pro restore)."""
return list(db.materialy.find({"seaweed_path": {"$exists": True, "$ne": None}}))
def cekajici_materialy(db, druh=None, vcetne_chyb=False):
"""Vrátí materiály ke stažení (stav 'ceka', volitelně i 'chyba')."""
stavy = [CEKA] + ([CHYBA] if vcetne_chyb else [])
q = {"stav": {"$in": stavy}}
if druh:
q["druh"] = druh
return list(db.materialy.find(q))
# ----------------------------------------------------------------- stats ------
def stats(db=None):
if db is None:
db = get_db()
out = {"kurzy": db.kurzy.count_documents({})}
pipe = [{"$group": {"_id": {"druh": "$druh", "stav": "$stav"},
"n": {"$sum": 1}}}]
for row in db.materialy.aggregate(pipe):
d = row["_id"]["druh"]
st = row["_id"]["stav"]
out.setdefault(d, {})[st] = row["n"]
return out
if __name__ == "__main__":
import json
db = ensure_indexes()
print("Připojeno k EUNI na", MONGO_URI)
print(json.dumps(stats(db), ensure_ascii=False, indent=2))
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
euni_menu.py — interaktivní menu pro stahování kurzů z euni.cz.
Spuštění:
python euni_menu.py
Jen vyber číslo a Enter. Každá volba spustí příslušný skript a po doběhnutí
se vrátíš do menu (Ctrl+C přeruší aktuální akci, ne celé menu).
"""
import os
import subprocess
import sys
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
PY = sys.executable
# klíč -> (popis, skript, argumenty)
AKCE = {
"1": ("Test - 3 kurzy, jen dokumenty (rychle)",
"euni_stahni.py", ["--from-json", "--no-videos", "--limit", "3"]),
"2": ("Vsechny dokumenty (PDF/prezentace)",
"euni_stahni.py", ["--from-json", "--no-videos"]),
"3": ("Vse vcetne videi - nejvyssi kvalita (1080p, velke)",
"euni_stahni.py", ["--from-json"]),
"4": ("Vse vcetne videi - 720p (mensi, rychlejsi)",
"euni_stahni.py",
["--from-json", "--video-format",
"bestvideo[height<=720]+bestaudio/best"]),
"5": ("Jen videa (1080p)",
"euni_stahni.py", ["--from-json", "--no-docs"]),
"6": ("Prehled stavu (dashboard)", "euni_report.py", []),
"7": ("Obnova ze SeaweedFS na disk", "euni_restore.py", []),
"8": ("Backfill - dohrat chybejici kopie do SeaweedFS",
"euni_stahni.py", ["--seaweed-backfill", "--from-json"]),
"9": ("Aktualizovat seznam kurzu (znovu scrape do Mongo)",
"euni_stahni.py", ["--scrape-only"]),
}
def vycisti_obrazovku():
os.system("cls" if os.name == "nt" else "clear")
def vypis_menu():
print("=" * 60)
print(" EUNI - stahovani kurzu z euni.cz")
print("=" * 60)
print()
for k in sorted(AKCE):
print(f" {k}) {AKCE[k][0]}")
print()
print(" 0) Konec")
print()
def main():
while True:
vycisti_obrazovku()
vypis_menu()
try:
volba = input("Vyber cislo a stiskni Enter: ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if volba in ("0", "q", "exit", "konec"):
break
akce = AKCE.get(volba)
if not akce:
continue
_, skript, args = akce
print()
try:
subprocess.run([PY, str(SKRIPT_DIR / skript), *args],
cwd=str(SKRIPT_DIR))
except KeyboardInterrupt:
print("\nPreruseno uzivatelem.")
try:
input("\n=== HOTOVO. Stiskni Enter pro navrat do menu ===")
except (EOFError, KeyboardInterrupt):
break
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
euni_report.py — přehled stavu stahování z databáze EUNI.
python euni_report.py # souhrnný přehled
python euni_report.py --chyby # vypíše materiály ve stavu chyba
python euni_report.py --soukroma # vypíše přeskočená (soukromá) videa
"""
import argparse
import sys
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
import euni_db as edb
CARA = "" * 56
def lidsky(n):
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
if n >= j:
return f"{n/j:.1f} {u}"
return f"{n} B"
def main():
p = argparse.ArgumentParser()
p.add_argument("--chyby", action="store_true", help="vypiš materiály ve stavu chyba")
p.add_argument("--soukroma", action="store_true", help="vypiš přeskočená videa")
a = p.parse_args()
db = edb.get_db()
print(CARA)
print(f" EUNI — přehled ({edb.MONGO_URI})")
print(CARA)
print(f" Kurzů: {db.kurzy.count_documents({})}")
kr = db.kurzy.aggregate([{"$group": {"_id": None, "k": {"$sum": "$kredity"}}}])
kr = next(kr, {}).get("k") or 0
print(f" Kreditů celkem (akreditované kurzy): {kr}")
print(CARA)
for druh in ("video", "dokument"):
print(f" {druh.upper()}:")
pipe = [{"$match": {"druh": druh}},
{"$group": {"_id": "$stav", "n": {"$sum": 1},
"b": {"$sum": {"$ifNull": ["$velikost_b", 0]}}}}]
celkem = 0
for row in sorted(db.materialy.aggregate(pipe), key=lambda r: r["_id"]):
vel = f" ({lidsky(row['b'])})" if row["b"] else ""
print(f" {row['_id']:<11} {row['n']:>5}{vel}")
celkem += row["n"]
print(f" {'celkem':<11} {celkem:>5}")
print(CARA)
if a.chyby:
print(" CHYBY:")
for m in db.materialy.find({"stav": edb.CHYBA}):
print(f" - [{m['druh']}] {m.get('kurz_nazev','')[:40]} | "
f"{m.get('posledni_chyba','')[:60]}")
print(f" {m['zdroj_url']}")
if a.soukroma:
print(" PŘESKOČENÁ VIDEA (soukromá/nedostupná):")
for m in db.materialy.find({"stav": edb.PRESKOCENO}):
print(f" - {m.get('kurz_nazev','')[:45]} | {m.get('duvod','')}")
print(f" {m.get('watch_url') or m['zdroj_url']}")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
euni_restore.py — obnoví všechny stažené soubory ze SeaweedFS na disk.
Funguje na libovolném počítači: čte reference (cesty/fid) z MongoDB EUNI a každý
soubor stáhne z filer SeaweedFS zpět do souborového systému se stejnou strukturou
jako stazeno/<id>-<slug>/<typ>/<soubor>.
Potřebuje jen síťový přístup k Mongu (192.168.1.76) a filer (192.168.1.50) a:
python -m pip install pymongo requests
Použití:
python euni_restore.py # obnoví do ./obnoveno
python euni_restore.py --out D:\\Euni # jiný cílový adresář
python euni_restore.py --kurz 5618 # jen jeden kurz
python euni_restore.py --dry-run # jen vypíše, co by stáhl
"""
import argparse
import sys
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
import euni_db as edb
import euni_seaweed as sw
def lidsky(n):
n = n or 0
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
if n >= j:
return f"{n/j:.1f} {u}"
return f"{n} B"
def main():
p = argparse.ArgumentParser(description="Obnoví soubory ze SeaweedFS na disk.")
p.add_argument("--out", default="obnoveno", help="cílový adresář (výchozí ./obnoveno)")
p.add_argument("--kurz", help="obnovit jen tento kurz_id")
p.add_argument("--dry-run", action="store_true", help="jen vypsat, nestahovat")
a = p.parse_args()
out = Path(a.out)
db = edb.get_db()
if not sw.ping():
sys.exit(f"SeaweedFS filer nedostupný ({sw.FILER}).")
mats = edb.materialy_v_seaweed(db)
if a.kurz:
mats = [m for m in mats if m.get("kurz_id") == a.kurz]
print(f"Obnovuji {len(mats)} souborů z {sw.FILER} -> {out.resolve()}")
ok = preskoc = chyb = 0
bajtu = 0
for m in mats:
remote = m["seaweed_path"]
# lokální cesta: zrcadlí seaweed cestu bez prefixu 'euni/'
parts = remote.split("/")
rel = Path(*parts[1:]) if parts and parts[0] == sw.PREFIX else Path(*parts)
dest = out / rel
want = m.get("seaweed_size")
if dest.exists() and (want is None or dest.stat().st_size == want):
preskoc += 1
continue
if a.dry_run:
print(f" [BY STÁHL] {rel} ({lidsky(want)})")
ok += 1
continue
try:
n = sw.download(remote, dest)
bajtu += n
ok += 1
print(f" [OK] {rel} ({lidsky(n)})")
except Exception as e:
chyb += 1
print(f" [CHYBA] {rel} ({str(e)[:60]})")
print(f"\nHotovo: {ok} obnoveno, {preskoc} přeskočeno (už je), {chyb} chyb. "
f"Staženo {lidsky(bajtu)}.")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
euni_seaweed.py — nahrávání/stahování souborů do SeaweedFS přes filer HTTP API.
Filer běží na Unraidu (default http://192.168.1.50:8888). Soubory se ukládají
podle cesty, která zrcadlí lokální strukturu: euni/<id>-<slug>/<typ>/<soubor>.
Filer metadata jdou do Mongo "seaweedfs" (na 192.168.1.76) — viz README v
U:\\PythonProject\\Janssen\\SeaweedFS\\.
Identifikátor pro vyžádání souboru = cesta (filer). Navíc se ukládají fid(y)
jednotlivých chunků (číslo souboru v SeaweedFS).
Přepsání endpointu: env EUNI_FILER.
"""
import os
from urllib.parse import quote
import requests
FILER = os.environ.get("EUNI_FILER", "http://192.168.1.50:8888")
PREFIX = "euni" # kořenová složka v SeaweedFS
def _url(remote_path):
return f"{FILER}/" + quote(remote_path.lstrip("/"), safe="/")
def entry_meta(remote_path, timeout=30):
"""Detailní metadata souboru (vč. chunků s fid), nebo None když neexistuje."""
try:
r = requests.get(_url(remote_path) + "?metadata=true", timeout=timeout)
if r.status_code == 200:
return r.json()
except requests.RequestException:
pass
return None
def exists(remote_path):
return entry_meta(remote_path) is not None
def upload(local_path, remote_path, timeout=900):
"""Nahraje soubor na filer. Vrátí dict: path, fids, size, md5."""
fname = os.path.basename(remote_path)
with open(local_path, "rb") as f:
r = requests.post(_url(remote_path), files={"file": (fname, f)},
timeout=timeout)
r.raise_for_status()
meta = entry_meta(remote_path) or {}
fids = [c.get("file_id") for c in (meta.get("chunks") or []) if c.get("file_id")]
return {
"path": remote_path,
"fids": fids,
"size": meta.get("FileSize"),
"md5": meta.get("Md5"),
}
def download(remote_path, local_path, timeout=900):
"""Stáhne soubor z fileru na lokální cestu. Vrátí velikost v bajtech."""
r = requests.get(_url(remote_path), stream=True, timeout=timeout)
r.raise_for_status()
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
tmp = str(local_path) + ".part"
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
if chunk:
f.write(chunk)
os.replace(tmp, local_path)
return os.path.getsize(local_path)
def ping():
try:
r = requests.get(f"{FILER}/?limit=1", headers={"Accept": "application/json"},
timeout=5)
return r.status_code == 200
except requests.RequestException:
return False
if __name__ == "__main__":
print("Filer:", FILER, "dostupný:" , ping())
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""
euni_stahni.py — přihlásí se na euni.cz, projde kurzy a stáhne, co se stáhnout dá
(dokumenty: PDF/DOCX/PPTX/XLSX/ZIP a videa: Vimeo/YouTube).
Postup:
1) login přes /sign/ (formulář se parsuje, kopírují se i skrytá Nette pole)
2) sběr kurzů přes signál studyAreaList-nextPage (stránkování, dokud přibývají)
3) z každého kurzu se vytáhnou <iframe> videa a odkazy na dokumenty
(vč. /redirect/<base64>)
4) vše se stáhne do stazeno/<id>-<slug>/ (dokumenty/ a videa/)
Soukromá / nedostupná videa se samo přeskočí (nepadá).
Závislosti:
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg
Údaje: Euni/.env -> EUNI_USERNAME=... EUNI_PASSWORD=...
Příklady:
python euni_stahni.py # vše: scrape + dokumenty + videa (profese Lékař)
python euni_stahni.py --scrape-only # jen inventura do euni_kurzy.json
python euni_stahni.py --from-json # přeskočí scrape, použije euni_kurzy.json
python euni_stahni.py --no-videos # jen dokumenty
python euni_stahni.py --professions 2,4 # více profesí (2=Lékař,4=Farmaceut,7=NLZP)
python euni_stahni.py --limit 3 # jen první 3 kurzy (test)
"""
import argparse
import base64
import hashlib
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin, unquote, urlparse
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
# výpis ať nikdy nespadne na znaku mimo kódování konzole
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
load_dotenv(SKRIPT_DIR / ".env")
# reuse stahovače videí z ../Video/stahni_video.py
sys.path.insert(0, str(SKRIPT_DIR.parent / "Video"))
try:
import stahni_video as sv
except Exception:
sv = None
try:
import euni_db as edb
except Exception:
edb = None
try:
import euni_seaweed as sw
except Exception:
sw = None
BASE = "https://www.euni.cz"
LOGIN_URL = f"{BASE}/sign/?bid=1"
LIST_URL = f"{BASE}/seznam-kurzu?bid=1"
NEXTPAGE = f"{BASE}/seznam-kurzu?studyAreaList-professionId={{prof}}&bid=1&do=studyAreaList-nextPage"
DOC_RE = re.compile(r"\.(pdf|docx?|pptx?|xlsx?|zip)(\?|$)", re.I)
FILE_PATH_RE = re.compile(r"fileUploader/download|files/resources", re.I)
VIDEO_RE = re.compile(r"vimeo|youtube|youtu\.be", re.I)
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120 Safari/537.36")
# ---------------------------------------------------------------- pomocné -----
def bezpecny_nazev(s: str, max_len: int = 120) -> str:
"""Očistí řetězec na bezpečný název souboru/složky pro Windows."""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", s).strip(" .")
s = re.sub(r"\s+", " ", s)
return (s[:max_len].strip() or "bez_nazvu")
def make_session():
s = requests.Session()
s.headers.update({"User-Agent": UA})
return s
def _relpath(p):
"""Cesta k souboru relativně k adresáři Euni (pro uložení do DB)."""
if not p:
return None
try:
return str(Path(p).resolve().relative_to(SKRIPT_DIR))
except Exception:
return str(p)
def _seaweed_path(dest, out_root):
"""Cesta v SeaweedFS zrcadlící lokální strukturu: euni/<id-slug>/<typ>/<soubor>."""
try:
rel = Path(dest).resolve().relative_to(Path(out_root).resolve())
except Exception:
rel = Path(dest).name
return sw.PREFIX + "/" + "/".join(Path(rel).parts)
def _zaloh_do_seaweed(db, dest, out_root, kurz_id, klic):
"""Nahraje soubor do SeaweedFS a uloží referenci (fid) k materiálu do Mongo."""
if sw is None or not dest or not Path(dest).exists():
return None
remote = _seaweed_path(dest, out_root)
try:
meta = sw.entry_meta(remote)
if meta and meta.get("FileSize") == Path(dest).stat().st_size:
# už tam je se stejnou velikostí — jen zaznamenat referenci
info = {"path": remote,
"fids": [c.get("file_id") for c in (meta.get("chunks") or [])
if c.get("file_id")],
"size": meta.get("FileSize"), "md5": meta.get("Md5")}
else:
info = sw.upload(str(dest), remote)
if db is not None:
edb.set_seaweed(db, kurz_id, klic, info["path"],
fids=info.get("fids"), md5=info.get("md5"),
size=info.get("size"))
return info
except Exception as e:
print(f" [SEAWEED-CHYBA] {remote} ({str(e)[:60]})")
return None
# ----------------------------------------------------------------- login ------
def login(s):
r = s.get(LOGIN_URL, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
form = next((f for f in soup.find_all("form")
if f.find("input", {"type": "password"})), None)
if not form:
raise RuntimeError("Přihlašovací formulář nenalezen.")
data, user_field, pass_field = {}, None, None
for inp in form.find_all("input"):
name = inp.get("name")
if not name:
continue
itype = (inp.get("type") or "text").lower()
data[name] = inp.get("value", "") # zachová skrytá pole (_do, _token...)
if itype == "password":
pass_field = name
elif itype in ("text", "email") and user_field is None:
user_field = name
user = os.environ.get("EUNI_USERNAME")
pwd = os.environ.get("EUNI_PASSWORD")
if not user or not pwd:
sys.exit("Chybí EUNI_USERNAME / EUNI_PASSWORD. Vyplň je v Euni/.env "
"(vzor je v .env.example).")
data[user_field] = user
data[pass_field] = pwd
action = urljoin(LOGIN_URL, form.get("action") or LOGIN_URL)
r = s.post(action, data=data, headers={"Referer": LOGIN_URL}, timeout=30)
r.raise_for_status()
if "Odhlásit" not in r.text and "odhlasit" not in r.text.lower():
raise RuntimeError("Přihlášení se nezdařilo zkontroluj údaje v .env.")
print("✓ Přihlášeno")
# ------------------------------------------------------------- seznam kurzů ----
def get_courses_for_profession(s, profession_id):
# inicializace stránkování pro danou profesi
s.get(f"{BASE}/seznam-kurzu?studyAreaList-professionId={profession_id}&bid=1",
timeout=30)
seen, prev, guard = {}, -1, 0
while guard < 200:
guard += 1
r = s.get(NEXTPAGE.format(prof=profession_id),
headers={"X-Requested-With": "XMLHttpRequest"}, timeout=30)
r.raise_for_status()
try:
snippet = r.json().get("snippets", {}).get(
"snippet-studyAreaList-areaList", "")
except ValueError:
break
if not snippet:
break
soup = BeautifulSoup(snippet, "html.parser")
for a in soup.select("a.workshop"):
href = (a.get("href") or "").split("?")[0]
m = re.match(r"/lecture/(\d+)-(.+)", href)
if m:
seen[m.group(1)] = {
"id": m.group(1),
"slug": m.group(2),
"title": (a.find("h3").get_text(strip=True)
if a.find("h3") else m.group(2)),
"url": urljoin(BASE, href),
"profession": profession_id,
}
if len(seen) == prev:
break
prev = len(seen)
time.sleep(0.25)
return list(seen.values())
def get_all_courses(s, professions):
vse = {}
for prof in professions:
kurzy = get_courses_for_profession(s, prof)
print(f" profese {prof}: {len(kurzy)} kurzů")
for k in kurzy:
vse.setdefault(k["id"], k)
return list(vse.values())
# --------------------------------------------------------- extrakce odkazů ----
def decode_redirect(href):
m = re.search(r"/redirect/([A-Za-z0-9+/=]+)", href)
if m:
try:
return base64.b64decode(m.group(1)).decode("utf-8", "ignore")
except Exception:
pass
return None
def watch_url(embed):
m = re.search(r"player\.vimeo\.com/video/(\d+)", embed)
if m:
return f"https://vimeo.com/{m.group(1)}"
m = re.search(r"youtube\.com/embed/([\w-]+)", embed)
if m:
return f"https://www.youtube.com/watch?v={m.group(1)}"
return embed
def _text(el):
return " ".join(el.get_text(" ", strip=True).split()) if el else None
def _parse_date(s):
m = re.search(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})", s or "")
if m:
try:
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1)))
except ValueError:
return None
return None
def _mark_for_label(soup, label_text):
"""Najde hodnotu (lecture-info-mark/bold) ve stejném containeru jako daný label."""
for lab in soup.select(".lecture-info-label"):
if label_text.lower() in lab.get_text(strip=True).lower():
par = lab.parent
mark = (par.select_one(".lecture-info-mark")
or par.select_one(".lecture-info-bold"))
if mark:
return _text(mark)
return None
def extract_course_meta(soup):
meta = {}
autor_el = soup.select_one(".lecture-info-column-author")
if autor_el:
meta["autor"] = _text(autor_el.select_one(".lecture-info-mark"))
href = autor_el.get("href") or ""
if "vimeo" in href or "youtube" in href:
meta["autor_medailonek_url"] = href
if not meta.get("autor"):
meta["autor"] = (_mark_for_label(soup, "Autor kurzu")
or _mark_for_label(soup, "Autorka kurzu"))
meta["datum_publikace"] = _parse_date(_mark_for_label(soup, "Datum publikace"))
meta["revidovano"] = _parse_date(_mark_for_label(soup, "Revidováno"))
meta["akreditace"] = _mark_for_label(soup, "Akreditace")
m = re.search(r"(\d+)\s*kredit", soup.get_text(" "), re.I)
meta["kredity"] = int(m.group(1)) if m else None
return meta
def material_klic(druh, item):
"""Vrátí (klic, platforma) pro deduplikaci materiálu."""
if druh == "video":
e = item["embed"]
m = re.search(r"vimeo\.com/(?:video/)?(\d+)", e)
if m:
return f"vimeo:{m.group(1)}", "vimeo"
m = (re.search(r"youtube\.com/embed/([\w-]+)", e)
or re.search(r"youtu\.be/([\w-]+)", e)
or re.search(r"[?&]v=([\w-]+)", e))
if m:
return f"youtube:{m.group(1)}", "youtube"
return "video:" + hashlib.sha1(e.encode()).hexdigest()[:16], None
return "doc:" + hashlib.sha1(item["url"].encode()).hexdigest()[:16], None
def _pripona(url):
m = re.search(r"\.([a-z0-9]{2,4})(\?|$)", url, re.I)
return m.group(1).lower() if m else None
def extract_course_links(s, course_url):
r = s.get(course_url, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
videos, vseen = [], set()
for f in soup.find_all("iframe"):
src = f.get("src") or f.get("data-src") or ""
if src.startswith("//"):
src = "https:" + src
if VIDEO_RE.search(src) and src not in vseen:
vseen.add(src)
videos.append({"embed": src, "watch": watch_url(src)})
docs, seen = [], set()
for a in soup.find_all("a", href=True):
target = decode_redirect(a["href"]) or urljoin(BASE, a["href"])
if DOC_RE.search(target) or FILE_PATH_RE.search(target):
url = unquote(target)
if url in seen:
continue
seen.add(url)
docs.append({
"label": " ".join(a.get_text(" ", strip=True).split())[:70],
"url": url,
})
return {"videos": videos, "documents": docs, "meta": extract_course_meta(soup)}
# ------------------------------------------------------------- stahování ------
def stahni_dokument(s, url, out_dir: Path, label=""):
out_dir.mkdir(parents=True, exist_ok=True)
r = s.get(url, stream=True, timeout=120)
r.raise_for_status()
# jméno souboru z Content-Disposition, jinak z URL
fname = None
cd = r.headers.get("Content-Disposition", "")
m = re.search(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)", cd)
if m:
fname = unquote(m.group(1))
if not fname:
fname = os.path.basename(urlparse(url).path) or "soubor"
fname = bezpecny_nazev(fname)
if "." not in fname and label:
fname = bezpecny_nazev(label)
dest = out_dir / fname
if dest.exists() and dest.stat().st_size > 0:
return ("existuje", dest.name)
tmp = dest.with_suffix(dest.suffix + ".part")
with open(tmp, "wb") as fp:
for chunk in r.iter_content(chunk_size=65536):
if chunk:
fp.write(chunk)
tmp.replace(dest)
return ("staženo", dest.name)
def stahni_video(embed, out_dir: Path, referer, fmt="bestvideo*+bestaudio/best",
frags=10):
"""Stáhne video přes yt-dlp; soukromé/nedostupné přeskočí. Vrací (stav, info, fp)."""
if sv is None:
return ("chyba", "modul stahni_video není dostupný", None)
try:
import yt_dlp
from yt_dlp.utils import DownloadError
except ImportError:
return ("chyba", "yt-dlp není nainstalován", None)
out_dir.mkdir(parents=True, exist_ok=True)
ff_dir = sv.priprav_ffmpeg()
opts = {
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
"format": fmt,
"concurrent_fragment_downloads": frags, # paralelní HLS fragmenty = rychlejší
"merge_output_format": "mp4",
"logger": sv._TichyLogger(),
"progress_hooks": [sv._progress_hook],
"noprogress": True,
"noplaylist": True,
"http_headers": {"Referer": referer, "User-Agent": UA},
}
if ff_dir:
opts["ffmpeg_location"] = ff_dir
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(embed, download=True)
fp = None
rd = (info or {}).get("requested_downloads")
if rd:
fp = rd[0].get("filepath")
return ("staženo", info.get("title", embed) if info else embed, fp)
except DownloadError as e:
duvod = sv.klasifikuj_chybu(str(e))
if duvod:
return ("přeskočeno", duvod, None)
return ("chyba", str(e).split("\n")[0], None)
except Exception as e:
return ("chyba", str(e), None)
def _ingest_course(db, c):
"""Zapíše kurz + jeho materiály do Mongo (idempotentně)."""
meta = c.get("meta") or {}
nazev = c.get("nazev") or c.get("title")
kurz = {
"id": c["id"], "slug": c.get("slug"), "nazev": nazev, "url": c.get("url"),
"profese": [c["profession"]] if c.get("profession") else c.get("profese", []),
"pocet_videi": len(c.get("videos", [])),
"pocet_dokumentu": len(c.get("documents", [])),
}
for k in ("autor", "autor_medailonek_url", "datum_publikace", "revidovano",
"akreditace", "kredity"):
kurz[k] = meta.get(k)
edb.upsert_kurz(db, kurz)
for v in c.get("videos", []):
klic, plat = material_klic("video", v)
edb.upsert_material(db, {
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "video",
"platforma": plat, "klic": klic, "zdroj_url": v["embed"],
"watch_url": v.get("watch"), "popis": None, "pripona": "mp4",
})
for d in c.get("documents", []):
klic, _ = material_klic("dokument", d)
edb.upsert_material(db, {
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "dokument",
"platforma": None, "klic": klic, "zdroj_url": d["url"],
"watch_url": None, "popis": d.get("label"), "pripona": _pripona(d["url"]),
})
# ---------------------------------------------------------------- hlavní ------
def main():
p = argparse.ArgumentParser(description="Stáhne obsah kurzů z euni.cz.")
p.add_argument("--professions", default="2",
help="ID profesí oddělené čárkou (2=Lékař,4=Farmaceut,7=NLZP), nebo 'all'")
p.add_argument("--scrape-only", action="store_true", help="jen inventura do JSON")
p.add_argument("--from-json", action="store_true",
help="přeskočí scrape, použije existující euni_kurzy.json")
p.add_argument("--no-videos", action="store_true", help="nestahovat videa")
p.add_argument("--no-docs", action="store_true", help="nestahovat dokumenty")
p.add_argument("--video-format", default="bestvideo*+bestaudio/best",
help="yt-dlp formát videa (např. \"bestvideo[height<=720]+bestaudio/best\")")
p.add_argument("--frags", type=int, default=10,
help="počet paralelně stahovaných HLS fragmentů videa (default 10)")
p.add_argument("--limit", type=int, default=0, help="jen prvních N kurzů (test)")
p.add_argument("--out", default=str(SKRIPT_DIR / "stazeno"), help="výstupní adresář")
p.add_argument("--json", default=str(SKRIPT_DIR / "euni_kurzy.json"),
help="cesta k inventurnímu JSON")
p.add_argument("--no-mongo", action="store_true",
help="nezapisovat do MongoDB (jen JSON / stahování)")
p.add_argument("--no-seaweed", action="store_true",
help="nenahrávat kopie do SeaweedFS")
p.add_argument("--seaweed-backfill", action="store_true",
help="jen dohraje do SeaweedFS stažené soubory, které tam chybí")
a = p.parse_args()
json_path = Path(a.json)
out_root = Path(a.out)
s = make_session()
db = None
if not a.no_mongo:
if edb is None:
print("UPOZORNĚNÍ: modul euni_db nedostupný — pokračuji bez Mongo.")
else:
try:
db = edb.ensure_indexes()
print(f"✓ Mongo EUNI připojeno ({edb.MONGO_URI})")
except Exception as e:
print(f"UPOZORNĚNÍ: Mongo nedostupné ({e}) — pokračuji bez něj.")
use_seaweed = not a.no_seaweed and sw is not None
if use_seaweed:
if sw.ping():
print(f"✓ SeaweedFS filer dostupný ({sw.FILER})")
else:
print(f"UPOZORNĚNÍ: SeaweedFS filer nedostupný ({sw.FILER}) — "
f"pokračuji bez záloh.")
use_seaweed = False
# režim: jen dohrát do SeaweedFS chybějící stažené soubory
if a.seaweed_backfill:
if db is None or not use_seaweed:
sys.exit("Backfill potřebuje Mongo i SeaweedFS.")
chybi = edb.materialy_bez_seaweed(db)
print(f"Backfill do SeaweedFS: {len(chybi)} souborů")
ok = 0
for m in chybi:
dest = SKRIPT_DIR / m["soubor"]
if not dest.exists():
continue
remote = _seaweed_path(dest, out_root)
info = _zaloh_do_seaweed(db, dest, out_root, m["kurz_id"], m["klic"])
if info:
ok += 1
print(f" [SEAWEED] {remote}")
print(f"Hotovo: {ok}/{len(chybi)} nahráno.")
return
if a.from_json:
if not json_path.exists():
sys.exit(f"JSON {json_path} neexistuje — spusť nejdřív bez --from-json.")
results = json.loads(json_path.read_text(encoding="utf-8"))
print(f"✓ Načteno z JSON: {len(results)} kurzů")
login(s) # přihlášení potřeba pro stahování dokumentů
else:
login(s)
if a.professions.lower() == "all":
profs = [2, 4, 5, 6, 7]
else:
profs = [int(x) for x in a.professions.split(",") if x.strip()]
print(f"Sbírám kurzy (profese {profs})…")
courses = get_all_courses(s, profs)
print(f"✓ Nalezeno kurzů: {len(courses)}")
if a.limit:
courses = courses[: a.limit]
print(f" (--limit: zpracuji jen prvních {len(courses)})")
results = []
for i, c in enumerate(courses, 1):
try:
links = extract_course_links(s, c["url"])
except Exception as e:
links = {"videos": [], "documents": [], "error": str(e)}
course = {**c, **links}
results.append(course)
if db is not None and "error" not in links:
try:
_ingest_course(db, course)
except Exception as e:
print(f" [MONGO-CHYBA] {c['id']}: {e}")
print(f"[{i}/{len(courses)}] {c['title']}"
f"{len(links.get('videos', []))} videí, "
f"{len(links.get('documents', []))} dokumentů")
time.sleep(0.35)
json_path.write_text(
json.dumps(results, ensure_ascii=False, indent=2, default=str),
encoding="utf-8")
print(f"✓ Inventura uložena: {json_path}")
# souhrn inventury
n_vid = sum(len(c.get("videos", [])) for c in results)
n_doc = sum(len(c.get("documents", [])) for c in results)
print(f"\nCelkem: {len(results)} kurzů, {n_vid} videí, {n_doc} dokumentů")
if a.scrape_only:
return
# stahování
if a.limit:
results = results[: a.limit]
stat = {"doc_ok": 0, "doc_skip": 0, "doc_err": 0,
"vid_ok": 0, "vid_skip": 0, "vid_err": 0, "sw_ok": 0}
for i, c in enumerate(results, 1):
folder = out_root / bezpecny_nazev(f"{c['id']}-{c.get('slug', '')}", 80)
print(f"\n[{i}/{len(results)}] {c.get('title', c['id'])}")
if not a.no_docs:
for d in c.get("documents", []):
klic = material_klic("dokument", d)[0]
try:
stav, name = stahni_dokument(s, d["url"], folder / "dokumenty",
d.get("label", ""))
dest = folder / "dokumenty" / name
if stav == "staženo":
stat["doc_ok"] += 1
print(f" [DOK] {name}")
else:
stat["doc_skip"] += 1
if db is not None:
sz = dest.stat().st_size if dest.exists() else None
edb.set_status(db, c["id"], klic, edb.STAZENO,
soubor=_relpath(dest), velikost_b=sz)
if use_seaweed and dest.exists():
if _zaloh_do_seaweed(db, dest, out_root, c["id"], klic):
stat["sw_ok"] += 1
except Exception as e:
stat["doc_err"] += 1
print(f" [DOK-CHYBA] {d['url']} ({e})")
if db is not None:
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=str(e))
if not a.no_videos:
for v in c.get("videos", []):
klic = material_klic("video", v)[0]
stav, info, fp = stahni_video(v["embed"], folder / "videa", c["url"],
fmt=a.video_format, frags=a.frags)
if stav == "staženo":
stat["vid_ok"] += 1
print(f" [VIDEO] {info}")
if db is not None:
sz = (Path(fp).stat().st_size
if fp and Path(fp).exists() else None)
edb.set_status(db, c["id"], klic, edb.STAZENO,
soubor=_relpath(fp) if fp else None,
velikost_b=sz)
if use_seaweed and fp and Path(fp).exists():
if _zaloh_do_seaweed(db, fp, out_root, c["id"], klic):
stat["sw_ok"] += 1
elif stav == "přeskočeno":
stat["vid_skip"] += 1
print(f" [VIDEO-PŘESKOČENO] {info}")
if db is not None:
edb.set_status(db, c["id"], klic, edb.PRESKOCENO, duvod=info)
else:
stat["vid_err"] += 1
print(f" [VIDEO-CHYBA] {info}")
if db is not None:
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=info)
print("\n=== SOUHRN STAHOVÁNÍ ===")
print(f" dokumenty: {stat['doc_ok']} staženo, {stat['doc_skip']} přeskočeno, "
f"{stat['doc_err']} chyb")
print(f" videa: {stat['vid_ok']} staženo, {stat['vid_skip']} přeskočeno "
f"(soukromá/nedostupná), {stat['vid_err']} chyb")
if not a.no_seaweed:
print(f" SeaweedFS: {stat['sw_ok']} souborů zazálohováno")
print(f" výstup: {out_root}")
if __name__ == "__main__":
main()
+377
View File
@@ -0,0 +1,377 @@
# FakturyRename.py
# Verze: 1.2
# Datum: 05JUN2026
# Autor: Vladimír Buzalka
#
# Popis:
# Projde PDF faktury a doklady přímo ve vstupním adresáři, pošle je do
# OpenAI Responses API k vytěžení údajů a navrhne jednotný název souboru.
# Výsledný formát názvu:
# YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
#
# Při DRY_RUN = False skript soubor přejmenuje a přesune do podadresáře
# NamedInvoicesbyOpenAI, aby další běh zpracovával jen nové dokumenty.
# Loguje původní název, návrh, tokeny, odhad ceny a stav zpracování.
import os
from dotenv import load_dotenv
import base64
import json
import re
import time
from pathlib import Path
from openai import OpenAI
# =========================
# CENA API
# =========================
USD_TO_CZK = 25.0
# # Ceny nastav ručně podle modelu, který používáš.
# # Zde počítáme:
# # input = 5 USD / 1M tokenů
# # output = 30 USD / 1M tokenů
# PRICE_INPUT_USD_PER_1M = 5.00
# PRICE_OUTPUT_USD_PER_1M = 30.00
# # Model s podporou PDF / vision vstupu.
# MODEL = "gpt-5.5"
MODEL = "gpt-5.4-mini"
PRICE_INPUT_USD_PER_1M = 0.75
PRICE_OUTPUT_USD_PER_1M = 4.50
# =========================
# NASTAVENÍ
# =========================
FOLDER = Path(
r"u:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté"
)
PROCESSED_FOLDER = FOLDER / "NamedInvoicesbyOpenAI"
# Pro test na 3 fakturách nech DRY_RUN = True.
# Skript jen vypíše a zaloguje návrhy názvů, ale soubory nepřejmenuje.
DRY_RUN = False
# Nepůjde do podadresářů, protože používáme glob(), ne rglob().
PDF_PATTERN = "*.pdf"
LOG_FILE = FOLDER / "_rename_log_invoices.txt"
ENV_FILE = Path(r"U:\ordinaceprojekt\.env")
# =========================
# PRAVIDLA PRO POJMENOVÁNÍ
# =========================
NAMING_RULES = """
Jsi pomocník pro pojmenování naskenovaných PDF dokladů MUDr. Michaely Buzalkové.
ÚKOL:
Z PDF faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
Vrať pouze JSON s polem "filename".
CÍLOVÝ FORMÁT:
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
PŘÍKLADY:
2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf
2026-06-01 Faktura MEDIPOS 10195703 [CRP, kapiláry, písty, rukavice, nádoba] [5578.97 CZK].pdf
2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf
2026-05-29 Faktura Poliklinika Prosek 91260763 [lékárna] [16165.40 CZK].pdf
2026-06-01 Dodací list QuickSeal 200609058 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf
DŮLEŽITÁ PRAVIDLA:
1. Prefix [POHODA] nikdy nepřidávej.
2. Používej datum vystavení dokladu, ne datum splatnosti.
3. Typ dokladu vyber podle dokumentu:
- Faktura
- Dobropis
- Paragon
- Dodací list
- Zálohová faktura
- Smlouva
- Platba
- Poplatek
- Výdajový pokladní doklad
4. Pokud je v dokumentu napsáno "Dodací list není daňový doklad - nehraďte", typ musí být "Dodací list", ne "Faktura".
5. Dodavatel zapisuj krátce a konzistentně:
- MEDIPOS
- MEDEVIO
- MEDATRON
- ASKER
- QuickSeal
- Poliklinika Prosek
- Alza
- Microsoft
- OpenAI
- Ptáček
6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel/firma "Distribuce CZ", v názvu souboru použij dodavatele "Ptáček".
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo dokladu variabilní symbol nebo hlavní číslo faktury bez mezer, například 10195703. Nepoužívej interní evidenční číslo typu FV-5703/2026.
8. Částku piš vždy s desetinnou tečkou a měnou, například [5578.97 CZK].
9. Když je částka v Kč, měna je CZK.
10. Popis drž krátký, praktický a česky.
11. Popis dávej do hranatých závorek.
12. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy souborů.
13. Pokud jde jen o dodací list bez daňového dokladu, částku můžeš uvést, ale typ musí zůstat Dodací list.
14. Pokud si nejsi jistý popisem, použij obecný popis typu [materiál do ordinace], [lékárna], [vakcíny], [testy].
15. Výstup musí být pouze validní JSON, nic jiného.
JSON FORMÁT:
{
"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"
}
"""
# =========================
# POMOCNÉ FUNKCE
# =========================
def pdf_to_base64_data_url(path: Path) -> str:
data = path.read_bytes()
b64 = base64.b64encode(data).decode("utf-8")
return f"data:application/pdf;base64,{b64}"
def sanitize_windows_filename(name: str) -> str:
"""
Očistí název souboru pro Windows.
"""
# Zakázané znaky ve Windows: < > : " / \ | ? *
name = re.sub(r'[<>:"/\\|?*]', " ", name)
# Sjednocení mezer
name = re.sub(r"\s+", " ", name).strip()
# Windows nemá rád tečku nebo mezeru na konci
name = name.rstrip(" .")
if not name.lower().endswith(".pdf"):
name += ".pdf"
return name
def unique_path(target: Path) -> Path:
"""
Pokud cílový soubor existuje, přidá (2), (3), ...
"""
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
parent = target.parent
i = 2
while True:
candidate = parent / f"{stem} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
def extract_json_object(text: str) -> dict:
"""
Kdyby model náhodou vrátil něco kolem JSONu, zkusí vytáhnout první JSON objekt.
"""
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not match:
raise ValueError(f"Model nevrátil JSON:\n{text}")
return json.loads(match.group(0))
def get_usage_value(usage, key: str) -> int:
"""
Bezpečně přečte usage hodnotu.
Funguje pro objekt i dict.
"""
if usage is None:
return 0
if isinstance(usage, dict):
return usage.get(key, 0) or 0
return getattr(usage, key, 0) or 0
def calculate_cost_from_usage(usage) -> dict:
"""
Spočítá odhad ceny z response.usage.
"""
input_tokens = get_usage_value(usage, "input_tokens")
output_tokens = get_usage_value(usage, "output_tokens")
total_tokens = get_usage_value(usage, "total_tokens")
if not total_tokens:
total_tokens = input_tokens + output_tokens
input_cost_usd = input_tokens / 1_000_000 * PRICE_INPUT_USD_PER_1M
output_cost_usd = output_tokens / 1_000_000 * PRICE_OUTPUT_USD_PER_1M
total_cost_usd = input_cost_usd + output_cost_usd
total_cost_czk = total_cost_usd * USD_TO_CZK
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"input_cost_usd": input_cost_usd,
"output_cost_usd": output_cost_usd,
"total_cost_usd": total_cost_usd,
"total_cost_czk": total_cost_czk,
}
def ask_openai_for_filename(client: OpenAI, pdf_path: Path) -> tuple[str, dict]:
file_data = pdf_to_base64_data_url(pdf_path)
response = client.responses.create(
model=MODEL,
input=[
{
"role": "user",
"content": [
{
"type": "input_file",
"filename": pdf_path.name,
"file_data": file_data,
},
{
"type": "input_text",
"text": NAMING_RULES,
},
],
}
],
)
text = response.output_text.strip()
obj = extract_json_object(text)
filename = obj.get("filename", "").strip()
if not filename:
raise ValueError(f"JSON neobsahuje filename:\n{text}")
cost = calculate_cost_from_usage(response.usage)
return sanitize_windows_filename(filename), cost
def log_line(text: str) -> None:
print(text)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
if not FOLDER.exists():
raise FileNotFoundError(f"Adresář neexistuje: {FOLDER}")
# Načtení API klíče z .env
load_dotenv(ENV_FILE)
if not os.getenv("OPENAI_API_KEY"):
raise RuntimeError(
f"Chybí OPENAI_API_KEY. Zkontroluj soubor {ENV_FILE}"
)
client = OpenAI()
pdfs = sorted(FOLDER.glob(PDF_PATTERN))
pdfs = [p for p in pdfs if p.is_file() and p.suffix.lower() == ".pdf"]
if not pdfs:
print("Nenalezeno žádné PDF.")
return
total_input_tokens = 0
total_output_tokens = 0
total_tokens = 0
total_cost_usd = 0.0
total_cost_czk = 0.0
log_line("")
log_line("=" * 80)
log_line(f"START: {time.strftime('%Y-%m-%d %H:%M:%S')}")
log_line(f"Adresář: {FOLDER}")
log_line(f"Hotové faktury: {PROCESSED_FOLDER}")
log_line(f"Počet PDF: {len(pdfs)}")
log_line(f"DRY_RUN: {DRY_RUN}")
log_line(f"MODEL: {MODEL}")
log_line(f"Kurz: 1 USD = {USD_TO_CZK:.2f} CZK")
log_line("=" * 80)
for i, pdf in enumerate(pdfs, start=1):
log_line(f"\n[{i}/{len(pdfs)}] Původní název: {pdf.name}")
try:
new_name, cost = ask_openai_for_filename(client, pdf)
total_input_tokens += cost["input_tokens"]
total_output_tokens += cost["output_tokens"]
total_tokens += cost["total_tokens"]
total_cost_usd += cost["total_cost_usd"]
total_cost_czk += cost["total_cost_czk"]
log_line(f" Návrh: {new_name}")
log_line(
f" Tokeny: input={cost['input_tokens']}, "
f"output={cost['output_tokens']}, "
f"total={cost['total_tokens']}"
)
log_line(
f" Cena volání: ${cost['total_cost_usd']:.6f} "
f"{cost['total_cost_czk']:.2f}"
)
target = unique_path(PROCESSED_FOLDER / new_name)
if target.name != new_name:
log_line(f" Cíl po vyřešení konfliktu: {target.name}")
if DRY_RUN:
log_line(f" Cíl: {target}")
log_line(" Stav: DRY-RUN, nepřejmenováno/nepřesunuto")
else:
PROCESSED_FOLDER.mkdir(exist_ok=True)
pdf.rename(target)
if pdf.name == new_name:
log_line(" Stav: PŘESUNUTO")
else:
log_line(" Stav: PŘEJMENOVÁNO A PŘESUNUTO")
except Exception as e:
log_line(f" CHYBA: {type(e).__name__}: {e}")
log_line("")
log_line("=" * 80)
log_line("SOUHRN CENY")
log_line(f"Tokeny celkem: input={total_input_tokens}, output={total_output_tokens}, total={total_tokens}")
log_line(f"Cena celkem: ${total_cost_usd:.6f}{total_cost_czk:.2f}")
log_line("=" * 80)
log_line("\nHOTOVO")
if __name__ == "__main__":
main()
+365
View File
@@ -0,0 +1,365 @@
# FakturyRenameClaude.py
# Verze: 1.0
# Datum: 05JUN2026
# Autor: Claude (Anthropic)
#
# Popis:
# Obdoba FakturyRenameOpenAI.py — místo OpenAI Responses API používá
# Anthropic Messages API (Claude). PDF se posílá jako base64 document blok.
# Výsledný formát názvu:
# YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
#
# Při DRY_RUN = False skript soubor přejmenuje a přesune do podadresáře
# NamedInvoicesbyClaude, aby další běh zpracovával jen nové dokumenty.
import os
import base64
import json
import re
import time
from pathlib import Path
import anthropic
# =========================
# CENA API
# =========================
USD_TO_CZK = 25.0
MODEL = "claude-haiku-4-5"
PRICE_INPUT_USD_PER_1M = 1.00
PRICE_OUTPUT_USD_PER_1M = 5.00
# =========================
# NASTAVENÍ
# =========================
FOLDER = Path(
r"u:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté"
)
PROCESSED_FOLDER = FOLDER / "NamedInvoicesbyClaude"
# Pro test na 3 fakturách nech DRY_RUN = True.
# Skript jen vypíše a zaloguje návrhy názvů, ale soubory nepřejmenuje.
DRY_RUN = False
PDF_PATTERN = "*.pdf"
LOG_FILE = FOLDER / "_rename_log_invoices_claude.txt"
ENV_FILE = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
# =========================
# PRAVIDLA PRO POJMENOVÁNÍ
# =========================
NAMING_RULES = """
Jsi pomocník pro pojmenování naskenovaných PDF dokladů MUDr. Michaely Buzalkové.
ÚKOL:
Z PDF faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
Vrať pouze JSON s polem "filename".
CÍLOVÝ FORMÁT:
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
PŘÍKLADY — různé typy dokladů:
2026-01-22 Faktura MEDIPOS 101827406 [materiál do ordinace] [9620.80 CZK].pdf
2026-01-16 Faktura MEDEVIO 2600616 JAN2026 [1999.00 CZK].pdf
2026-01-07 Faktura Ptáček 202600168 [vakcíny] [6070.00 CZK].pdf
2026-01-15 Faktura Poliklinika Prosek 91251957 [telefon a sterilizace] [827.28 CZK].pdf
2026-01-29 Faktura QuickSeal 120600292 [kontrola kvality QSK 1. cyklus 2026] [6970.00 CZK].pdf
2026-01-20 Faktura Microsoft G136228996 [licence] [942.95 CZK].pdf
2026-01-04 Faktura OpenAI 3OXD6KWG-0006 [ChatGPT plus subscription] [558.88 CZK].pdf
2026-01-31 Faktura Mediately 80fae [předplatné] [175.12 CZK].pdf
2026-01-16 Faktura MEDATRON 2261100086 [coagucheck 2x] [5677.40 CZK].pdf
2026-02-11 Opravný doklad Alza 3260384509 [vratka faktury 4009941955] [-12941.00 CZK].pdf
2026-04-28 Faktura CLIMPROFI 900260026 [servis a čištění klimatizace] [2178.00 CZK].pdf
2026-01-19 Paragon [parkování Poliklinika Prosek 2026] [4800.00 CZK].pdf
2026-03-18 Paragon [papír do tiskárny] [180.00 CZK].pdf
2026-01-13 Mzdy MUDr. Buzalkové 202512 [Jarmila Kusinová].pdf
2025-03-31 Platba Michaela Buzalkové ČLK 2025 [4000.00 CZK].pdf
2025-01-24 Zálohová faktura Stormware 2512805657 [program Pohoda mini].pdf
2025-11-11 Faktura Avenier 425160437 [vakcíny] [28180.00 CZK].pdf
2025-04-03 Faktura CLIMPROFI 900250020 [servis a čištění klimatizace] [2057.00 CZK].pdf
2026-01-22 Dodatek Poliklinika Prosek [nájemní smlouva č.3].pdf
2025-12-31 Smlouva Kooperativa 8604142932 [profesní pojištění odpovědnosti 2026].pdf
DŮLEŽITÁ PRAVIDLA:
1. Prefix [POHODA] nikdy nepřidávej.
2. Používej datum vystavení dokladu, ne datum splatnosti.
3. Typ dokladu vyber podle dokumentu:
- Faktura
- Dobropis
- Opravný doklad ← pro storno/vrátky (ne Dobropis, pokud dokument říká "Opravný daňový doklad")
- Paragon
- Dodací list
- Zálohová faktura
- Smlouva
- Dodatek ← pro dodatky ke smlouvám
- Platba ← pro členské příspěvky a podobné platby bez faktury
- Poplatek
- Mzdy ← pro výplatní / mzdové dokumenty
- Výdajový pokladní doklad
4. Pokud je v dokumentu napsáno "Dodací list není daňový doklad - nehraďte", typ musí být "Dodací list", ne "Faktura".
5. Dodavatel zapisuj krátce a konzistentně podle tohoto seznamu — použij přesně tato jména:
- MEDIPOS (Medipos, MEDIPOS s.r.o.)
- MEDEVIO (Medevio)
- MEDATRON (MEDATRON s.r.o., Medatron)
- ASKER (Asker)
- QuickSeal (QuickSeal International s.r.o.)
- Poliklinika Prosek (i pro "Lékárna Poliklinika Prosek a.s." — viz pravidlo 14)
- Alza
- Microsoft
- OpenAI
- Ptáček (i pro "Distribuce CZ" — viz pravidlo 6)
- Avenier
- Stormware (STORMWARE s.r.o., Pohoda software)
- CompuGroup (CompuGroup Medical, Medicus software)
- CLIMPROFI (CLIMPROFI s.r.o.)
- SEIVA (SEIVA s.r.o.)
- DrMAX
- Mediately (číslo je krátký hash, např. 80fae, bcd33)
- Kooperativa
- ICA (ICA a.s., První certifikační autorita — certifikáty)
- Česká pošta
- SOLDIERBOY
- OMNIPRAX
- Medicross (MediCross s.r.o.)
6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel/firma "Distribuce CZ", v názvu souboru použij dodavatele "Ptáček".
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo dokladu variabilní symbol nebo hlavní číslo faktury bez mezer, například 10195703. Nepoužívej interní evidenční číslo typu FV-5703/2026.
8. SPECIÁLNÍ PRAVIDLO: u faktur MEDEVIO přidej za číslo faktury i měsíční kód, například "2600616 JAN2026".
9. Částku piš vždy s desetinnou tečkou a měnou, například [5578.97 CZK]. Pokud je částka záporná (dobropis/storno), piš ji jako [-12941.00 CZK].
10. Pokud je částka v EUR, měna je EUR, pokud v Kč/CZK, měna je CZK.
11. Popis drž krátký, praktický a česky.
12. Popis dávej do hranatých závorek [popis].
13. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy souborů.
14. Pokud je dodavatel "Lékárna Poliklinika Prosek", "Lékárna Prosek" nebo podobně, použij jako dodavatele "Poliklinika Prosek" a jako popis [lékárna] nebo [léky do ordinace].
15. Paragon: pokud dokument nemá číslo dokladu, vynech ho. Popis musí popisovat co bylo nakoupeno.
16. Pokud jde jen o dodací list bez daňového dokladu, částku můžeš uvést, ale typ musí zůstat Dodací list.
17. Pokud si nejsi jistý popisem, použij obecný popis: [materiál do ordinace], [lékárna], [vakcíny], [testy], [licence], [předplatné].
18. Výstup musí být pouze validní JSON, nic jiného.
JSON FORMÁT:
{
"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"
}
"""
# =========================
# POMOCNÉ FUNKCE
# =========================
def load_env() -> None:
if ENV_FILE.exists():
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
def pdf_to_base64(path: Path) -> str:
return base64.standard_b64encode(path.read_bytes()).decode("utf-8")
def sanitize_windows_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', " ", name)
name = re.sub(r"\s+", " ", name).strip()
name = name.rstrip(" .")
if not name.lower().endswith(".pdf"):
name += ".pdf"
return name
def unique_path(target: Path) -> Path:
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
parent = target.parent
i = 2
while True:
candidate = parent / f"{stem} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
def extract_json_object(text: str) -> dict:
text = text.strip()
# Odstraň markdown code block (```json ... ``` nebo ``` ... ```)
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text.strip()).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not match:
raise ValueError(f"Model nevrátil JSON:\n{text}")
return json.loads(match.group(0))
def calculate_cost(input_tokens: int, output_tokens: int) -> dict:
input_cost_usd = input_tokens / 1_000_000 * PRICE_INPUT_USD_PER_1M
output_cost_usd = output_tokens / 1_000_000 * PRICE_OUTPUT_USD_PER_1M
total_cost_usd = input_cost_usd + output_cost_usd
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"input_cost_usd": input_cost_usd,
"output_cost_usd": output_cost_usd,
"total_cost_usd": total_cost_usd,
"total_cost_czk": total_cost_usd * USD_TO_CZK,
}
def ask_claude_for_filename(client: anthropic.Anthropic, pdf_path: Path) -> tuple[str, dict]:
pdf_b64 = pdf_to_base64(pdf_path)
response = client.messages.create(
model=MODEL,
max_tokens=256,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_b64,
},
},
{
"type": "text",
"text": NAMING_RULES,
},
],
}
],
)
text = next(
(block.text for block in response.content if block.type == "text"), ""
).strip()
obj = extract_json_object(text)
filename = obj.get("filename", "").strip()
if not filename:
raise ValueError(f"JSON neobsahuje filename:\n{text}")
cost = calculate_cost(response.usage.input_tokens, response.usage.output_tokens)
return sanitize_windows_filename(filename), cost
def log_line(text: str) -> None:
print(text)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
if not FOLDER.exists():
raise FileNotFoundError(f"Adresář neexistuje: {FOLDER}")
load_env()
if not os.getenv("ANTHROPIC_API_KEY"):
raise RuntimeError(
f"Chybí ANTHROPIC_API_KEY. Zkontroluj soubor {ENV_FILE}"
)
client = anthropic.Anthropic()
pdfs = sorted(FOLDER.glob(PDF_PATTERN))
pdfs = [p for p in pdfs if p.is_file() and p.suffix.lower() == ".pdf"]
if not pdfs:
print("Nenalezeno žádné PDF.")
return
total_input_tokens = 0
total_output_tokens = 0
total_tokens = 0
total_cost_usd = 0.0
total_cost_czk = 0.0
log_line("")
log_line("=" * 80)
log_line(f"START: {time.strftime('%Y-%m-%d %H:%M:%S')}")
log_line(f"Adresář: {FOLDER}")
log_line(f"Hotové faktury: {PROCESSED_FOLDER}")
log_line(f"Počet PDF: {len(pdfs)}")
log_line(f"DRY_RUN: {DRY_RUN}")
log_line(f"MODEL: {MODEL}")
log_line(f"Kurz: 1 USD = {USD_TO_CZK:.2f} CZK")
log_line("=" * 80)
for i, pdf in enumerate(pdfs, start=1):
log_line(f"\n[{i}/{len(pdfs)}] Původní název: {pdf.name}")
try:
new_name, cost = ask_claude_for_filename(client, pdf)
total_input_tokens += cost["input_tokens"]
total_output_tokens += cost["output_tokens"]
total_tokens += cost["total_tokens"]
total_cost_usd += cost["total_cost_usd"]
total_cost_czk += cost["total_cost_czk"]
log_line(f" Návrh: {new_name}")
log_line(
f" Tokeny: input={cost['input_tokens']}, "
f"output={cost['output_tokens']}, "
f"total={cost['total_tokens']}"
)
log_line(
f" Cena volání: ${cost['total_cost_usd']:.6f} "
f"{cost['total_cost_czk']:.2f}"
)
target = unique_path(PROCESSED_FOLDER / new_name)
if target.name != new_name:
log_line(f" Cíl po vyřešení konfliktu: {target.name}")
if DRY_RUN:
log_line(f" Cíl: {target}")
log_line(" Stav: DRY-RUN, nepřejmenováno/nepřesunuto")
else:
PROCESSED_FOLDER.mkdir(exist_ok=True)
pdf.rename(target)
if pdf.name == new_name:
log_line(" Stav: PŘESUNUTO")
else:
log_line(" Stav: PŘEJMENOVÁNO A PŘESUNUTO")
except Exception as e:
log_line(f" CHYBA: {type(e).__name__}: {e}")
log_line("")
log_line("=" * 80)
log_line("SOUHRN CENY")
log_line(f"Tokeny celkem: input={total_input_tokens}, output={total_output_tokens}, total={total_tokens}")
log_line(f"Cena celkem: ${total_cost_usd:.6f}{total_cost_czk:.2f}")
log_line("=" * 80)
log_line("\nHOTOVO")
if __name__ == "__main__":
main()
@@ -0,0 +1,386 @@
# FakturyRenameClaudeLocalOCR.py
# Verze: 1.0
# Datum: 05JUN2026
# Autor: Claude (Anthropic)
#
# Popis:
# Jako FakturyRenameClaude.py, ale PDF se nezasílá do API.
# Místo toho se každá stránka PDF převede lokálně na obrázek (PyMuPDF),
# provede se OCR pomocí Tesseract (pytesseract) a Claude dostane pouze
# vytěžený text. Levnější a rychlejší — odesíláme jen text, ne velký PDF.
#
# Závislosti:
# pip install anthropic pymupdf pytesseract
# + nainstalovaný Tesseract: https://github.com/UB-Mannheim/tesseract/wiki
#
# Výsledný formát názvu:
# YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
#
# Při DRY_RUN = False skript soubor přejmenuje a přesune do podadresáře
# NamedInvoicesbyClaude, aby další běh zpracovával jen nové dokumenty.
import os
import json
import re
import time
from pathlib import Path
import anthropic
import fitz # PyMuPDF
import pytesseract
from PIL import Image
# =========================
# CENA API (jen text tokeny — levnější než posílat PDF)
# =========================
USD_TO_CZK = 25.0
MODEL = "claude-haiku-4-5"
PRICE_INPUT_USD_PER_1M = 1.00
PRICE_OUTPUT_USD_PER_1M = 5.00
# =========================
# NASTAVENÍ
# =========================
FOLDER = Path(
r"u:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté"
)
PROCESSED_FOLDER = FOLDER / "NamedInvoicesByClaudeLocalOCR"
# Pro test na 3 fakturách nech DRY_RUN = True.
DRY_RUN = False
PDF_PATTERN = "*.pdf"
LOG_FILE = FOLDER / "_rename_log_invoices_claude_ocr.txt"
ENV_FILE = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
# Cesta k Tesseract.exe (Windows výchozí instalace)
TESSERACT_CMD = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
# Jazyk OCR — češtiny + angličtina (pro čísla, názvy)
TESSERACT_LANG = "ces+eng"
# DPI pro renderování stránek PDF před OCR (150 = rychlé, 300 = přesnější)
OCR_DPI = 300
# =========================
# PRAVIDLA PRO POJMENOVÁNÍ
# =========================
NAMING_RULES = """
Jsi pomocník pro pojmenování naskenovaných PDF dokladů MUDr. Michaely Buzalkové.
ÚKOL:
Z OCR textu faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
Vrať pouze JSON s polem "filename".
CÍLOVÝ FORMÁT:
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
PŘÍKLADY — různé typy dokladů:
2026-01-22 Faktura MEDIPOS 101827406 [materiál do ordinace] [9620.80 CZK].pdf
2026-01-16 Faktura MEDEVIO 2600616 JAN2026 [1999.00 CZK].pdf
2026-01-07 Faktura Ptáček 202600168 [vakcíny] [6070.00 CZK].pdf
2026-01-15 Faktura Poliklinika Prosek 91251957 [telefon a sterilizace] [827.28 CZK].pdf
2026-01-29 Faktura QuickSeal 120600292 [kontrola kvality QSK 1. cyklus 2026] [6970.00 CZK].pdf
2026-01-20 Faktura Microsoft G136228996 [licence] [942.95 CZK].pdf
2026-01-04 Faktura OpenAI 3OXD6KWG-0006 [ChatGPT plus subscription] [558.88 CZK].pdf
2026-01-31 Faktura Mediately 80fae [předplatné] [175.12 CZK].pdf
2026-01-16 Faktura MEDATRON 2261100086 [coagucheck 2x] [5677.40 CZK].pdf
2026-02-11 Opravný doklad Alza 3260384509 [vratka faktury 4009941955] [-12941.00 CZK].pdf
2026-04-28 Faktura CLIMPROFI 900260026 [servis a čištění klimatizace] [2178.00 CZK].pdf
2026-01-19 Paragon [parkování Poliklinika Prosek 2026] [4800.00 CZK].pdf
2026-03-18 Paragon [papír do tiskárny] [180.00 CZK].pdf
2026-01-13 Mzdy MUDr. Buzalkové 202512 [Jarmila Kusinová].pdf
2025-03-31 Platba Michaela Buzalkové ČLK 2025 [4000.00 CZK].pdf
2025-01-24 Zálohová faktura Stormware 2512805657 [program Pohoda mini].pdf
2025-11-11 Faktura Avenier 425160437 [vakcíny] [28180.00 CZK].pdf
2025-04-03 Faktura CLIMPROFI 900250020 [servis a čištění klimatizace] [2057.00 CZK].pdf
2026-01-22 Dodatek Poliklinika Prosek [nájemní smlouva č.3].pdf
2025-12-31 Smlouva Kooperativa 8604142932 [profesní pojištění odpovědnosti 2026].pdf
DŮLEŽITÁ PRAVIDLA:
1. Prefix [POHODA] nikdy nepřidávej.
2. Používej datum vystavení dokladu, ne datum splatnosti.
3. Typ dokladu vyber podle dokumentu:
- Faktura
- Dobropis
- Opravný doklad ← pro storno/vrátky (ne Dobropis, pokud dokument říká "Opravný daňový doklad")
- Paragon
- Dodací list
- Zálohová faktura
- Smlouva
- Dodatek ← pro dodatky ke smlouvám
- Platba ← pro členské příspěvky a podobné platby bez faktury
- Poplatek
- Mzdy ← pro výplatní / mzdové dokumenty
- Výdajový pokladní doklad
4. Pokud je v dokumentu napsáno "Dodací list není daňový doklad - nehraďte", typ musí být "Dodací list", ne "Faktura".
5. Dodavatel zapisuj krátce a konzistentně podle tohoto seznamu — použij přesně tato jména:
- MEDIPOS (Medipos, MEDIPOS s.r.o.)
- MEDEVIO (Medevio)
- MEDATRON (MEDATRON s.r.o., Medatron)
- ASKER (Asker)
- QuickSeal (QuickSeal International s.r.o.)
- Poliklinika Prosek (i pro "Lékárna Poliklinika Prosek a.s." — viz pravidlo 14)
- Alza
- Microsoft
- OpenAI
- Ptáček (i pro "Distribuce CZ" — viz pravidlo 6)
- Avenier
- Stormware (STORMWARE s.r.o., Pohoda software)
- CompuGroup (CompuGroup Medical, Medicus software)
- CLIMPROFI (CLIMPROFI s.r.o.)
- SEIVA (SEIVA s.r.o.)
- DrMAX
- Mediately (číslo je krátký hash, např. 80fae, bcd33)
- Kooperativa
- ICA (ICA a.s., První certifikační autorita — certifikáty)
- Česká pošta
- SOLDIERBOY
- OMNIPRAX
- Medicross (MediCross s.r.o.)
6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel/firma "Distribuce CZ", v názvu souboru použij dodavatele "Ptáček".
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo dokladu variabilní symbol nebo hlavní číslo faktury bez mezer, například 10195703. Nepoužívej interní evidenční číslo typu FV-5703/2026.
8. SPECIÁLNÍ PRAVIDLO: u faktur MEDEVIO přidej za číslo faktury i měsíční kód, například "2600616 JAN2026".
9. Částku piš vždy s desetinnou tečkou a měnou, například [5578.97 CZK]. Pokud je částka záporná (dobropis/storno), piš ji jako [-12941.00 CZK].
10. Pokud je částka v EUR, měna je EUR, pokud v Kč/CZK, měna je CZK.
11. Popis drž krátký, praktický a česky.
12. Popis dávej do hranatých závorek [popis].
13. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy souborů.
14. Pokud je dodavatel "Lékárna Poliklinika Prosek", "Lékárna Prosek" nebo podobně, použij jako dodavatele "Poliklinika Prosek" a jako popis [lékárna] nebo [léky do ordinace].
15. Paragon: pokud dokument nemá číslo dokladu, vynech ho. Popis musí popisovat co bylo nakoupeno.
16. Pokud jde jen o dodací list bez daňového dokladu, částku můžeš uvést, ale typ musí zůstat Dodací list.
17. Pokud si nejsi jistý popisem, použij obecný popis: [materiál do ordinace], [lékárna], [vakcíny], [testy], [licence], [předplatné].
18. Výstup musí být pouze validní JSON, nic jiného.
JSON FORMÁT:
{
"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"
}
"""
# =========================
# POMOCNÉ FUNKCE
# =========================
def load_env() -> None:
if ENV_FILE.exists():
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
def ocr_pdf(pdf_path: Path) -> str:
"""Převede PDF na obrázky a provede OCR Tesseractem. Vrátí spojený text."""
doc = fitz.open(str(pdf_path))
texts = []
matrix = fitz.Matrix(OCR_DPI / 72, OCR_DPI / 72)
for page_num, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=matrix, colorspace=fitz.csRGB)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
page_text = pytesseract.image_to_string(img, lang=TESSERACT_LANG)
texts.append(f"--- Strana {page_num} ---\n{page_text}")
doc.close()
return "\n\n".join(texts)
def sanitize_windows_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', " ", name)
name = re.sub(r"\s+", " ", name).strip()
name = name.rstrip(" .")
if not name.lower().endswith(".pdf"):
name += ".pdf"
return name
def unique_path(target: Path) -> Path:
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
parent = target.parent
i = 2
while True:
candidate = parent / f"{stem} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
def extract_json_object(text: str) -> dict:
text = text.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text.strip()).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not match:
raise ValueError(f"Model nevrátil JSON:\n{text}")
return json.loads(match.group(0))
def calculate_cost(input_tokens: int, output_tokens: int) -> dict:
input_cost_usd = input_tokens / 1_000_000 * PRICE_INPUT_USD_PER_1M
output_cost_usd = output_tokens / 1_000_000 * PRICE_OUTPUT_USD_PER_1M
total_cost_usd = input_cost_usd + output_cost_usd
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"input_cost_usd": input_cost_usd,
"output_cost_usd": output_cost_usd,
"total_cost_usd": total_cost_usd,
"total_cost_czk": total_cost_usd * USD_TO_CZK,
}
def ask_claude_for_filename(client: anthropic.Anthropic, ocr_text: str) -> tuple[str, dict]:
prompt = f"OCR text z faktury:\n\n{ocr_text}\n\n{NAMING_RULES}"
response = client.messages.create(
model=MODEL,
max_tokens=256,
messages=[
{
"role": "user",
"content": prompt,
}
],
)
text = next(
(block.text for block in response.content if block.type == "text"), ""
).strip()
obj = extract_json_object(text)
filename = obj.get("filename", "").strip()
if not filename:
raise ValueError(f"JSON neobsahuje filename:\n{text}")
cost = calculate_cost(response.usage.input_tokens, response.usage.output_tokens)
return sanitize_windows_filename(filename), cost
def log_line(text: str) -> None:
print(text)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
if not FOLDER.exists():
raise FileNotFoundError(f"Adresář neexistuje: {FOLDER}")
pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD
load_env()
if not os.getenv("ANTHROPIC_API_KEY"):
raise RuntimeError(f"Chybí ANTHROPIC_API_KEY. Zkontroluj soubor {ENV_FILE}")
client = anthropic.Anthropic()
pdfs = sorted(FOLDER.glob(PDF_PATTERN))
pdfs = [p for p in pdfs if p.is_file() and p.suffix.lower() == ".pdf"]
if not pdfs:
print("Nenalezeno žádné PDF.")
return
total_input_tokens = 0
total_output_tokens = 0
total_tokens = 0
total_cost_usd = 0.0
total_cost_czk = 0.0
log_line("")
log_line("=" * 80)
log_line(f"START: {time.strftime('%Y-%m-%d %H:%M:%S')}")
log_line(f"Adresář: {FOLDER}")
log_line(f"Hotové faktury: {PROCESSED_FOLDER}")
log_line(f"Počet PDF: {len(pdfs)}")
log_line(f"DRY_RUN: {DRY_RUN}")
log_line(f"MODEL: {MODEL} (lokální OCR, do API jde jen text)")
log_line(f"OCR DPI: {OCR_DPI}, jazyk: {TESSERACT_LANG}")
log_line(f"Kurz: 1 USD = {USD_TO_CZK:.2f} CZK")
log_line("=" * 80)
for i, pdf in enumerate(pdfs, start=1):
log_line(f"\n[{i}/{len(pdfs)}] Původní název: {pdf.name}")
try:
log_line(" OCR...")
ocr_text = ocr_pdf(pdf)
ocr_chars = len(ocr_text)
log_line(f" OCR hotovo: {ocr_chars} znaků")
new_name, cost = ask_claude_for_filename(client, ocr_text)
total_input_tokens += cost["input_tokens"]
total_output_tokens += cost["output_tokens"]
total_tokens += cost["total_tokens"]
total_cost_usd += cost["total_cost_usd"]
total_cost_czk += cost["total_cost_czk"]
log_line(f" Návrh: {new_name}")
log_line(
f" Tokeny: input={cost['input_tokens']}, "
f"output={cost['output_tokens']}, "
f"total={cost['total_tokens']}"
)
log_line(
f" Cena volání: ${cost['total_cost_usd']:.6f} "
f"{cost['total_cost_czk']:.2f}"
)
target = unique_path(PROCESSED_FOLDER / new_name)
if target.name != new_name:
log_line(f" Cíl po vyřešení konfliktu: {target.name}")
if DRY_RUN:
log_line(f" Cíl: {target}")
log_line(" Stav: DRY-RUN, nepřejmenováno/nepřesunuto")
else:
PROCESSED_FOLDER.mkdir(exist_ok=True)
pdf.rename(target)
if pdf.name == new_name:
log_line(" Stav: PŘESUNUTO")
else:
log_line(" Stav: PŘEJMENOVÁNO A PŘESUNUTO")
except Exception as e:
log_line(f" CHYBA: {type(e).__name__}: {e}")
log_line("")
log_line("=" * 80)
log_line("SOUHRN CENY")
log_line(f"Tokeny celkem: input={total_input_tokens}, output={total_output_tokens}, total={total_tokens}")
log_line(f"Cena celkem: ${total_cost_usd:.6f}{total_cost_czk:.2f}")
log_line("=" * 80)
log_line("\nHOTOVO")
if __name__ == "__main__":
main()
+309
View File
@@ -0,0 +1,309 @@
# FakturyRenameOllama.py
# Verze: 1.0
# Datum: 05JUN2026
# Autor: Claude (Anthropic)
#
# Popis:
# Jako FakturyRenameClaudeLocalOCR.py, ale místo Anthropic API posílá
# OCR text na lokální Ollama server (Unraid). Žádné API tokeny, žádné náklady.
# Lokální OCR (Tesseract) + lokální LLM (Ollama) = 100% offline.
#
# Závislosti:
# pip install pymupdf pytesseract pillow requests
# + nainstalovaný Tesseract: https://github.com/UB-Mannheim/tesseract/wiki
#
# Výsledný formát názvu:
# YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
import json
import re
import time
import requests
from pathlib import Path
import fitz # PyMuPDF
import pytesseract
from PIL import Image
# =========================
# NASTAVENÍ OLLAMA
# =========================
OLLAMA_HOST = "http://192.168.1.76:11434"
MODEL = "mistral:7b"
# =========================
# NASTAVENÍ
# =========================
FOLDER = Path(
r"u:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté"
)
PROCESSED_FOLDER = FOLDER / "NamedInvoicesByOllama"
# Pro test nech DRY_RUN = True — jen vypíše návrhy, nepřejmenuje.
DRY_RUN = False
PDF_PATTERN = "*.pdf"
LOG_FILE = FOLDER / "_rename_log_invoices_ollama.txt"
# Cesta k Tesseract.exe
TESSERACT_CMD = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
TESSERACT_LANG = "ces+eng"
OCR_DPI = 300
# =========================
# PRAVIDLA PRO POJMENOVÁNÍ
# =========================
NAMING_RULES = """
Jsi pomocník pro pojmenování naskenovaných PDF dokladů MUDr. Michaely Buzalkové.
ÚKOL:
Z OCR textu faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
Vrať pouze JSON s polem "filename".
CÍLOVÝ FORMÁT:
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
PŘÍKLADY — různé typy dokladů:
2026-01-22 Faktura MEDIPOS 101827406 [materiál do ordinace] [9620.80 CZK].pdf
2026-01-16 Faktura MEDEVIO 2600616 JAN2026 [1999.00 CZK].pdf
2026-01-07 Faktura Ptáček 202600168 [vakcíny] [6070.00 CZK].pdf
2026-01-15 Faktura Poliklinika Prosek 91251957 [telefon a sterilizace] [827.28 CZK].pdf
2026-01-29 Faktura QuickSeal 120600292 [kontrola kvality QSK 1. cyklus 2026] [6970.00 CZK].pdf
2026-01-20 Faktura Microsoft G136228996 [licence] [942.95 CZK].pdf
2026-01-04 Faktura OpenAI 3OXD6KWG-0006 [ChatGPT plus subscription] [558.88 CZK].pdf
2026-01-31 Faktura Mediately 80fae [předplatné] [175.12 CZK].pdf
2026-01-16 Faktura MEDATRON 2261100086 [coagucheck 2x] [5677.40 CZK].pdf
2026-02-11 Opravný doklad Alza 3260384509 [vratka faktury 4009941955] [-12941.00 CZK].pdf
2026-04-28 Faktura CLIMPROFI 900260026 [servis a čištění klimatizace] [2178.00 CZK].pdf
2026-01-19 Paragon [parkování Poliklinika Prosek 2026] [4800.00 CZK].pdf
2026-03-18 Paragon [papír do tiskárny] [180.00 CZK].pdf
2026-01-13 Mzdy MUDr. Buzalkové 202512 [Jarmila Kusinová].pdf
2025-03-31 Platba Michaela Buzalkové ČLK 2025 [4000.00 CZK].pdf
2025-01-24 Zálohová faktura Stormware 2512805657 [program Pohoda mini].pdf
2025-11-11 Faktura Avenier 425160437 [vakcíny] [28180.00 CZK].pdf
2025-04-03 Faktura CLIMPROFI 900250020 [servis a čištění klimatizace] [2057.00 CZK].pdf
2026-01-22 Dodatek Poliklinika Prosek [nájemní smlouva č.3].pdf
2025-12-31 Smlouva Kooperativa 8604142932 [profesní pojištění odpovědnosti 2026].pdf
DŮLEŽITÁ PRAVIDLA:
1. Prefix [POHODA] nikdy nepřidávej.
2. Používej datum vystavení dokladu, ne datum splatnosti.
3. Typ dokladu vyber podle dokumentu:
- Faktura
- Dobropis
- Opravný doklad
- Paragon
- Dodací list
- Zálohová faktura
- Smlouva
- Dodatek
- Platba
- Poplatek
- Mzdy
- Výdajový pokladní doklad
4. Pokud je v dokumentu "Dodací list není daňový doklad - nehraďte", typ = "Dodací list".
5. Dodavatel krátce a konzistentně: MEDIPOS, MEDEVIO, MEDATRON, ASKER, QuickSeal,
Poliklinika Prosek, Alza, Microsoft, OpenAI, Ptáček, Avenier, Stormware,
CompuGroup, CLIMPROFI, SEIVA, DrMAX, Mediately, Kooperativa, ICA, Česká pošta,
SOLDIERBOY, OMNIPRAX, Medicross.
6. "Distribuce CZ" → použij "Ptáček".
7. MEDIPOS: číslo = variabilní symbol (např. 10195703), ne FV-5703/2026.
8. MEDEVIO: přidej měsíční kód za číslo (např. "2600616 JAN2026").
9. Částka s desetinnou tečkou a měnou [5578.97 CZK]. Záporná = [-12941.00 CZK].
10. EUR = EUR, Kč = CZK.
11. Popis krátký, česky, do hranatých závorek.
12. Žádné znaky nevhodné pro Windows: < > : " / \\ | ? *
13. "Lékárna Poliklinika Prosek" → dodavatel "Poliklinika Prosek", popis [lékárna].
14. Paragon bez čísla dokladu: číslo vynech.
15. Výstup musí být POUZE validní JSON, nic jiného, žádný komentář.
JSON FORMÁT:
{"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"}
"""
# =========================
# POMOCNÉ FUNKCE
# =========================
def ocr_pdf(pdf_path: Path) -> str:
doc = fitz.open(str(pdf_path))
texts = []
matrix = fitz.Matrix(OCR_DPI / 72, OCR_DPI / 72)
for page_num, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=matrix, colorspace=fitz.csRGB)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
page_text = pytesseract.image_to_string(img, lang=TESSERACT_LANG)
texts.append(f"--- Strana {page_num} ---\n{page_text}")
doc.close()
return "\n\n".join(texts)
def sanitize_windows_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', " ", name)
name = re.sub(r"\s+", " ", name).strip()
name = name.rstrip(" .")
if not name.lower().endswith(".pdf"):
name += ".pdf"
return name
def unique_path(target: Path) -> Path:
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
parent = target.parent
i = 2
while True:
candidate = parent / f"{stem} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
def extract_json_object(text: str) -> dict:
text = text.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text.strip()).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not match:
raise ValueError(f"Model nevrátil JSON:\n{text}")
return json.loads(match.group(0))
def ask_ollama_for_filename(ocr_text: str) -> tuple[str, float]:
prompt = f"OCR text z faktury:\n\n{ocr_text}\n\n{NAMING_RULES}"
payload = {
"model": MODEL,
"messages": [
{"role": "user", "content": prompt}
],
"stream": False,
"options": {
"temperature": 0,
},
}
t0 = time.time()
resp = requests.post(
f"{OLLAMA_HOST}/api/chat",
json=payload,
timeout=120,
)
resp.raise_for_status()
elapsed = time.time() - t0
data = resp.json()
text = data["message"]["content"].strip()
obj = extract_json_object(text)
filename = obj.get("filename", "").strip()
if not filename:
raise ValueError(f"JSON neobsahuje filename:\n{text}")
return sanitize_windows_filename(filename), elapsed
def log_line(text: str) -> None:
print(text)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
if not FOLDER.exists():
raise FileNotFoundError(f"Adresář neexistuje: {FOLDER}")
pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD
# Test spojení s Ollama
try:
r = requests.get(f"{OLLAMA_HOST}/api/tags", timeout=5)
r.raise_for_status()
except Exception as e:
raise RuntimeError(f"Ollama server není dostupný na {OLLAMA_HOST}: {e}")
pdfs = sorted(FOLDER.glob(PDF_PATTERN))
pdfs = [p for p in pdfs if p.is_file() and p.suffix.lower() == ".pdf"]
if not pdfs:
print("Nenalezeno žádné PDF.")
return
log_line("")
log_line("=" * 80)
log_line(f"START: {time.strftime('%Y-%m-%d %H:%M:%S')}")
log_line(f"Adresář: {FOLDER}")
log_line(f"Hotové faktury: {PROCESSED_FOLDER}")
log_line(f"Počet PDF: {len(pdfs)}")
log_line(f"DRY_RUN: {DRY_RUN}")
log_line(f"MODEL: {MODEL} @ {OLLAMA_HOST} (lokální, bez nákladů)")
log_line(f"OCR DPI: {OCR_DPI}, jazyk: {TESSERACT_LANG}")
log_line("=" * 80)
total_elapsed = 0.0
for i, pdf in enumerate(pdfs, start=1):
log_line(f"\n[{i}/{len(pdfs)}] Původní název: {pdf.name}")
try:
log_line(" OCR...")
ocr_text = ocr_pdf(pdf)
log_line(f" OCR hotovo: {len(ocr_text)} znaků")
new_name, elapsed = ask_ollama_for_filename(ocr_text)
total_elapsed += elapsed
log_line(f" Návrh: {new_name}")
log_line(f" Čas odpovědi modelu: {elapsed:.1f}s")
target = unique_path(PROCESSED_FOLDER / new_name)
if target.name != new_name:
log_line(f" Cíl po vyřešení konfliktu: {target.name}")
if DRY_RUN:
log_line(f" Cíl: {target}")
log_line(" Stav: DRY-RUN, nepřejmenováno/nepřesunuto")
else:
PROCESSED_FOLDER.mkdir(exist_ok=True)
pdf.rename(target)
if pdf.name == new_name:
log_line(" Stav: PŘESUNUTO")
else:
log_line(" Stav: PŘEJMENOVÁNO A PŘESUNUTO")
except Exception as e:
log_line(f" CHYBA: {type(e).__name__}: {e}")
log_line("")
log_line("=" * 80)
log_line("SOUHRN")
log_line(f"Celkový čas modelu: {total_elapsed:.1f}s | Cena: 0 Kč (lokální model)")
log_line("=" * 80)
log_line("\nHOTOVO")
if __name__ == "__main__":
main()
@@ -0,0 +1,364 @@
# FakturyRenameOpenAILocalOCR.py
# Verze: 1.0
# Datum: 05JUN2026
# Autor: Claude (Anthropic)
#
# Popis:
# Jako FakturyRenameOpenAI.py, ale PDF se nezasílá do API.
# Místo toho se každá stránka PDF převede lokálně na obrázek (PyMuPDF),
# provede se OCR pomocí Tesseract (pytesseract) a OpenAI dostane pouze
# vytěžený text. Levnější — odesíláme jen text, ne velký PDF.
#
# Závislosti:
# pip install openai pymupdf pytesseract pillow
# + nainstalovaný Tesseract: https://github.com/UB-Mannheim/tesseract/wiki
#
# Výsledný formát názvu:
# YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
import os
import json
import re
import time
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv
import fitz # PyMuPDF
import pytesseract
from PIL import Image
# =========================
# CENA API
# =========================
USD_TO_CZK = 25.0
MODEL = "gpt-5.4-mini"
PRICE_INPUT_USD_PER_1M = 0.75
PRICE_OUTPUT_USD_PER_1M = 4.50
# =========================
# NASTAVENÍ
# =========================
FOLDER = Path(
r"u:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté"
)
PROCESSED_FOLDER = FOLDER / "NamedInvoicesByOpenAILocalOCR"
# Pro test nech DRY_RUN = True — jen vypíše návrhy, nepřejmenuje.
DRY_RUN = False
PDF_PATTERN = "*.pdf"
LOG_FILE = FOLDER / "_rename_log_invoices_openai_ocr.txt"
ENV_FILE = Path(r"U:\ordinaceprojekt\.env")
# Cesta k Tesseract.exe
TESSERACT_CMD = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
TESSERACT_LANG = "ces+eng"
OCR_DPI = 300
# =========================
# PRAVIDLA PRO POJMENOVÁNÍ
# =========================
NAMING_RULES = """
Jsi pomocník pro pojmenování naskenovaných PDF dokladů MUDr. Michaely Buzalkové.
ÚKOL:
Z OCR textu faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
Vrať pouze JSON s polem "filename".
CÍLOVÝ FORMÁT:
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
PŘÍKLADY — různé typy dokladů:
2026-01-22 Faktura MEDIPOS 101827406 [materiál do ordinace] [9620.80 CZK].pdf
2026-01-16 Faktura MEDEVIO 2600616 JAN2026 [1999.00 CZK].pdf
2026-01-07 Faktura Ptáček 202600168 [vakcíny] [6070.00 CZK].pdf
2026-01-15 Faktura Poliklinika Prosek 91251957 [telefon a sterilizace] [827.28 CZK].pdf
2026-01-29 Faktura QuickSeal 120600292 [kontrola kvality QSK 1. cyklus 2026] [6970.00 CZK].pdf
2026-01-20 Faktura Microsoft G136228996 [licence] [942.95 CZK].pdf
2026-01-04 Faktura OpenAI 3OXD6KWG-0006 [ChatGPT plus subscription] [558.88 CZK].pdf
2026-01-31 Faktura Mediately 80fae [předplatné] [175.12 CZK].pdf
2026-01-16 Faktura MEDATRON 2261100086 [coagucheck 2x] [5677.40 CZK].pdf
2026-02-11 Opravný doklad Alza 3260384509 [vratka faktury 4009941955] [-12941.00 CZK].pdf
2026-04-28 Faktura CLIMPROFI 900260026 [servis a čištění klimatizace] [2178.00 CZK].pdf
2026-01-19 Paragon [parkování Poliklinika Prosek 2026] [4800.00 CZK].pdf
2026-03-18 Paragon [papír do tiskárny] [180.00 CZK].pdf
2026-01-13 Mzdy MUDr. Buzalkové 202512 [Jarmila Kusinová].pdf
2025-03-31 Platba Michaela Buzalkové ČLK 2025 [4000.00 CZK].pdf
2025-01-24 Zálohová faktura Stormware 2512805657 [program Pohoda mini].pdf
2025-11-11 Faktura Avenier 425160437 [vakcíny] [28180.00 CZK].pdf
2025-04-03 Faktura CLIMPROFI 900250020 [servis a čištění klimatizace] [2057.00 CZK].pdf
2026-01-22 Dodatek Poliklinika Prosek [nájemní smlouva č.3].pdf
2025-12-31 Smlouva Kooperativa 8604142932 [profesní pojištění odpovědnosti 2026].pdf
DŮLEŽITÁ PRAVIDLA:
1. Prefix [POHODA] nikdy nepřidávej.
2. Používej datum vystavení dokladu, ne datum splatnosti.
3. Typ dokladu vyber podle dokumentu:
- Faktura
- Dobropis
- Opravný doklad ← pro storno/vrátky (ne Dobropis, pokud dokument říká "Opravný daňový doklad")
- Paragon
- Dodací list
- Zálohová faktura
- Smlouva
- Dodatek ← pro dodatky ke smlouvám
- Platba ← pro členské příspěvky a podobné platby bez faktury
- Poplatek
- Mzdy ← pro výplatní / mzdové dokumenty
- Výdajový pokladní doklad
4. Pokud je v dokumentu napsáno "Dodací list není daňový doklad - nehraďte", typ musí být "Dodací list", ne "Faktura".
5. Dodavatel zapisuj krátce a konzistentně podle tohoto seznamu — použij přesně tato jména:
- MEDIPOS (Medipos, MEDIPOS s.r.o.)
- MEDEVIO (Medevio)
- MEDATRON (MEDATRON s.r.o., Medatron)
- ASKER (Asker)
- QuickSeal (QuickSeal International s.r.o.)
- Poliklinika Prosek (i pro "Lékárna Poliklinika Prosek a.s." — viz pravidlo 14)
- Alza
- Microsoft
- OpenAI
- Ptáček (i pro "Distribuce CZ" — viz pravidlo 6)
- Avenier
- Stormware (STORMWARE s.r.o., Pohoda software)
- CompuGroup (CompuGroup Medical, Medicus software)
- CLIMPROFI (CLIMPROFI s.r.o.)
- SEIVA (SEIVA s.r.o.)
- DrMAX
- Mediately (číslo je krátký hash, např. 80fae, bcd33)
- Kooperativa
- ICA (ICA a.s., První certifikační autorita — certifikáty)
- Česká pošta
- SOLDIERBOY
- OMNIPRAX
- Medicross (MediCross s.r.o.)
6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel/firma "Distribuce CZ", v názvu souboru použij dodavatele "Ptáček".
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo dokladu variabilní symbol nebo hlavní číslo faktury bez mezer, například 10195703. Nepoužívej interní evidenční číslo typu FV-5703/2026.
8. SPECIÁLNÍ PRAVIDLO: u faktur MEDEVIO přidej za číslo faktury i měsíční kód, například "2600616 JAN2026".
9. Částku piš vždy s desetinnou tečkou a měnou, například [5578.97 CZK]. Pokud je částka záporná (dobropis/storno), piš ji jako [-12941.00 CZK].
10. Pokud je částka v EUR, měna je EUR, pokud v Kč/CZK, měna je CZK.
11. Popis drž krátký, praktický a česky.
12. Popis dávej do hranatých závorek [popis].
13. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy souborů.
14. Pokud je dodavatel "Lékárna Poliklinika Prosek", "Lékárna Prosek" nebo podobně, použij jako dodavatele "Poliklinika Prosek" a jako popis [lékárna] nebo [léky do ordinace].
15. Paragon: pokud dokument nemá číslo dokladu, vynech ho. Popis musí popisovat co bylo nakoupeno.
16. Pokud jde jen o dodací list bez daňového dokladu, částku můžeš uvést, ale typ musí zůstat Dodací list.
17. Pokud si nejsi jistý popisem, použij obecný popis: [materiál do ordinace], [lékárna], [vakcíny], [testy], [licence], [předplatné].
18. Výstup musí být pouze validní JSON, nic jiného.
JSON FORMÁT:
{
"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"
}
"""
# =========================
# POMOCNÉ FUNKCE
# =========================
def ocr_pdf(pdf_path: Path) -> str:
doc = fitz.open(str(pdf_path))
texts = []
matrix = fitz.Matrix(OCR_DPI / 72, OCR_DPI / 72)
for page_num, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=matrix, colorspace=fitz.csRGB)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
page_text = pytesseract.image_to_string(img, lang=TESSERACT_LANG)
texts.append(f"--- Strana {page_num} ---\n{page_text}")
doc.close()
return "\n\n".join(texts)
def sanitize_windows_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', " ", name)
name = re.sub(r"\s+", " ", name).strip()
name = name.rstrip(" .")
if not name.lower().endswith(".pdf"):
name += ".pdf"
return name
def unique_path(target: Path) -> Path:
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
parent = target.parent
i = 2
while True:
candidate = parent / f"{stem} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
def extract_json_object(text: str) -> dict:
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not match:
raise ValueError(f"Model nevrátil JSON:\n{text}")
return json.loads(match.group(0))
def calculate_cost(input_tokens: int, output_tokens: int) -> dict:
input_cost_usd = input_tokens / 1_000_000 * PRICE_INPUT_USD_PER_1M
output_cost_usd = output_tokens / 1_000_000 * PRICE_OUTPUT_USD_PER_1M
total_cost_usd = input_cost_usd + output_cost_usd
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"input_cost_usd": input_cost_usd,
"output_cost_usd": output_cost_usd,
"total_cost_usd": total_cost_usd,
"total_cost_czk": total_cost_usd * USD_TO_CZK,
}
def ask_openai_for_filename(client: OpenAI, ocr_text: str) -> tuple[str, dict]:
prompt = f"OCR text z faktury:\n\n{ocr_text}\n\n{NAMING_RULES}"
response = client.chat.completions.create(
model=MODEL,
max_completion_tokens=256,
temperature=0,
messages=[
{"role": "user", "content": prompt}
],
)
text = response.choices[0].message.content.strip()
obj = extract_json_object(text)
filename = obj.get("filename", "").strip()
if not filename:
raise ValueError(f"JSON neobsahuje filename:\n{text}")
cost = calculate_cost(response.usage.prompt_tokens, response.usage.completion_tokens)
return sanitize_windows_filename(filename), cost
def log_line(text: str) -> None:
print(text)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
if not FOLDER.exists():
raise FileNotFoundError(f"Adresář neexistuje: {FOLDER}")
pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD
load_dotenv(ENV_FILE)
if not os.getenv("OPENAI_API_KEY"):
raise RuntimeError(f"Chybí OPENAI_API_KEY. Zkontroluj soubor {ENV_FILE}")
client = OpenAI()
pdfs = sorted(FOLDER.glob(PDF_PATTERN))
pdfs = [p for p in pdfs if p.is_file() and p.suffix.lower() == ".pdf"]
if not pdfs:
print("Nenalezeno žádné PDF.")
return
total_input_tokens = 0
total_output_tokens = 0
total_tokens = 0
total_cost_usd = 0.0
total_cost_czk = 0.0
log_line("")
log_line("=" * 80)
log_line(f"START: {time.strftime('%Y-%m-%d %H:%M:%S')}")
log_line(f"Adresář: {FOLDER}")
log_line(f"Hotové faktury: {PROCESSED_FOLDER}")
log_line(f"Počet PDF: {len(pdfs)}")
log_line(f"DRY_RUN: {DRY_RUN}")
log_line(f"MODEL: {MODEL} (lokální OCR, do API jde jen text)")
log_line(f"OCR DPI: {OCR_DPI}, jazyk: {TESSERACT_LANG}")
log_line(f"Kurz: 1 USD = {USD_TO_CZK:.2f} CZK")
log_line("=" * 80)
for i, pdf in enumerate(pdfs, start=1):
log_line(f"\n[{i}/{len(pdfs)}] Původní název: {pdf.name}")
try:
log_line(" OCR...")
ocr_text = ocr_pdf(pdf)
log_line(f" OCR hotovo: {len(ocr_text)} znaků")
new_name, cost = ask_openai_for_filename(client, ocr_text)
total_input_tokens += cost["input_tokens"]
total_output_tokens += cost["output_tokens"]
total_tokens += cost["total_tokens"]
total_cost_usd += cost["total_cost_usd"]
total_cost_czk += cost["total_cost_czk"]
log_line(f" Návrh: {new_name}")
log_line(
f" Tokeny: input={cost['input_tokens']}, "
f"output={cost['output_tokens']}, "
f"total={cost['total_tokens']}"
)
log_line(
f" Cena volání: ${cost['total_cost_usd']:.6f} "
f"{cost['total_cost_czk']:.2f}"
)
target = unique_path(PROCESSED_FOLDER / new_name)
if target.name != new_name:
log_line(f" Cíl po vyřešení konfliktu: {target.name}")
if DRY_RUN:
log_line(f" Cíl: {target}")
log_line(" Stav: DRY-RUN, nepřejmenováno/nepřesunuto")
else:
PROCESSED_FOLDER.mkdir(exist_ok=True)
pdf.rename(target)
if pdf.name == new_name:
log_line(" Stav: PŘESUNUTO")
else:
log_line(" Stav: PŘEJMENOVÁNO A PŘESUNUTO")
except Exception as e:
log_line(f" CHYBA: {type(e).__name__}: {e}")
log_line("")
log_line("=" * 80)
log_line("SOUHRN CENY")
log_line(f"Tokeny celkem: input={total_input_tokens}, output={total_output_tokens}, total={total_tokens}")
log_line(f"Cena celkem: ${total_cost_usd:.6f}{total_cost_czk:.2f}")
log_line("=" * 80)
log_line("\nHOTOVO")
if __name__ == "__main__":
main()
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
01_zakoupeno.py — workflow status 0: Zakoupeno (datum 31.12.2024)
=================================================================
První stav reconciliačního workflow. Všem pacientům ze zakoupeného souboru
(CSV příloha smlouvy, 1712 RČ) nastaví status 0 "Zakoupeno" k 31.12.2024.
- Pacienti ze smlouvy, kteří už jsou v Mongo (registrovaní k 1.1.2025) → status 0.
- Pacienti ze smlouvy, kteří v Mongo nejsou (odhlášeni u Buzalkové před předáním)
→ doplněni z Medicus kar s markerem `mimo_vzp_populaci` + status 0.
- Pacienti v Mongo mimo smlouvu → označeni `ve_smlouve=False` (status 0 nedostanou).
Workflow stav drží:
status, status_popis, status_datum (aktuální stav)
status_historie[] (postup stavů — pro další kroky)
Idempotentní — opakované spuštění status 0 nezduplikuje.
"""
import csv
import re
import sys
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import pymongo
from Knihovny.medicus_db import get_medicus_connection
# ── Konfigurace ────────────────────────────────────────────────────────────────
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "ordinace"
MONGO_COLL = "registrovani_tracking"
CSV_PATH = Path(__file__).resolve().parent / "Inputs" / "2025-01-01 seznam_pacientu_jmeno_rc.csv"
STATUS = 0
STATUS_POPIS = "Zakoupeno"
STATUS_DATUM = "2024-12-31"
POJ_ZKR = {"111": "VZP", "201": "VoZP", "205": "ČPZP", "207": "OZP",
"209": "ZPŠ", "211": "ZPMV ČR", "213": "RBP"}
norm = lambda s: re.sub(r"\D", "", s or "")
def status_entry(now):
return {"status": STATUS, "status_popis": STATUS_POPIS,
"status_datum": STATUS_DATUM, "zapsano": now}
def main():
# ── CSV (zakoupený soubor) ──────────────────────────────────────────────────
csv_rc = {}
with CSV_PATH.open(encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
csv_rc[norm(row["Rodné číslo"])] = row["Příjmení a jméno"]
contract = set(csv_rc)
print(f"Zakoupený soubor (CSV): {len(contract)}")
cli = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)
coll = cli[MONGO_DB][MONGO_COLL]
coll.create_index("status")
coll.create_index("ve_smlouve")
pop = {norm(d["_id"]): d["_id"] for d in coll.find({}, {"_id": 1})}
popset = set(pop)
present = contract & popset
absent = contract - popset
extra = popset - contract
now = datetime.now(timezone.utc)
# ── 1) Pacienti ze smlouvy už v Mongo → status 0 (idempotentně) ─────────────
n_pres = 0
for rc in present:
_id = pop[rc]
d = coll.find_one({"_id": _id}, {"status_historie": 1})
sh = d.get("status_historie", [])
if not any(e.get("status") == STATUS for e in sh):
sh = sh + [status_entry(now)]
coll.update_one({"_id": _id}, {"$set": {
"status": STATUS, "status_popis": STATUS_POPIS, "status_datum": STATUS_DATUM,
"ve_smlouve": True, "status_historie": sh, "updated_at": now}})
n_pres += 1
# ── 2) Pacienti v Mongo mimo smlouvu → ve_smlouve = False ───────────────────
coll.update_many({"_id": {"$in": [pop[rc] for rc in extra]}},
{"$set": {"ve_smlouve": False, "updated_at": now}})
# ── 3) Pacienti ze smlouvy chybějící v Mongo → doplnit z kar + status 0 ─────
conn = get_medicus_connection()
cur = conn.cursor()
kar = {}
abslist = list(absent)
for i in range(0, len(abslist), 500):
b = abslist[i:i + 500]
ph = ",".join("?" for _ in b)
cur.execute(f"""
SELECT TRIM(k.rodcis), TRIM(k.prijmeni), TRIM(k.jmeno), TRIM(k.poj),
(SELECT MAX(r.datum_zruseni) FROM registr r JOIN icp i ON r.idicp=i.idicp
WHERE r.idpac=k.idpac AND i.icp='09305001' AND i.odb='001')
FROM kar k WHERE k.rodcis IN ({ph})""", b)
for rc, p, j, poj, zrus in cur.fetchall():
kar[(rc or "").strip()] = {"prijmeni": p, "jmeno": j,
"poj": (poj or "").strip(), "zruseni": zrus}
conn.close()
n_ins = 0
for rc in absent:
if coll.find_one({"_id": rc}):
continue
k = kar.get(rc, {})
poj = k.get("poj", "")
zrus = k.get("zruseni")
zrus_s = zrus.strftime("%Y-%m-%d") if zrus else None
snap = {
"k_datu": "2025-01-01", "kategorie": "ODHLASEN_PRED_PREDANIM",
"kategorie_popis": "Registrace u Buzalkové zrušena před předáním (1.1.2025)",
"v_zakoupenem_souboru": False,
"flag": "NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ",
"flag_duvod": f"registrace u Buzalkové zrušena {zrus_s} (před předáním)",
"praktik_nazev": None, "praktik_icz": None, "praktik_icp": None,
"praktik_od": None, "datum_zahajeni": None, "datum_ukonceni": None,
"medicus_zruseni": zrus_s,
}
coll.insert_one({
"_id": rc, "rc": rc,
"prijmeni": k.get("prijmeni"), "jmeno": k.get("jmeno"),
"pojistovna": {"kod": poj, "zkratka": POJ_ZKR.get(poj, poj)},
"medicus_poj": poj,
"status": STATUS, "status_popis": STATUS_POPIS, "status_datum": STATUS_DATUM,
"ve_smlouve": True, "mimo_vzp_populaci": True,
"vychozi_datum": "2025-01-01", "aktualni": snap,
"historie": [{**snap, "zmena": "doplněn ze smlouvy (mimo VZP populaci)"}],
"status_historie": [status_entry(now)],
"created_at": now, "updated_at": now,
})
n_ins += 1
# ── Souhrn ──────────────────────────────────────────────────────────────────
print(f"present (status 0 nastaveno) : {n_pres}")
print(f"absent doplněno ze smlouvy (insert) : {n_ins}")
print(f"extra mimo smlouvu (ve_smlouve=False): {len(extra)}")
print()
print(f"status 0 (Zakoupeno) celkem : {coll.count_documents({'status': 0})}")
print(f"ve_smlouve = True : {coll.count_documents({'ve_smlouve': True})}")
print(f"kolekce celkem : {coll.count_documents({})}")
cli.close()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
# FinalReconcilliation — sledování stavu registrovaných pacientů
## Cíl
Jednoznačně roztřídit pacienty **registrované v Medicusu** podle **skutečnosti ověřené u pojišťovny**:
kdo je k danému dni jejich registrující **praktik (odbornost 001)** dle VZP B2B.
- praktik = **Buzalková (IČP 09305001)** → pacient **je** v zakoupeném souboru pacientů (OK)
- praktik = kdokoli jiný / žádný → **„NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ"**
„Registrovaný v Medicusu" je jen stav v software; tohle ověřuje realitu u pojišťovny.
## Úložiště — MongoDB
| | |
|---|---|
| Server | `mongodb://192.168.1.76:27017` (stejný stroj jako MySQL `medevio`) |
| Databáze | `ordinace` |
| Kolekce | `registrovani_tracking` |
| Klíč | `_id` = rodné číslo (1 dokument na pacienta) |
### Schéma dokumentu
```json
{
"_id": "8202...", "rc": "8202...", "prijmeni": "...", "jmeno": "...",
"pojistovna": {"kod": "111", "zkratka": "VZP"},
"vychozi_datum": "2025-01-01",
"aktualni": { ...snímek... },
"historie": [ { ...snímek..., "zmena": "výchozí snímek" } ],
"created_at": ISODate, "updated_at": ISODate
}
```
Snímek (`aktualni` i položky `historie[]`):
`k_datu, kategorie, kategorie_popis, v_zakoupenem_souboru (bool), flag, flag_duvod,
praktik_nazev, praktik_icz, praktik_icp, praktik_od, datum_zahajeni, datum_ukonceni`
- **`praktik_nazev` / `praktik_icz` / `praktik_icp`** = KDO je registrující praktik dle VZP
(u `OK_BUZALKOVA` Buzalková, u `JINY_PRAKTIK` cizí ZZZ).
- **`praktik_od`** (= `datum_zahajeni`) = OD KDY je u tohoto praktika registrován.
- **`flag_duvod`** = čitelný důvod mimo soubor, např. `"jiný praktik: MOJE AMBULANCE A.S.
(IČZ 91777000) od 2014-01-01"`.
### Kategorie (plné podkategorie)
| kategorie | význam | v souboru |
|---|---|---|
| `OK_BUZALKOVA` | praktik 001 = Buzalková (IČP 09305001) | ✅ ano |
| `JINY_PRAKTIK` | praktik 001 je jiné ZZZ | 🚩 ne |
| `BEZ_PRAKTIKA_VZP` | u VZP záznam (jiná odb.), ale praktik 001 ne | 🚩 ne |
| `BEZ_ZAZNAMU_VZP` | VZP nevrátila žádný záznam (jiná pojišťovna / neplatné RČ / zaniklé pojištění) | 🚩 ne |
## Stav k výchozímu snímku 1.1.2025
Populace = 1688 pacientů registrovaných v Medicusu k 1.1.2025 (= RČ v `vzp_registrace_raw` pro to datum).
| kategorie | počet |
|---|---:|
| OK_BUZALKOVA | 1537 |
| JINY_PRAKTIK | 53 |
| BEZ_ZAZNAMU_VZP | 50 |
| BEZ_PRAKTIKA_VZP | 48 |
| **v souboru / mimo** | **1537 / 151** |
## Skript `seed_tracking.py`
Zdroj klasifikace = MySQL `medevio` tabulky `vzp_registrace_raw` + `vzp_registrace_lekari`
(plní je skripty z `Insurance/KdoJeLekar/`).
```
python seed_tracking.py # výchozí snímek k 2025-01-01
python seed_tracking.py 2026-05-02 # aplikuje další snímek (appendne změny do historie)
```
Funkce `apply_snapshot(coll, mysql, k_datu)`:
- nový pacient → vloží dokument s historií `["výchozí snímek"]`
- existující pacient → při změně `kategorie` nebo `praktik_icp` appendne položku do `historie[]`
a aktualizuje `aktualni`; jinak jen `updated_at`
→ tím se **postupně trackují změny stavu** mezi jednotlivými běhy.
### Doplnění jmen (BEZ_ZAZNAMU_VZP)
50 pacientů bez žádného VZP záznamu nemá jméno v MySQL `vzp_registrace_lekari`.
Jména + pojišťovnu jim doplňujeme z Medicus Firebird (tabulka `kar`) — uloženo i pole
`medicus_poj`. Pozn.: kdo má `medicus_poj=111` (VZP), ale je `BEZ_ZAZNAMU_VZP`, je reálně
podezřelý (zaniklé pojištění/úmrtí); 201/205/207/211 jsou prostě jiné pojišťovny.
## Reconciliation workflow — statusy
Zakoupený soubor (příloha smlouvy) = `Inputs/2025-01-01 seznam_pacientu_jmeno_rc.csv`
(OCR ze skenu; `;`-CSV, UTF-8 BOM; sloupce *Příjmení a jméno; Rodné číslo; Strana; Řádek*).
**1712 RČ.** (Opraven 1 OCR překlep RČ: Slavíková Zuzana `8956534235`→`8956039037`.)
Každý dokument nese workflow stav:
| pole | význam |
|---|---|
| `status` (int) | aktuální stav workflow |
| `status_popis` | název stavu |
| `status_datum` | datum platnosti stavu |
| `status_historie[]` | postup stavů (`status, status_popis, status_datum, zapsano`) |
| `ve_smlouve` (bool) | je pacient v zakoupeném souboru 1712? |
| `mimo_vzp_populaci` | true = doplněn ze smlouvy, nebyl ve VZP populaci k 1.1.2025 |
### Stavy
| status | popis | datum | skript |
|---|---|---|---|
| **0** | **Zakoupeno** | 31.12.2024 | `01_zakoupeno.py` |
`01_zakoupeno.py` (idempotentní): nastaví status 0 všem 1712 ze smlouvy.
- 1678 už v Mongo → status 0
- 34 chybělo (odhlášeni u Buzalkové před předáním) → doplněno z `kar`, `mimo_vzp_populaci=true`,
`aktualni.kategorie="ODHLASEN_PRED_PREDANIM"` + `medicus_zruseni`
- 10 v Mongo mimo smlouvu → `ve_smlouve=false` (status 0 nedostali)
Kolekce po kroku 0: **1722 dokumentů** (1712 ve smlouvě + 10 mimo).
### Reconciliation 1712 (k 1.1.2025)
```
1712 zakoupeno (status 0)
34 registrace zrušena před 1.1.2025 (mimo_vzp_populaci)
─────
1678 registrovaní v Medicusu k 1.1.2025
├ 1531 OK Buzalková · 50 jiný praktik · 49 bez záznamu · 48 bez praktika
```
## Další kroky (workflow)
- Definovat status 1, 2, … (např. 1 = ověřeno u VZP / registrovaný u Buzalkové).
- Aplikovat snímky z dalších běhů (29.4. a 2.5.2026 v MySQL) → naplní `historie[]`.
- Doplnit ověření **stavu pojištění** (`vzp_stav_pojisteni`).
- Finální reconciliation Excel + MCP nástroj nad kolekcí.
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
report_tracking.py
==================
Excel report nad MongoDB `ordinace.registrovani_tracking`.
Pro každého pacienta zobrazí:
jméno, datum narození, rodné číslo, pojišťovnu, stav a důvod (kdo + od kdy).
Identifikační údaje (jméno, datum narození, pojišťovna) se berou AUTORITATIVNĚ
z Medicus Firebird tabulky `kar` (přes Knihovny.medicus_db.get_medicus_connection).
Stav a důvod (kategorie, flag, flag_duvod, praktik kdo/od kdy) z Mongo trackingu.
Výstup: report_registrovani_<vychozi_datum>.xlsx v tomto adresáři.
"""
import sys
from pathlib import Path
from datetime import date
from collections import defaultdict
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import pymongo
from Knihovny.medicus_db import get_medicus_connection
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
# ── Konfigurace ────────────────────────────────────────────────────────────────
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "ordinace"
MONGO_COLL = "registrovani_tracking"
POJ_NAZVY = {
"111": "VZP", "201": "VoZP", "205": "ČPZP", "207": "OZP",
"209": "ZPŠ", "211": "ZPMV ČR", "213": "RBP",
}
# Barvy podle kategorie
BLUE_HEADER = "1F497D"
WHITE = "FFFFFF"
BARVA_KAT = {
"OK_BUZALKOVA": "EBF1DE", # zelená
"JINY_PRAKTIK": "FCE4D6", # červená
"BEZ_PRAKTIKA_VZP": "FFF2CC", # žlutá
"BEZ_ZAZNAMU_VZP": "DCE6F1", # modrá
}
STAV_TEXT = {
"OK_BUZALKOVA": "V souboru",
"JINY_PRAKTIK": "NEBYL v souboru",
"BEZ_PRAKTIKA_VZP": "NEBYL v souboru",
"BEZ_ZAZNAMU_VZP": "NEBYL v souboru",
}
def chunked(seq, n):
for i in range(0, len(seq), n):
yield seq[i:i + n]
def nacti_kar(conn, rcs):
"""Vrátí {rc: {prijmeni, jmeno, datnar, poj}} z Medicus kar."""
out = {}
cur = conn.cursor()
for batch in chunked(rcs, 500): # Firebird IN má limit 1500 prvků
ph = ",".join("?" for _ in batch)
cur.execute(
f"SELECT TRIM(rodcis), TRIM(prijmeni), TRIM(jmeno), datnar, TRIM(poj) "
f"FROM kar WHERE rodcis IN ({ph})", batch)
for rc, prij, jm, datnar, poj in cur.fetchall():
out[(rc or "").strip()] = {
"prijmeni": prij, "jmeno": jm,
"datnar": datnar, "poj": (poj or "").strip(),
}
return out
def main():
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)
coll = client[MONGO_DB][MONGO_COLL]
docs = list(coll.find({}))
vychozi = docs[0].get("vychozi_datum", "snimek") if docs else "snimek"
rcs = [d["_id"] for d in docs]
print(f"Pacientů v trackingu: {len(rcs)}")
print("Načítám kar z Medicusu ...")
conn = get_medicus_connection()
kar = nacti_kar(conn, rcs)
conn.close()
print(f"Dohledáno v kar: {len(kar)}")
# ── Sestavení řádků ────────────────────────────────────────────────────────
rows = []
for d in docs:
rc = d["_id"]
a = d.get("aktualni", {})
k = kar.get(rc, {})
prijmeni = k.get("prijmeni") or d.get("prijmeni") or ""
jmeno = k.get("jmeno") or d.get("jmeno") or ""
datnar = k.get("datnar")
poj_kod = k.get("poj") or (d.get("pojistovna") or {}).get("kod") or ""
kat = a.get("kategorie", "")
rows.append({
"prijmeni": prijmeni,
"jmeno": jmeno,
"datnar": datnar.strftime("%d.%m.%Y") if datnar else "",
"rc": rc,
"poj": f"{poj_kod} {POJ_NAZVY.get(poj_kod, '')}".strip(),
"stav": STAV_TEXT.get(kat, kat),
"kategorie": a.get("kategorie_popis", ""),
"duvod": a.get("flag_duvod", ""),
"kat_kod": kat,
})
# Řazení: nejdřív flagnutí (mimo soubor), pak podle příjmení
rows.sort(key=lambda r: (r["kat_kod"] == "OK_BUZALKOVA", r["prijmeni"], r["jmeno"]))
# ── Excel ──────────────────────────────────────────────────────────────────
wb = Workbook()
# List 1: Přehled
ws_p = wb.active
ws_p.title = "Přehled"
ws_p.column_dimensions["A"].width = 34
ws_p.column_dimensions["B"].width = 14
ws_p.merge_cells("A1:B1")
t = ws_p["A1"]
t.value = f"Registrovaní pacienti k {vychozi} — ověření praktika u VZP"
t.font = Font(name="Arial", bold=True, size=13, color=WHITE)
t.fill = PatternFill("solid", fgColor=BLUE_HEADER)
t.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws_p.row_dimensions[1].height = 34
ws_p["A2"] = f"Vygenerováno: {date.today().strftime('%d.%m.%Y')}"
ws_p["A2"].font = Font(name="Arial", italic=True, size=9, color="595959")
counts = defaultdict(int)
for r in rows:
counts[r["kat_kod"]] += 1
ws_p.cell(row=4, column=1, value="Kategorie / stav").font = Font(bold=True)
ws_p.cell(row=4, column=2, value="Počet").font = Font(bold=True)
poradi = ["OK_BUZALKOVA", "JINY_PRAKTIK", "BEZ_PRAKTIKA_VZP", "BEZ_ZAZNAMU_VZP"]
KAT_POPIS = {
"OK_BUZALKOVA": "V souboru (praktik Buzalková)",
"JINY_PRAKTIK": "Mimo soubor — jiný praktik",
"BEZ_PRAKTIKA_VZP": "Mimo soubor — bez praktika u VZP",
"BEZ_ZAZNAMU_VZP": "Mimo soubor — bez záznamu u VZP",
}
for i, kat in enumerate(poradi):
r = 5 + i
c1 = ws_p.cell(row=r, column=1, value=KAT_POPIS[kat])
c2 = ws_p.cell(row=r, column=2, value=counts[kat])
fill = PatternFill("solid", fgColor=BARVA_KAT[kat])
c1.fill = fill; c2.fill = fill
c1.font = Font(name="Arial", size=10)
ws_p.cell(row=9, column=1, value="CELKEM").font = Font(bold=True)
ws_p.cell(row=9, column=2, value=len(rows)).font = Font(bold=True)
mimo = len(rows) - counts["OK_BUZALKOVA"]
ws_p.cell(row=10, column=1, value="z toho NEBYL v zakoupeném souboru").font = Font(bold=True, color="C00000")
ws_p.cell(row=10, column=2, value=mimo).font = Font(bold=True, color="C00000")
# List 2: Pacienti
ws = wb.create_sheet("Pacienti")
COLS = [
("Příjmení", 20), ("Jméno", 14), ("Datum narození", 14),
("Rodné číslo", 14), ("Pojišťovna", 14), ("Stav", 16),
("Kategorie", 30), ("Důvod (kdo / od kdy)", 52),
]
for ci, (h, w) in enumerate(COLS, 1):
c = ws.cell(row=1, column=ci, value=h)
c.font = Font(name="Arial", bold=True, color=WHITE, size=10)
c.fill = PatternFill("solid", fgColor=BLUE_HEADER)
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws.column_dimensions[get_column_letter(ci)].width = w
ws.row_dimensions[1].height = 30
ws.freeze_panes = "A2"
thin = Side(style="thin", color="D9D9D9")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
for ri, r in enumerate(rows, 2):
bg = BARVA_KAT.get(r["kat_kod"], "FFFFFF")
data = [r["prijmeni"], r["jmeno"], r["datnar"], r["rc"], r["poj"],
r["stav"], r["kategorie"], r["duvod"]]
for ci, val in enumerate(data, 1):
c = ws.cell(row=ri, column=ci, value=val)
c.font = Font(name="Arial", size=9)
c.fill = PatternFill("solid", fgColor=bg)
c.border = border
c.alignment = Alignment(vertical="center", wrap_text=(ci == 8))
if ci == 6 and r["kat_kod"] != "OK_BUZALKOVA":
c.font = Font(name="Arial", size=9, bold=True, color="C00000")
ws.auto_filter.ref = f"A1:{get_column_letter(len(COLS))}{len(rows) + 1}"
out = Path(__file__).resolve().parent / f"report_registrovani_{vychozi}.xlsx"
wb.save(out)
print(f"\nUloženo: {out}")
print(f"Řádků: {len(rows)} | v souboru: {counts['OK_BUZALKOVA']} | mimo: {mimo}")
client.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
seed_tracking.py
================
Naplní MongoDB databázi `ordinace`, kolekci `registrovani_tracking`, výchozím
snímkem registrovaných pacientů a jejich OVĚŘENÝM stavem u VZP.
Logika "v zakoupeném souboru pacientů":
- "Registrovaný v Medicusu" je jen stav v software.
- Skutečnost ověřujeme u pojišťovny: kdo je k danému dni registrující praktik
(odbornost 001) daného pacienta.
* praktik = Buzalková (IČP 09305001) -> v pořádku, v zakoupeném souboru
* praktik = někdo jiný / žádný -> NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ
Kategorie (plné podkategorie):
OK_BUZALKOVA praktik 001 je Buzalková (IČP 09305001)
JINY_PRAKTIK praktik 001 je jiné ZZZ
BEZ_PRAKTIKA_VZP pacient má u VZP záznam (jiná odbornost), ale praktika 001 ne
BEZ_ZAZNAMU_VZP VZP nevrátila žádný záznam (typicky jiná pojišťovna / neplatné RČ)
Schéma dokumentu (1 dokument na pacienta, _id = rodné číslo):
{
"_id": "8202...", "rc": "...", "prijmeni": "...", "jmeno": "...",
"pojistovna": {"kod": "111", "zkratka": "VZP"},
"vychozi_datum": "2025-01-01",
"aktualni": { ...snímek... },
"historie": [ { ...snímek..., "zmena": "výchozí snímek" }, ... ],
"created_at": ..., "updated_at": ...
}
Snímek (aktualni i položka historie):
{ "k_datu", "kategorie", "kategorie_popis", "v_zakoupenem_souboru" (bool),
"flag", "praktik_nazev", "praktik_icz", "praktik_icp",
"datum_zahajeni", "datum_ukonceni" }
Spuštění:
python seed_tracking.py # seed k 2025-01-01
python seed_tracking.py 2026-05-02 # aplikuje další snímek (appendne změny do historie)
"""
import sys
from pathlib import Path
from datetime import datetime, date, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import pymongo
from Knihovny.mysql_db import connect_mysql
# ── KONFIGURACE ───────────────────────────────────────────────────────────────
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "ordinace"
MONGO_COLL = "registrovani_tracking"
ICP_BUZALKOVA = "09305001"
KATEGORIE_POPIS = {
"OK_BUZALKOVA": "OK praktik je Buzalková (IČP 09305001)",
"JINY_PRAKTIK": "Registrován u jiného praktika",
"BEZ_PRAKTIKA_VZP": "U VZP bez praktika (odb. 001)",
"BEZ_ZAZNAMU_VZP": "VZP nevrátila žádný záznam (jiná pojišťovna / neplatné RČ)",
}
FLAG_MIMO_SOUBOR = "NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ"
def klasifikuj(praktik_001: dict | None, ma_nejaky_zaznam: bool) -> dict:
"""Vrátí snímek stavu (bez k_datu) na základě 001 záznamu z VZP."""
if praktik_001 and praktik_001.get("ICP") == ICP_BUZALKOVA:
kat = "OK_BUZALKOVA"
elif praktik_001:
kat = "JINY_PRAKTIK"
elif ma_nejaky_zaznam:
kat = "BEZ_PRAKTIKA_VZP"
else:
kat = "BEZ_ZAZNAMU_VZP"
v_souboru = (kat == "OK_BUZALKOVA")
nazev = (praktik_001 or {}).get("nazev_zzz")
icz = (praktik_001 or {}).get("ICZ")
od = (praktik_001 or {}).get("datum_zahajeni")
# Čitelný důvod, proč pacient NENÍ v zakoupeném souboru (kdo + od kdy)
if kat == "JINY_PRAKTIK":
flag_duvod = f"jiný praktik: {nazev} (IČZ {icz}) od {od}"
elif kat == "BEZ_PRAKTIKA_VZP":
flag_duvod = "u VZP bez registrujícího praktika (odb. 001)"
elif kat == "BEZ_ZAZNAMU_VZP":
flag_duvod = "VZP nevrátila žádný záznam (jiná pojišťovna / neplatné RČ / zaniklé pojištění)"
else:
flag_duvod = ""
return {
"kategorie": kat,
"kategorie_popis": KATEGORIE_POPIS[kat],
"v_zakoupenem_souboru": v_souboru,
"flag": "" if v_souboru else FLAG_MIMO_SOUBOR,
"flag_duvod": flag_duvod,
# "kdo" a "od kdy" registrujícího praktika dle VZP
"praktik_nazev": nazev,
"praktik_icz": icz,
"praktik_icp": (praktik_001 or {}).get("ICP"),
"praktik_od": od,
"datum_zahajeni": od,
"datum_ukonceni": (praktik_001 or {}).get("datum_ukonceni"),
}
def nacti_snimek_z_mysql(mysql, k_datu: str) -> dict:
"""
Vrátí {rc: {prijmeni, jmeno, pojistovna{}, praktik_001 | None, ma_zaznam}}
pro populaci registrovaných dotázaných k danému datu.
"""
cur = mysql.cursor()
# Populace = všechna dotázaná RČ (raw) k tomuto datu
cur.execute("SELECT rc FROM vzp_registrace_raw WHERE k_datu = %s", (k_datu,))
populace = [r[0] for r in cur.fetchall()]
# Parsované záznamy lékařů k tomuto datu
cur.execute("""
SELECT rc, prijmeni, jmeno, kod_odbornosti, ICP, ICZ, nazev_zzz,
poj_kod, poj_zkratka, datum_zahajeni, datum_ukonceni
FROM vzp_registrace_lekari
WHERE k_datu = %s
""", (k_datu,))
data: dict[str, dict] = {rc: {"prijmeni": None, "jmeno": None,
"pojistovna": {"kod": None, "zkratka": None},
"praktik_001": None, "ma_zaznam": False}
for rc in populace}
for (rc, prijmeni, jmeno, odb, icp, icz, nazev_zzz,
poj_kod, poj_zkr, dat_zah, dat_uk) in cur.fetchall():
d = data.setdefault(rc, {"prijmeni": None, "jmeno": None,
"pojistovna": {"kod": None, "zkratka": None},
"praktik_001": None, "ma_zaznam": False})
d["ma_zaznam"] = True
if prijmeni and not d["prijmeni"]:
d["prijmeni"] = prijmeni
if jmeno and not d["jmeno"]:
d["jmeno"] = jmeno
# Pojišťovnu vezmi z jakéhokoli záznamu (preferuj 001 níže)
if poj_kod and not d["pojistovna"]["kod"]:
d["pojistovna"] = {"kod": poj_kod, "zkratka": poj_zkr}
if odb == "001":
d["praktik_001"] = {
"ICP": icp, "ICZ": icz, "nazev_zzz": nazev_zzz,
"poj_kod": poj_kod, "poj_zkratka": poj_zkr,
"datum_zahajeni": str(dat_zah) if dat_zah else None,
"datum_ukonceni": str(dat_uk) if dat_uk else None,
}
# Pojišťovna z 001 má přednost
if poj_kod:
d["pojistovna"] = {"kod": poj_kod, "zkratka": poj_zkr}
return data
def apply_snapshot(coll, mysql, k_datu: str) -> dict:
"""
Klasifikuje populaci k danému datu a upsertne do Mongo.
Při změně kategorie/praktika oproti `aktualni` appendne do `historie`.
Vrátí statistiku.
"""
data = nacti_snimek_z_mysql(mysql, k_datu)
now = datetime.now(timezone.utc)
stats = {"novych": 0, "zmen": 0, "beze_zmeny": 0, "kategorie": {}}
for rc, d in data.items():
snimek = klasifikuj(d["praktik_001"], d["ma_zaznam"])
snimek_s_datem = {"k_datu": k_datu, **snimek}
stats["kategorie"][snimek["kategorie"]] = stats["kategorie"].get(snimek["kategorie"], 0) + 1
existing = coll.find_one({"_id": rc})
if existing is None:
doc = {
"_id": rc, "rc": rc,
"prijmeni": d["prijmeni"], "jmeno": d["jmeno"],
"pojistovna": d["pojistovna"],
"vychozi_datum": k_datu,
"aktualni": snimek_s_datem,
"historie": [{**snimek_s_datem, "zmena": "výchozí snímek"}],
"created_at": now, "updated_at": now,
}
coll.insert_one(doc)
stats["novych"] += 1
else:
akt = existing.get("aktualni", {})
zmena = (akt.get("kategorie") != snimek["kategorie"]
or akt.get("praktik_icp") != snimek["praktik_icp"])
update = {"aktualni": snimek_s_datem, "updated_at": now}
if d["prijmeni"]:
update["prijmeni"] = d["prijmeni"]
if d["jmeno"]:
update["jmeno"] = d["jmeno"]
ops = {"$set": update}
if zmena:
popis = (f"{akt.get('kategorie')}{snimek['kategorie']}")
ops["$push"] = {"historie": {**snimek_s_datem, "zmena": popis}}
stats["zmen"] += 1
else:
stats["beze_zmeny"] += 1
coll.update_one({"_id": rc}, ops)
return stats
def main():
k_datu = sys.argv[1] if len(sys.argv) > 1 else "2025-01-01"
mysql = connect_mysql()
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)
client.admin.command("ping")
coll = client[MONGO_DB][MONGO_COLL]
# Indexy pro běžné dotazy
coll.create_index("aktualni.kategorie")
coll.create_index("aktualni.v_zakoupenem_souboru")
coll.create_index("prijmeni")
print(f"Aplikuji snímek k {k_datu} do {MONGO_DB}.{MONGO_COLL} ...")
stats = apply_snapshot(coll, mysql, k_datu)
print(f"\nNových pacientů : {stats['novych']}")
print(f"Změn stavu : {stats['zmen']}")
print(f"Beze změny : {stats['beze_zmeny']}")
print("\nRozpad podle kategorií:")
for kat, n in sorted(stats["kategorie"].items(), key=lambda x: -x[1]):
print(f" {kat:18s} {n:5d} {KATEGORIE_POPIS[kat]}")
celkem = sum(stats["kategorie"].values())
mimo = celkem - stats["kategorie"].get("OK_BUZALKOVA", 0)
print(f"\nCelkem v populaci: {celkem}")
print(f" v zakoupeném souboru (Buzalková): {stats['kategorie'].get('OK_BUZALKOVA', 0)}")
print(f" NEBYL v zakoupeném souboru : {mimo}")
mysql.close()
client.close()
if __name__ == "__main__":
main()
@@ -1,4 +1,4 @@
# KdoJeLékař — poznámky k vývoji # KdoJeLekar — poznámky k vývoji
## Cíl ## Cíl
@@ -0,0 +1,87 @@
# VZP (111) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuVZP.py` (Playwright + Chrome):
1. **Přihlásí se** certifikátem na VZP Point (auto-výběr cert z Windows store)
2. Projde **ODESLANÁ PODÁNÍ** (řazeno od nejnovějšího) a najde podání typu
„Seznam registrovaných pojištěnců"
3. Stahuje **přiložené datové dávky** `F111MMRR.nnn` (CP852) do
`…\Zúčtovací zprávy\SeznamyPojištěnců\` od nejnovějšího a **zastaví se na první
už stažené dávce** (inkrementálně — starší jsou stažené, nejde hluboko do minulosti).
4. **Podá novou žádost** o výpis (datové rozhraní) za nejnovější dostupné období
(zjištěno z configu) — výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se příště.
Dávky pak zpracovává `Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py`.
## Platforma — ODLIŠNÁ
VZP běží na **point.vzp.cz** (VZP Point), NE portalzp.cz ani eforms. Login je
certifikátem přes Chrome — politika `AutoSelectCertificateForUrls` vybere cert
automaticky (issuer `I.CA Public CA/RSA 06/2022`), bez NMSigneru. Plně Playwright.
## Jak se seznam získává
VZP seznam **není** samočinná zpráva — musí se **požádat podáním**:
- NOVÉ PODÁNÍ → „Seznam registrovaných pojištěnců ke dni"
- **Formát výstupu = „Datové rozhraní"** (NE „PDF"!) + období (měsíc/rok)
- VZP požadavek zpracuje (~minuty) a výsledek = datová dávka III-1.1.2,
stažitelná z detailu zpracovaného podání (sloupec „Přiložený soubor").
> Pozn.: pokud se zvolí formát „PDF", výsledkem je PDF (p…pdf), které parser neumí.
> Vždy volit „Datové rozhraní".
## Formát dávky (III-1.1.2)
Soubor `F111MMRR.nnn`, pevná šířka, **CP852**. Hlavička typ H:
`H09305001` (IČP) + počet + RRMMDD. Věty typu I: příjmení, jméno, číslo poj.,
datum registrace, kód pojišťovny. (Detaily v `SeznamPojistencu/01_parse_seznam_dg_tool.py`.)
## Stažení dávky z detailu podání
Detail `/Desk/Form/Detail/{id}` → záložka „Výsledky zpracování" → odkaz s názvem
`F111MMRR.nnn` (href="#", JS handler). Stahuje se Playwright klikem
(`expect_download` + `dispatch_event('click')`) — žádná přímá URL.
## Podání žádosti (REST API — bez podpisu!)
Podání jde čistě přes REST API Pointu (Bearer token z inline `"bearerToken"` na dashboardu),
**žádný elektronický podpis** — autentizace stačí přes session + token. Tři kroky:
1. **Config** (zjištění období): `GET /api/desk/draft/form65/config`
`periodLimits {from, until}` + `defaultModel.period {month, year}`.
Podává se za **nejnovější dostupné období** (`until` / `defaultModel`), ne za kalendářní
měsíc (ten portál odmítne — HTTP 400 při publish).
2. **Vytvoř koncept**: `POST /api/desk/draft/form65/{partnerId}`
body `{"outputFormat":"Text","period":{"month":M,"year":Y}}``{"draftId":"...","state":"Verified"}`
- `outputFormat:"Text"` = **Datové rozhraní** (NE "Pdf"!)
- partnerId = `3197807` (subjekt MUDr. Buzalková)
3. **Publikuj**: `POST /api/desk/draft/form65/{draftId}/publish` (prázdné tělo)
`{"formId": <id odeslaného podání>}`
Token se čte stejně jako v `StahováníZpráv/111 VZP/stahovanipodani.py`.
### Jak bylo zjištěno
Formulář Form65 je React SPA s custom comboboxem, který nešel proklikat headless ani
naslepo. Odchyceno tak, že uživatel podal jedno podání ručně a do stránky byl vložen
háček ukládající fetch/XHR do `localStorage` (přežije přesměrování) — z toho se vyčetly
přesné endpointy a payloady.
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuVZP.py` | Login + stažení datových dávek z podání |
## Parametry
- **IČP**: 09305001, **IČZ**: 09305000 (MUDr. Michaela Buzalková)
- **Login**: certifikát ve Windows store (sdílený profil `StahováníZpráv/111 VZP/chrome_profile`)
## Stav
Hotovo a otestováno (17.06.2026): login ✓, backfill 23 dávek `F111….0NN` (všechny `H09305001`),
inkrementální běh zastaví na první už stažené dávce ✓, **podání žádosti přes REST API ✓**
(auto období z configu = 04/2026, create+publish → formId). Download i podání plně automatické.
@@ -0,0 +1,322 @@
"""
Stahování seznamu registrovaných pojištěnců VZP (111) — VZP Point (Playwright).
VZP běží na ODLIŠNÉ platformě (point.vzp.cz) — ne portalzp.cz, ne eforms:
- login: certifikát přes Chrome (auto-výběr z Windows store, politika
AutoSelectCertificateForUrls), Playwright. Bez NMSigneru.
- seznam: požaduje se podáním "Seznam registrovaných pojištěnců" s formátem
výstupu "Datové rozhraní". Výsledek = datová dávka III-1.1.2
(soubor F111MMRR.nnn, CP852, hlavička H09305001), stažitelná
z detailu zpracovaného podání.
Tento skript STAHUJE výsledky už zpracovaných podání "Seznam registrovaných
pojištěnců" (datová dávka) do složky SeznamyPojištěnců.
Podání žádosti (NOVÉ PODÁNÍ) zatím dělá uživatel ručně na portálu — viz NOTES.md.
Soubory dávek pak zpracovává Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py.
"""
import json
import os
import re
import sys
import time
import winreg
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
POINT_URL = "https://point.vzp.cz"
DASHBOARD_URL = f"{POINT_URL}/Desk/FormDashboard"
INBOX_URL = f"{POINT_URL}/Inbox/Message"
# Sdílené s VZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "111 VZP"))
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vzp_cookies.json")
DEST_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
CERT_ISSUER_CN = "I.CA Public CA/RSA 06/2022"
# Název podání i přílohy
PODANI_NAZEV = "Seznam registrovaných pojištěnců"
DAVKA_RE = re.compile(r"^F\d{7}\.\d+$") # F111MMRR.nnn
# Podání žádosti (REST API, ověřeno odchytem)
PARTNER_ID = "3197807" # subjekt MUDr. Buzalková (partnerId z formuláře Form65)
OUTPUT_FORMAT = "Text" # "Text" = Datové rozhraní (NE "Pdf"!)
# Období podávané žádosti se zjistí automaticky z configu (nejnovější dostupné, viz
# config.defaultModel / periodLimits.until). Pro ruční přepsání nastav OVERRIDE_OBDOBI
# na (měsíc, rok), jinak ponech None.
OVERRIDE_OBDOBI: tuple[int, int] | None = None
# Kolikrát max. kliknout 'Načíst další' při hledání podání (dashboard míchá typy).
# Stahování se stejně zastaví na první už stažené dávce, takže do minulosti nejde hluboko.
MAX_LOADS = 8
def _set_chrome_cert_policy() -> None:
policy = json.dumps({"pattern": "https://[*.]vzp.cz",
"filter": {"ISSUER": {"CN": CERT_ISSUER_CN}}})
try:
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Policies\Google\Chrome\AutoSelectCertificateForUrls")
winreg.SetValueEx(key, "1", 0, winreg.REG_SZ, policy)
winreg.CloseKey(key)
except Exception as e:
print(f" Varování: nelze nastavit Chrome politiku: {e}")
def _load_cookies(context) -> int:
if not os.path.exists(COOKIES_FILE):
return 0
try:
with open(COOKIES_FILE, encoding="utf-8") as f:
context.add_cookies(json.load(f))
return 1
except Exception:
return 0
def _save_cookies(context) -> None:
try:
vzp = [c for c in context.cookies() if "vzp.cz" in c.get("domain", "")]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(vzp, f, indent=2, ensure_ascii=False)
except Exception:
pass
def prihlaseni(context):
"""Zajistí přihlášení na VZP Point. Vrátí přihlášenou page."""
_load_cookies(context)
page = context.new_page()
page.goto(DASHBOARD_URL, wait_until="domcontentloaded", timeout=30_000)
if page.url.startswith("https://auth.vzp.cz/signin"):
print("Přihlašuji certifikátem...")
cert_btn = page.locator("a, button").filter(has_text=re.compile(r"certifikát", re.I)).first
cert_btn.wait_for(state="visible", timeout=10_000)
cert_btn.click(no_wait_after=True)
try:
page.wait_for_url("https://point.vzp.cz/**", timeout=60_000)
except Exception:
pass
if not page.url.startswith(POINT_URL):
raise RuntimeError(f"Přihlášení selhalo. URL: {page.url}")
print("Přihlášení OK.")
_save_cookies(context)
return page
def _bearer_token(page) -> str:
"""Vytáhne Bearer token z inline <script> na stránce VZP Point."""
scripts = page.evaluate(
"() => Array.from(document.querySelectorAll('script:not([src])')).map(s => s.textContent)"
)
for text in scripts:
m = re.search(r'"bearerToken"\s*:\s*"([^"]+)"', text)
if m:
return m.group(1)
raise RuntimeError("bearerToken nenalezen na stránce")
def zjisti_obdobi(page) -> tuple[int, int]:
"""Vrátí nejnovější dostupné období (měsíc, rok) z configu formuláře Form65."""
token = _bearer_token(page)
cfg = page.evaluate(
"""async (token) => {
const r = await fetch('/api/desk/draft/form65/config',
{headers:{'Authorization':'Bearer '+token, 'Accept':'application/json'}});
return await r.json();
}""",
token,
)
period = (cfg.get("defaultModel") or {}).get("period") \
or (cfg.get("periodLimits") or {}).get("until") or {}
return int(period["month"]), int(period["year"])
def podej_zadost(page, mesic: int, rok: int) -> int | None:
"""Podá žádost 'Seznam registrovaných pojištěnců' (datové rozhraní) za období mesic/rok.
Vytvoří koncept (POST .../form65/{partnerId}) a publikuje ho
(POST .../form65/{draftId}/publish). Vrátí formId odeslaného podání nebo None.
"""
token = _bearer_token(page)
res = page.evaluate(
"""async ({token, partner, fmt, mesic, rok}) => {
const h = {'Authorization':'Bearer '+token,
'Content-Type':'application/json', 'Accept':'application/json'};
const r1 = await fetch('/api/desk/draft/form65/'+partner, {
method:'POST', headers:h,
body: JSON.stringify({outputFormat: fmt, period: {month: mesic, year: rok}})});
let j1=null; try { j1 = await r1.json(); } catch(e){}
if (!r1.ok || !j1 || !j1.draftId)
return {ok:false, step:'create', status:r1.status, body: JSON.stringify(j1)};
const r2 = await fetch('/api/desk/draft/form65/'+j1.draftId+'/publish', {
method:'POST', headers:h});
let j2=null; try { j2 = await r2.json(); } catch(e){}
return {ok: r2.ok, step:'publish', status:r2.status,
formId: j2 && j2.formId, state: j1.state};
}""",
{"token": token, "partner": PARTNER_ID, "fmt": OUTPUT_FORMAT, "mesic": mesic, "rok": rok},
)
if res.get("ok"):
print(f" OK — podání odesláno, formId: {res.get('formId')} (stav konceptu: {res.get('state')})")
return res.get("formId")
print(f" Podání selhalo ({res.get('step')}, HTTP {res.get('status')}): {res.get('body','')[:200]}")
return None
def _seznam_podani_v_dom(page) -> list[dict]:
"""Vrátí podání 'Seznam registrovaných pojištěnců' aktuálně načtená v DOMu (pořadí = nejnovější první)."""
podani = page.evaluate(r"""() => {
return Array.from(document.querySelectorAll('a[href*="/Desk/Form/Detail/"]'))
.map(a => ({ text: (a.innerText || a.title || '').replace(/\s+/g, ' ').trim(),
href: a.getAttribute('href') }))
.filter(x => /Seznam registrovaných pojištěnců/i.test(x.text));
}""")
seen, out = set(), []
for p in podani:
if p["href"] in seen:
continue
seen.add(p["href"])
out.append(p)
return out
def _nacti_dalsi(page) -> bool:
"""Klikne 'Načíst další záznamy'. Vrátí True pokud tlačítko existovalo."""
clicked = page.evaluate("""() => {
const a = Array.from(document.querySelectorAll('a,button'))
.find(e => /Načíst další/i.test(e.innerText || ''));
if (a) { a.scrollIntoView(); a.click(); return true; }
return false;
}""")
if clicked:
page.wait_for_timeout(1500)
return clicked
def stahni_davku_z_podani(page, href: str, already: set) -> tuple[int, bool]:
"""Otevře detail podání a stáhne přiloženou datovou dávku (F...).
Vrátí (počet_stažených, narazil_na_uz_stazenou). Druhý příznak je True, pokud
má podání dávku, kterou už máme v archivu — signál, že jsme dorazili do už
stažené minulosti a stahování lze ukončit.
"""
page.goto(POINT_URL + href, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2000)
fnames = page.evaluate(r"""() => Array.from(document.querySelectorAll('a'))
.map(a => (a.innerText || '').trim())
.filter(t => /^F\d{7}\.\d+$/.test(t))""")
fnames = list(dict.fromkeys(fnames))
downloaded = 0
hit_existing = False
for fname in fnames:
if fname in already or os.path.exists(os.path.join(DEST_DIR, fname)):
print(f" [stop] dávka už stažena: {fname}")
hit_existing = True
continue
link = page.locator("a", has_text=fname).first
try:
with page.expect_download(timeout=30_000) as di:
link.dispatch_event("click")
body = di.value
target = os.path.join(DEST_DIR, fname)
body.save_as(target)
with open(target, "rb") as fh:
head = fh.read(9)
if not head.decode("cp852", errors="ignore").startswith("H09305001"):
print(f" POZOR: {fname} nemá hlavičku H09305001 (přesto uloženo)")
print(f" OK: {fname}")
already.add(fname)
downloaded += 1
time.sleep(0.5)
except Exception as e:
print(f" Chyba při stahování {fname}: {e}")
return downloaded, hit_existing
def hlavni() -> None:
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
sys.exit(1)
os.makedirs(DEST_DIR, exist_ok=True)
_set_chrome_cert_policy()
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
accept_downloads=True,
args=["--force-renderer-accessibility"],
)
try:
page = prihlaseni(context)
already = set(os.listdir(DEST_DIR))
print(f"V archivu: {len(already)} souborů.\n")
# Nasbírej podání 'Seznam...' — ODESLANÁ PODÁNÍ řadí od nejnovějšího.
# Dashboard míchá typy podání, proto je potřeba pár 'Načíst další'.
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2500)
for _ in range(MAX_LOADS):
if not _nacti_dalsi(page):
break
podani = _seznam_podani_v_dom(page)
print(f"Nalezeno podání '{PODANI_NAZEV}': {len(podani)}\n")
# Stahuj od nejnovějšího; jakmile narazíš na už staženou dávku, skonči
# (starší jsou všechny stažené — není třeba jít hlouběji do minulosti).
celkem = 0
for pdn in podani:
print(f"Podání: {pdn['text']}")
dl, hit_existing = stahni_davku_z_podani(page, pdn["href"], already)
celkem += dl
if hit_existing:
print("Dosaženo už stažené dávky — končím (starší jsou stažené).")
break
print(f"\nStaženo nových dávek: {celkem}")
# Podání žádosti o nový výpis (datové rozhraní) za zvolené období.
# Výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se při příštím spuštění.
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2000)
mesic, rok = OVERRIDE_OBDOBI if OVERRIDE_OBDOBI else zjisti_obdobi(page)
print(f"\n=== Podávám žádost za období {mesic:02d}/{rok} ===")
podej_zadost(page, mesic, rok)
print("\nHotovo.")
finally:
_save_cookies(context)
context.close()
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,87 @@
# VoZP (201) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuVoZP.py` provede v jednom spuštění:
1. **Přihlásí se** certifikátem na portál VoZP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/201 VoZP/vozp_cookies.json`
2. **Stáhne nové výpisy** ze schránky `vypis-registrovanych-pacientu-praktickeho-lekare`
— stahuje soubory s hlavičkou `H09305001` (PDF protokoly se přeskočí)
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o aktuální výpis (datové rozhraní)
## Platforma
VoZP běží na stejné platformě jako **ZPŠ, OZP, RBP** (portalzp.cz / json-api).
Login identický, jen `BASE_URL = https://portal.vozp.cz`.
## Schránka a stažení
Schránka má **vlastní URL** (ne `schranky-vypis-pojistencu-v-kapitaci` jako OZP/RBP):
`/app/vypis-registrovanych-pacientu-praktickeho-lekare`
Stažení přílohy: GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datové soubory `f201MMRR.001`, hlavička `H09305001`.
Ve schránce bývá i PDF protokol — header checkem se přeskočí.
## Podání žádosti
Formulář `106-zadost-o-vypis` je **nejjednodušší** — jen IČZ + Třídění, žádné datum ani typ.
Výpis je aktuální snímek registrovaných pacientů. Pro datový soubor se volí třídění = `d`
(Datové rozhraní). Žádný stav.json.
POST `https://portal.vozp.cz/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">
<PolozkaFiltru Nazev="nicoz">-109305000</PolozkaFiltru>
<PolozkaFiltru Nazev="trideni">d</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `nicoz` | `-109305000` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). Pozor: záporné! Ověřeno. |
| `trideni` | `d` | `p`=příjmení, `i`=IČP+příjmení, `r`=rodná čísla, **`d`=Datové rozhraní** (datový soubor) |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP/RBP.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
`ulozit-formular`. První ostré podání: **ref. 179776197** (17.06.2026).
## Srovnání platformy portalzp.cz
| | ZPŠ (209) | OZP (207) | RBP (213) | VoZP (201) |
|--|-----------|-----------|-----------|------------|
| Schránka | schranka-vypis-… | schranky-vypis-… | schranky-vypis-… | vypis-registrovanych-pacientu-… |
| Formulář | 29-… | 108-… | 110-… | 106-… |
| Schránka/filtr | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP |
| IČZ položka | icz=25520 | nicoz=13074913 | icz=933189 | nicoz=-109305000 |
| datum | poslední den měsíce | — | Ke dni (dnešek) | — |
| typ/trideni | razeni+typ=soubor | trideni=p+typ=soubor | razeni+typ=soubor | trideni=d (Datové rozhraní) |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuVoZP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `-109305000`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (3 datové soubory, PDF přeskočeno),
podání ✓ (ref. 179776197). Výpis z prvního podání dorazí do schránky.
@@ -0,0 +1,408 @@
"""
Stahování seznamu registrovaných pojištěnců VoZP (201) — čistý Python, bez NMSigneru.
VoZP běží na stejné platformě jako ZPŠ/OZP/RBP (portalzp.cz / json-api), s rozdíly:
- schránka: /app/vypis-registrovanych-pacientu-praktickeho-lekare
- formulář: 106-zadost-o-vypis
- filtr XML: NazevSchranky = NazevFiltru = "SEZNAM_KAP" (jako OZP)
- položky: nicoz (interní ID = -109305000), trideni (p/i/r/d)
trideni="d" = Datové rozhraní → datový soubor f201MMRR.001
- BEZ pole "datum" a BEZ pole "typ" — výpis je aktuální snímek registrovaných pacientů.
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy ze schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis (datové rozhraní)
Log podání: log_podani.json — seznam { ref_cislo, podano_kdy }
"""
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.vozp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/vypis-registrovanych-pacientu-praktickeho-lekare"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "-109305000" # IČZ 09305000 — interní ID položky "nicoz"
TRIDENI = "d" # p=příjmení, i=IČP+příjmení, r=rodná čísla, d=Datové rozhraní
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s VoZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "201 VoZP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vozp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.vozp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml() -> str:
"""Sestaví XML žádosti o aktuální výpis registrovaných pacientů (datové rozhraní)."""
return (
f'<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">\r\n'
f'<PolozkaFiltru Nazev="nicoz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="trideni">{TRIDENI}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session) -> str | None:
"""Odešle podepsanou žádost o aktuální výpis. Vrátí referenční číslo nebo None."""
xml = build_xml()
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o aktuální výpis
print("=== Podávám žádost o aktuální výpis (datové rozhraní) ===")
ref = odeslat_zadost(session)
if ref:
uloz_log(ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,6 @@
[
{
"ref_cislo": "179776533",
"podano_kdy": "2026-06-17 05:48:36"
}
]
@@ -0,0 +1,96 @@
# OZP (207) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuOZP.py` provede v jednom spuštění čtyři kroky:
1. **Přihlásí se** certifikátem na portál OZP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/207 OZP/ozp_cookies.json`
2. **Stáhne nové výpisy** z výpisové schránky `schranky-vypis-pojistencu-v-kapitaci`
— stahuje soubory, jejichž obsah začíná `H09305001`
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— zastaví se při první již stažené zprávě
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o aktuální výpis (typ=soubor, třídění dle příjmení)
## Platforma
OZP běží na stejné platformě jako **ZPŠ, VoZP, RBP** (portalzp.cz / json-api).
Login je identický se ZPŠ. Liší se URL schránky, ID formuláře a názvy filtru/položek.
## Flow přihlášení (stejné jako ZPŠ)
1. GET `/app/prihlaseni` → session cookie
2. POST `/json-api/prihlaseni/prihlasovaci-zprava` → challenge (`zprava`)
3. Podpis challenge certifikátem (PKCS7/SHA-256, **s** certifikátem)
4. POST `/json-api/prihlaseni/prihlaseni-certifikatem` → autentizovaná session
## Stažení přílohy
GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` se získá z `onclick="SchrPolOpenFile(<id>)"` v řádcích tabulky schránky.
Soubory ve schránce mají název `F207MMRR.xxx` (MM/RR = měsíc/rok generování).
## Podání žádosti (KLÍČOVÝ ROZDÍL oproti ZPŠ)
OZP **nemá pole „datum/měsíc"** — výpis je *aktuální snímek* platných registrací
(„připraveno do příštího dne"). Nepodává se za konkrétní měsíc, nepočítá se „další měsíc".
Při každém běhu se podá jedna žádost o aktuální výpis. Žádný stavový soubor s měsícem.
POST `https://portal.ozp.cz/json-api/formular-schranky/108-vypis-pojistencu-v-registraci/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">
<PolozkaFiltru Nazev="nicoz">13074913</PolozkaFiltru>
<PolozkaFiltru Nazev="trideni">p</PolozkaFiltru>
<PolozkaFiltru Nazev="typ">soubor</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `nicoz` | `13074913` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). Ověřeno: posílá se interní ID, ne číslo IČZ. |
| `trideni` | `p` | `p`=podle příjmení, `i`=IČP+příjmení, `r`=rodná čísla |
| `typ` | `soubor` | `soubor`=datový soubor dle rozhraní, `sestava`=tiskový výstup |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu v podpisu (`NoCerts`) — stejně jako ZPŠ formulář.
Server certifikát v podpisu odmítá.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy formuláře daly názvy
schránky/filtru a položek, odchycený XHR na `ulozit-formular` potvrdil přesný payload.
První ostré podání: **ref. 179774883** (17.06.2026).
## Srovnání se ZPŠ
| | ZPŠ (209) | OZP (207) |
|--|-----------|-----------|
| Schránka URL | `schranka-vypis-…` (jedn.) | `schranky-vypis-…` (množ.) |
| Formulář | `29-vypis-registrov-pojistencu` | `108-vypis-pojistencu-v-registraci` |
| NazevSchranky / NazevFiltru | `VypisPojKap` / `ZZ_VYP_REG` | `SEZNAM_KAP` / `SEZNAM_KAP` |
| Položka IČZ | `icz` = 25520 | `nicoz` = 13074913 (interní ID) |
| Pole datum | ano (za měsíc) | **ne** (aktuální snímek) |
| Stav | `stav.json` (měsíc) | jen `log_podani.json` |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuOZP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `13074913`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (3 výpisy), podání ✓ (ref. 179774883).
Výpis z prvního podání dorazí do schránky do příštího dne.
@@ -0,0 +1,412 @@
"""
Stahování seznamu registrovaných pojištěnců OZP (207) — čistý Python, bez NMSigneru.
OZP běží na stejné platformě jako ZPŠ (portalzp.cz / json-api), ale s rozdíly:
- schránka: /app/schranky-vypis-pojistencu-v-kapitaci (množné "schranky")
- formulář: 108-vypis-pojistencu-v-registraci
- filtr XML: NazevSchranky = NazevFiltru = "SEZNAM_KAP"
- položky: nicoz (IČZ interní ID), trideni (p/i/r), typ (soubor/sestava)
- BEZ pole "datum" — výpis je aktuální snímek platných registrací
("připraveno do příštího dne"), nepodává se za konkrétní měsíc.
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy z výpisové schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis (typ=soubor, třídění dle příjmení)
Log podání: log_podani.json — seznam { ref_cislo, podano_kdy }
"""
import io
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
# UTF-8 výstup i na Windows konzoli (cp1252 by padal na českých znacích)
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.ozp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/108-vypis-pojistencu-v-registraci/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/schranky-vypis-pojistencu-v-kapitaci"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "13074913" # IČZ 09305000 — interní ID položky "nicoz"
TRIDENI = "p" # p = podle příjmení, i = IČP+příjmení, r = rodná čísla
TYP = "soubor" # soubor = datový soubor, sestava = tiskový výstup
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s OZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "207 OZP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "ozp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.ozp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml() -> str:
"""Sestaví XML žádosti o aktuální výpis pojištěnců (bez data — aktuální snímek)."""
return (
f'<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">\r\n'
f'<PolozkaFiltru Nazev="nicoz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="trideni">{TRIDENI}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="typ">{TYP}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session) -> str | None:
"""Odešle podepsanou žádost o aktuální výpis. Vrátí referenční číslo nebo None."""
xml = build_xml()
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o aktuální výpis
print("=== Podávám žádost o aktuální výpis ===")
ref = odeslat_zadost(session)
if ref:
uloz_log(ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,6 @@
[
{
"ref_cislo": "179774959",
"podano_kdy": "2026-06-17 05:21:08"
}
]
@@ -78,5 +78,10 @@
"datum": "30.06.2026", "datum": "30.06.2026",
"ref_cislo": "178258393", "ref_cislo": "178258393",
"podano_kdy": "2026-05-13 21:03:20" "podano_kdy": "2026-05-13 21:03:20"
},
{
"datum": "31.07.2026",
"ref_cislo": "179746549",
"podano_kdy": "2026-06-16 10:21:54"
} }
] ]
@@ -1 +1 @@
{"mesic": 6, "rok": 2026} {"mesic": 7, "rok": 2026}
@@ -0,0 +1,64 @@
# ZPMVČR (211) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuZPMVCR.py` (čistý Python, requests + bs4):
1. **Přihlásí se** PIN + heslem (POST formulář, bez certifikátu / NMSigneru)
2. **Projde stránkovaný přehled** všech registrací pro IČP 09305001
3. **Uloží CSV** do `…\Zúčtovací zprávy\SeznamyPojištěnců\`
## Platforma — ODLIŠNÁ od ostatních
ZPMVČR běží na **eforms.zpmvcr.cz**, NE na portalzp.cz. Žádné certifikáty, žádné schránky,
žádné datové rozhraní .001. Login je PIN + heslo.
## Zásadní rozdíl: NENÍ datový soubor
Ostatní pojišťovny dávají datový soubor (.001 / F-soubor). ZPMVČR **nemá** ekvivalent:
- EP2 sekce (`dokumenty_ke_stazeni/ep2`) je prázdná — *"nebylo stahování dokumentů nastaveno"*.
- Jediný zdroj seznamu je **HTML přehled** na stránce `registrovani_pojistenci`,
který se musí naparsovat → proto výstupem je **CSV**, ne datový soubor.
## Přihlášení
POST `https://eforms.zpmvcr.cz/eforms/ekomunikace`
Pole: `pin` (9023895287), `pin2` (prázdné), `pwd` (heslo).
## Stažení seznamu
POST `https://eforms.zpmvcr.cz/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci`
| Pole | Hodnota | Význam |
|------|---------|--------|
| `icp` | `09305001` | IČP (nebo "Vše") |
| `arztart` | `` (prázdné = Vše) | odbornost D/G/P/S |
| `mesic` / `rok` | aktuální měsíc/rok | období |
| `registrace` | `3` | 1=platné, 2=neplatné, **3=všechny** |
| `tridit` | `1` | 1=příjmení, 2=číslo pojištěnce |
| `vyhledat` | `Vyhledat` | submit |
Výsledek je **stránkovaný** (~20 řádků/strana). Další strany: POST + pole `page=N`.
Řádky v HTML: `<tr class="c1|c2">`, hodnoty za `<span class="responsiveColumn">Label:</span>`.
Hláška "Přehled ... (celkem N)" udává očekávaný počet (kontrola úplnosti).
## CSV výstup
Soubor `YYYY-MM-DD 211 ZPMVČR vsechny registrace.csv`, kódování utf-8-sig (Excel),
oddělovač `;`. Sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do.
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuZPMVCR.py` | Hlavní skript — login + scrape přehledu → CSV |
## Parametry
- **IČP**: 09305001 (MUDr. Michaela Buzalková)
- **Login**: PIN 9023895287 + heslo (v kódu, stejně jako StahováníZpráv/211)
## Stav
Hotovo a otestováno (17.06.2026): login ✓, staženo 172 registrací (9 stran, sedí s "celkem 172"),
CSV uloženo. Volba uživatele: VŠECHNY registrace (registrace=3).
@@ -0,0 +1,174 @@
"""
Stahování seznamu registrovaných pojištěnců ZPMVČR (211) — čistý Python (requests + bs4).
ZPMVČR běží na ODLIŠNÉ platformě (eforms.zpmvcr.cz) — ne portalzp.cz:
- login: PIN + heslo (POST formulář), bez certifikátu a bez NMSigneru
- seznam: NENÍ datový soubor jako u ostatních pojišťoven (EP2 sekce je prázdná).
Jediný zdroj je HTML "Přehled registrací" na stránce registrovani_pojistenci,
který se naparsuje a uloží jako CSV.
Co skript dělá:
1. Přihlásí se (PIN + heslo)
2. Projde stránkovaný přehled VŠECH registrací (platné i neplatné) pro IČP 09305001
3. Uloží výsledek jako CSV do složky SeznamyPojištěnců (sloupce níže)
CSV sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do
"""
import csv
import os
import sys
from datetime import date
import requests
from bs4 import BeautifulSoup
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
# ── Přihlašovací údaje ────────────────────────────────────────────────────────
PIN = "9023895287"
PIN2 = ""
HESLO = "Ax162q8+"
# ─────────────────────────────────────────────────────────────────────────────
BASE_URL = "https://eforms.zpmvcr.cz"
LOGIN_URL = f"{BASE_URL}/eforms/ekomunikace"
SEZNAM_URL = f"{BASE_URL}/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci"
ICP = "09305001" # IČP MUDr. Michaela Buzalková
REGISTRACE = "3" # 1=platné, 2=neplatné, 3=všechny
TRIDIT = "1" # 1=příjmení, 2=číslo pojištěnce
CSV_HLAVICKA = ["Číslo pojištěnce", "Titul", "Příjmení", "Jméno", "Registrace od", "Registrace do"]
DEST_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
def prihlaseni() -> requests.Session:
"""Přihlásí se PIN + heslem, vrátí session."""
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
session.get(LOGIN_URL, timeout=15).raise_for_status()
r = session.post(LOGIN_URL, data={"pin": PIN, "pin2": PIN2, "pwd": HESLO}, timeout=15)
r.raise_for_status()
if 'name="pin"' in r.text and "Přihlásit" in r.text:
raise RuntimeError("Přihlášení selhalo — zkontroluj PIN a heslo")
print("Přihlášení úspěšné!")
return session
def parse_rows(html: str) -> list[list[str]]:
"""Naparsuje řádky přehledu. Vrátí seznam [číslo, titul, příjmení, jméno, reg_od, reg_do]."""
soup = BeautifulSoup(html, "html.parser")
rows = []
for tr in soup.select("tr.c1, tr.c2"):
vals = []
for td in tr.find_all("td"):
for sp in td.select("span.responsiveColumn"):
sp.extract()
vals.append(td.get_text(strip=True))
# platný datový řádek má vyplněné číslo pojištěnce v prvním sloupci
if len(vals) >= 6 and vals[0]:
rows.append(vals[:6])
return rows
def precti_celkem(html: str) -> int | None:
"""Z hlášky 'Přehled ... (celkem N)' získá očekávaný počet."""
import re
m = re.search(r"celkem\s+(\d+)", html)
return int(m.group(1)) if m else None
def stahni_seznam(session: requests.Session) -> list[list[str]]:
"""Projde stránkovaný přehled a vrátí všechny řádky."""
base_data = {
"icp": ICP, "arztart": "",
"mesic": str(date.today().month), "rok": str(date.today().year),
"registrace": REGISTRACE, "tridit": TRIDIT, "vyhledat": "Vyhledat",
}
vsechny: list[list[str]] = []
videno: set = set()
celkem_ocekavano = None
page = 1
while page <= 200:
data = dict(base_data)
if page > 1:
data["page"] = str(page)
r = session.post(SEZNAM_URL, data=data, timeout=30)
r.raise_for_status()
if celkem_ocekavano is None:
celkem_ocekavano = precti_celkem(r.text)
if celkem_ocekavano is not None:
print(f"Přehled hlásí celkem {celkem_ocekavano} registrací.")
rows = parse_rows(r.text)
nove = [row for row in rows if tuple(row) not in videno]
if not nove:
break
for row in nove:
videno.add(tuple(row))
vsechny.extend(nove)
print(f" Strana {page}: +{len(nove)} (celkem {len(vsechny)})")
# poslední strana — méně řádků než plná stránka
if len(rows) < 20:
break
page += 1
if celkem_ocekavano is not None and len(vsechny) != celkem_ocekavano:
print(f" POZOR: staženo {len(vsechny)}, ale přehled hlásil {celkem_ocekavano}.")
return vsechny
def uloz_csv(rows: list[list[str]]) -> str:
"""Uloží řádky jako CSV (Excel-friendly: utf-8-sig, oddělovač ;). Vrátí cestu."""
os.makedirs(DEST_DIR, exist_ok=True)
dnes = date.today().strftime("%Y-%m-%d")
filename = f"{dnes} 211 ZPMVČR vsechny registrace.csv"
path = os.path.join(DEST_DIR, filename)
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f, delimiter=";")
w.writerow(CSV_HLAVICKA)
w.writerows(rows)
return path
def hlavni() -> None:
session = prihlaseni()
print("\n=== Stahování přehledu registrací ===")
rows = stahni_seznam(session)
print(f"Staženo: {len(rows)} registrací.")
if not rows:
print("Žádné registrace — CSV se neuloží.")
return
path = uloz_csv(rows)
print(f"\nHotovo — uloženo: {path}")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,90 @@
# RBP (213) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuRBP.py` provede v jednom spuštění:
1. **Přihlásí se** certifikátem na portál RBP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/213 RBP/rbp_cookies.json`
2. **Stáhne nové výpisy** z výpisové schránky `schranky-vypis-pojistencu-v-kapitaci`
— stahuje soubory, jejichž obsah začíná `H09305001` (textové `odpoved.txt` se přeskočí)
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— zastaví se při první již stažené zprávě
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o výpis ke dnešnímu dni (typ=soubor, třídění dle příjmení)
## Platforma
RBP běží na stejné platformě jako **ZPŠ, OZP, VoZP** (portalzp.cz / json-api).
Login identický se ZPŠ/OZP, jen `BASE_URL = https://portal.rbp-zp.cz`.
## Stažení přílohy
GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datový výpis má hlavičku `H09305001`.
## Podání žádosti
RBP je **hybrid ZPŠ/OZP**: schránka/filtr jako ZPŠ, ale `datum` je „Ke dni" (aktuální
snímek platných registrací k danému dni, default dnešní datum). Nepočítá se měsíc,
žádný stav.json — při každém běhu se podá žádost ke dni `date.today()`.
POST `https://portal.rbp-zp.cz/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">
<PolozkaFiltru Nazev="icz">933189</PolozkaFiltru>
<PolozkaFiltru Nazev="datum">17.06.2026</PolozkaFiltru>
<PolozkaFiltru Nazev="razeni">jmeno</PolozkaFiltru>
<PolozkaFiltru Nazev="typ">soubor</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `icz` | `933189` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). |
| `datum` | `DD.MM.YYYY` | „Ke dni" — den, ke kterému chceme snímek (použijeme dnešek). |
| `razeni` | `jmeno` | `jmeno`=příjmení a jména, `rc`=rodná čísla |
| `typ` | `soubor` | `soubor`=datový soubor netříděno, `sestava`=PDF |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
`ulozit-formular`. Skrytý input datumu vypadal jako JWT, ale odchycený XML potvrdil
prostý formát `DD.MM.YYYY`. První ostré podání: **ref. 179775430** (17.06.2026).
## Srovnání
| | ZPŠ (209) | OZP (207) | RBP (213) |
|--|-----------|-----------|-----------|
| NazevSchranky | `VypisPojKap` | `SEZNAM_KAP` | `VypisPojKap` |
| NazevFiltru | `ZZ_VYP_REG` | `SEZNAM_KAP` | `ZZ_VYP_REG` |
| Formulář | `29-…` | `108-…` | `110-…` |
| Položka IČZ | `icz`=25520 | `nicoz`=13074913 | `icz`=933189 |
| Pole datum | ano (poslední den měsíce) | ne | ano (Ke dni, dnešek) |
| razeni / typ | jmeno / soubor | trideni=p / typ=soubor | jmeno / soubor |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuRBP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `933189`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (odpoved.txt správně přeskočeny),
podání ✓ (ref. 179775430). Výpis z prvního podání dorazí do schránky do příštího dne
— při dalším spuštění ověřit, že hlavička `H09305001` u RBP datového souboru sedí.
@@ -0,0 +1,415 @@
"""
Stahování seznamu registrovaných pojištěnců RBP (213) — čistý Python, bez NMSigneru.
RBP běží na stejné platformě jako ZPŠ/OZP/VoZP (portalzp.cz / json-api).
- schránka: /app/schranky-vypis-pojistencu-v-kapitaci
- formulář: 110-vypis-pojistencu-reg-u-pzs
- filtr XML: NazevSchranky="VypisPojKap", NazevFiltru="ZZ_VYP_REG" (jako ZPŠ)
- položky: icz (interní ID), datum (Ke dni), razeni (jmeno/rc), typ (soubor/sestava)
- datum = "Ke dni" aktuální snímek platných registrací — použijeme dnešní datum,
nepočítá se měsíc, žádný stav.json (jako OZP).
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy z výpisové schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis ke dnešnímu dni
Log podání: log_podani.json — seznam { ref_cislo, datum, podano_kdy }
"""
import json
import os
import re
import sys
import time
from datetime import date, datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
# UTF-8 výstup i na Windows konzoli
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.rbp-zp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/schranky-vypis-pojistencu-v-kapitaci"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "933189" # IČZ 09305000 — interní ID položky "icz"
RAZENI = "jmeno" # jmeno = příjmení a jména, rc = rodná čísla
TYP = "soubor" # soubor = datový soubor, sestava = PDF sestava
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s RBP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "213 RBP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "rbp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.rbp-zp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml(datum: date) -> str:
"""Sestaví XML žádosti o výpis pojištěnců ke dni `datum`."""
datum_str = datum.strftime("%d.%m.%Y")
return (
f'<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">\r\n'
f'<PolozkaFiltru Nazev="icz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="datum">{datum_str}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="razeni">{RAZENI}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="typ">{TYP}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session, datum: date) -> str | None:
"""Odešle podepsanou žádost o výpis ke dni `datum`. Vrátí referenční číslo nebo None."""
xml = build_xml(datum)
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(datum: date, ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"datum": datum.strftime("%d.%m.%Y"),
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o výpis ke dnešnímu dni
datum = date.today()
print(f"=== Podávám žádost o výpis ke dni {datum.strftime('%d.%m.%Y')} ===")
ref = odeslat_zadost(session, datum)
if ref:
uloz_log(datum, ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,7 @@
[
{
"ref_cislo": "179775825",
"datum": "15.05.2026",
"podano_kdy": "2026-06-17 05:40:28"
}
]
@@ -1,7 +1,7 @@
[ [
{ {
"name": "SID", "name": "SID",
"value": "323fa186a7c38b49f8f40e6798f019a1", "value": "786ed43afb46b3c7432371f7f2ee282e",
"domain": ".portal.ozp.cz", "domain": ".portal.ozp.cz",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT", "value": "CERT",
"domain": ".portal.ozp.cz", "domain": ".portal.ozp.cz",
"path": "/", "path": "/",
"expires": 1808541892, "expires": 1813202467,
"secure": true, "secure": true,
"httpOnly": false, "httpOnly": false,
"sameSite": "Lax" "sameSite": "Lax"
@@ -1,7 +1,7 @@
[ [
{ {
"name": "SID", "name": "SID",
"value": "0589c59247aa8fa221c380eec74c9cef", "value": "1be176fa462a5f32ad908b07b0b380ac",
"domain": ".portal.zpskoda.cz", "domain": ".portal.zpskoda.cz",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT", "value": "CERT",
"domain": ".portal.zpskoda.cz", "domain": ".portal.zpskoda.cz",
"path": "/", "path": "/",
"expires": 1810234998, "expires": 1813134113,
"secure": true, "secure": true,
"httpOnly": false, "httpOnly": false,
"sameSite": "Lax" "sameSite": "Lax"
@@ -1,7 +1,7 @@
[ [
{ {
"name": "SID", "name": "SID",
"value": "01bb61e3cd536ffbf7c4f2b74260466e", "value": "22319828cc5b7600290e217c8f533ca0",
"domain": ".portal.rbp-zp.cz", "domain": ".portal.rbp-zp.cz",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT", "value": "CERT",
"domain": ".portal.rbp-zp.cz", "domain": ".portal.rbp-zp.cz",
"path": "/", "path": "/",
"expires": 1808541922, "expires": 1813203627,
"secure": true, "secure": true,
"httpOnly": false, "httpOnly": false,
"sameSite": "Lax" "sameSite": "Lax"
+77 -5
View File
@@ -2,29 +2,101 @@
# Připojení k Firebird databázi Medicus (medicus.fdb). Volí DSN podle názvu počítače. # Připojení k Firebird databázi Medicus (medicus.fdb). Volí DSN podle názvu počítače.
# Obsahuje třídu MedicusDB s metodami pro dotazy na pacienty, registrace a faktury. # Obsahuje třídu MedicusDB s metodami pro dotazy na pacienty, registrace a faktury.
import os
import socket import socket
import fdb import fdb
def get_medicus_connection(): def get_medicus_connection():
""" """
Připojí se k Firebird medicus.fdb podle názvu počítače. Připojí se k Firebird medicus.fdb. DSN se vybere takto:
Vrátí fdb.Connection nebo vyhodí RuntimeError pro neznámý počítač. 1) env MEDICUS_FDB_DSN (má přednost — nutné v dockeru, kde hostname = ID kontejneru),
2) podle názvu počítače (dsn_map),
3) default.
Vrátí fdb.Connection nebo vyhodí RuntimeError.
""" """
computer_name = socket.gethostname().upper() computer_name = socket.gethostname().upper()
dsn_map = { dsn_map = {
"LEKAR": r"localhost:M:\medicus\data\medicus.fdb", "LEKAR": r"localhost:M:\medicus\data\medicus.fdb",
"SESTRA": r"192.168.1.10:m:\medicus\data\medicus.fdb", "SESTRA": r"192.168.1.10:m:\medicus\data\medicus.fdb",
"LENOVO": r"192.168.1.10:m:\medicus\data\medicus.fdb", "LENOVO": r"192.168.1.10:m:\medicus\data\medicus.fdb",
"NTBVBHP470G10": r"reporter:c:\medicus\medicus.fdb", "NTBVBHP470G10": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14
"Z230": r"reporter:c:\medicus\medicus.fdb", "Z230": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14
"TOWER": r"192.168.1.76:/firebird/data/medicus.fdb", # Firebird 2.5 docker kontejner na toweru
} }
dsn = dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb") dsn = (os.environ.get("MEDICUS_FDB_DSN")
or dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb"))
import sys import sys
print(f"[medicus_db] Pripojuji se jako {computer_name} -> {dsn}", file=sys.stderr, flush=True) print(f"[medicus_db] Pripojuji se jako {computer_name} -> {dsn}", file=sys.stderr, flush=True)
return fdb.connect(dsn=dsn, user="SYSDBA", password="masterkey", charset="win1250") return fdb.connect(dsn=dsn, user="SYSDBA", password="masterkey", charset="win1250")
class ReconnectingConnection:
"""Obal nad fdb.Connection, který se sám znovu připojí, když spojení umře.
Dlouho běžící procesy (např. MCP server) drží jedno spojení od startu.
To se rozpadne, když:
* se notebook uspí/hibernuje (TCP socket odumře), nebo
* na serveru proběhne denní gbak restore (Firebird zabije stará spojení).
Výsledkem je `SQLCODE -902 Error writing data to the connection`.
Tato třída ověří před každým použitím, že spojení žije, a když ne,
tiše ho znovu naváže. Volající kód používá .cursor()/.commit() beze změny.
"""
def __init__(self, connect_fn=get_medicus_connection):
self._connect = connect_fn
self._conn = None
def _alive(self):
if self._conn is None:
return False
try:
cur = self._conn.cursor()
cur.execute("SELECT 1 FROM RDB$DATABASE")
cur.fetchone()
return True
except Exception:
return False
def _ensure(self):
if not self._alive():
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
self._conn = None
self._conn = self._connect()
return self._conn
def cursor(self):
return self._ensure().cursor()
def commit(self):
return self._ensure().commit()
def rollback(self):
if self._conn is not None:
return self._conn.rollback()
def close(self):
if self._conn is not None:
try:
self._conn.close()
finally:
self._conn = None
def __getattr__(self, name):
# ostatní atributy/metody proxy na živé spojení
return getattr(self._ensure(), name)
def get_medicus_connection_reconnecting():
"""Vrátí spojení s automatickým reconnectem (vhodné pro dlouho běžící procesy)."""
return ReconnectingConnection(get_medicus_connection)
def get_medicus_db(): def get_medicus_db():
"""Vrátí MedicusDB instanci s připojením podle názvu počítače.""" """Vrátí MedicusDB instanci s připojením podle názvu počítače."""
conn = get_medicus_connection() conn = get_medicus_connection()
+6 -2
View File
@@ -4,8 +4,12 @@ import pymysql
def _print(msg): def _print(msg):
print(msg, file=sys.stdout, flush=True) if sys.stdout.encoding and sys.stdout.encoding.lower() in ("utf-8", "utf8") \ # Diagnostika jde na stderr — stdout je u MCP serverů vyhrazen pro JSON-RPC.
else print(msg.encode("utf-8", errors="replace").decode("ascii", errors="replace"), flush=True) if sys.stderr.encoding and sys.stderr.encoding.lower() in ("utf-8", "utf8"):
print(msg, file=sys.stderr, flush=True)
else:
print(msg.encode("utf-8", errors="replace").decode("ascii", errors="replace"),
file=sys.stderr, flush=True)
_LOCAL_HOSTS = {"lekar", "sestra", "lenovo"} _LOCAL_HOSTS = {"lekar", "sestra", "lenovo"}
+184
View File
@@ -0,0 +1,184 @@
"""
telegram_notify.py
------------------
Notifikace a obousměrná komunikace přes Telegram Bot API
(bot ClaudeBot @Vlado_Claude_Bot).
Token a výchozí chat_id se načítají z `Medevio/.env`:
TELEGRAM_BOT_TOKEN=123456789:AAE...
TELEGRAM_CHAT_ID=6639316354
Použití ze skriptu:
from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram
posli_telegram("Pipeline 08 hotová, 142 záznamů")
odpoved = zeptej_se_telegram("Mám reimportovat i archiv? (ano/ne)")
if odpoved and odpoved.strip().lower() == "ano":
...
Použití z příkazové řádky:
python -m Knihovny.telegram_notify "Hotovo"
python -m Knihovny.telegram_notify --ask "Pokracovat? (ano/ne)"
POZN.: getUpdates smí v jednu chvíli pollovat jen JEDEN proces. Pokud běží
víc skriptů naráz, které čekají na odpověď, kradou si navzájem zprávy —
v praxi se ptá vždy jen jeden agent.
"""
import os
import sys
import time
from pathlib import Path
import requests
# =========================
# Načtení .env (Medevio/.env)
# =========================
def _load_env():
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
API_BASE = "https://api.telegram.org/bot{token}/{method}"
def _token() -> str:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN v Medevio/.env")
return token
def _resolve_chat_id(chat_id: str | None) -> str:
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
if not chat_id:
raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v Medevio/.env)")
return str(chat_id)
def _call(method: str, *, http_timeout: int = 15, **params):
"""Zavolá Telegram Bot API metodu a vrátí pole `result`."""
url = API_BASE.format(token=_token(), method=method)
r = requests.post(url, json=params, timeout=http_timeout)
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}")
return data["result"]
def posli_telegram(
text: str,
*,
chat_id: str | None = None,
parse_mode: str | None = None,
disable_notification: bool = False,
) -> dict:
"""
Pošle zprávu přes Telegram bota.
:param text: text zprávy (max 4096 znaků)
:param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID
:param parse_mode: None | "Markdown" | "MarkdownV2" | "HTML"
:param disable_notification: True = tichá zpráva (bez upozornění)
:return: odeslaná zpráva (dict z Telegram API)
"""
params = {
"chat_id": _resolve_chat_id(chat_id),
"text": text,
"disable_notification": disable_notification,
}
if parse_mode:
params["parse_mode"] = parse_mode
return _call("sendMessage", **params)
def zeptej_se_telegram(
otazka: str,
*,
chat_id: str | None = None,
timeout: int = 300,
poll_timeout: int = 30,
parse_mode: str | None = None,
) -> str | None:
"""
Pošle otázku a BLOKUJÍCÍ čeká na textovou odpověď uživatele.
Zahodí starší zprávy a bere jen tu, která přijde PO odeslání otázky.
:param otazka: text otázky
:param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID
:param timeout: celkové čekání na odpověď v sekundách (pak vrátí None)
:param poll_timeout: délka jednoho long-poll cyklu v sekundách
:param parse_mode: formátování otázky (None | "HTML" | "Markdown")
:return: text odpovědi, nebo None když nikdo neodpoví do timeoutu
"""
cid = _resolve_chat_id(chat_id)
# Zjisti poslední update_id, ať bereme jen NOVÉ zprávy po otázce.
existujici = _call("getUpdates", http_timeout=15)
offset = (existujici[-1]["update_id"] + 1) if existujici else 0
posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
zbyva = int(deadline - time.monotonic())
if zbyva <= 0:
break
lp = max(1, min(poll_timeout, zbyva))
updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp)
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message") or {}
if str(msg.get("chat", {}).get("id")) != cid:
continue # zpráva z jiného chatu — ignoruj
text = msg.get("text")
if text:
return text
return None
def _safe_print(text: str):
"""Výpis odolný vůči kódování Windows konzole (cp1252)."""
try:
print(text)
except UnicodeEncodeError:
print(text.encode("ascii", "replace").decode("ascii"))
if __name__ == "__main__":
# Ať projdou i diakritika/emoji na Windows konzoli.
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
args = sys.argv[1:]
if not args:
print('Použití:')
print(' python -m Knihovny.telegram_notify "text zprávy"')
print(' python -m Knihovny.telegram_notify --ask "otázka?"')
sys.exit(1)
if args[0] == "--ask":
otazka = " ".join(args[1:]) or "?"
odpoved = zeptej_se_telegram(otazka, timeout=240)
if odpoved is None:
_safe_print("(bez odpovědi — vypršel timeout)")
sys.exit(2)
_safe_print(odpoved)
else:
posli_telegram(" ".join(args))
_safe_print("Odesláno OK")
+302
View File
@@ -0,0 +1,302 @@
"""
telegram_user.py
----------------
Ovládání PLNOHODNOTNÉHO Telegram účtu (ne bota) přes user API (MTProto / Telethon).
Na rozdíl od bota umí napsat komukoli a unese VÍCE souběžných agentů na jednom účtu
(jako Telegram otevřený zároveň na PC, tabletu i mobilu).
⚠️ Jedná JMÉNEM přihlášeného účtu. Session soubor = plný přístup k účtu.
⚠️ Nové účty na automatizaci Telegram rychle banuje (zvlášť VoIP čísla — použij reálnou SIM).
────────────────────────────────────────────────────────────────────────
VÍCE AGENTŮ NA JEDNOM ÚČTU
────────────────────────────────────────────────────────────────────────
- api_id/api_hash se SDÍLÍ (identifikují „aplikaci", ne zařízení).
- Každý agent musí mít VLASTNÍ session soubor (= vlastní autorizace / „zařízení").
Sdílet jednu session mezi procesy NELZE (database is locked / AUTH_KEY_DUPLICATED).
→ každý agent se přihlásí zvlášť: `login --jako <jmeno>` (jeden SMS kód na agenta).
- Všechny sessions vidí stejný chat, proto se odpovědi směrují přes Telegram **Reply**:
agent pošle označenou otázku a bere jen tu odpověď, která je Reply na *jeho* zprávu
(shoda `reply_to_msg_id`). Tím se odpovědi více agentů nepomíchají.
Konfigurace v `Medevio/.env` (api_id/api_hash z https://my.telegram.org):
TELEGRAM_API_ID=1234567
TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789
TELEGRAM_PHONE=+420... # nepovinné (jinak se zeptá při loginu)
Session soubory: `Medevio/agent_telegram_<jmeno>.session` (gitignored).
────────────────────────────────────────────────────────────────────────
CLI
────────────────────────────────────────────────────────────────────────
Jednorázové přihlášení agenta (spusť ve svém terminálu — čeká na kód z SMS):
python -m Knihovny.telegram_user login --jako recepty
python -m Knihovny.telegram_user login --jako kalendar
Poslání zprávy ("me" = Uložené zprávy / Saved Messages):
python -m Knihovny.telegram_user send me "Test" --jako recepty
Otázka + čekání na Reply odpověď (vypíše odpověď na stdout):
python -m Knihovny.telegram_user ask recepty "Mam pokracovat? (ano/ne)"
────────────────────────────────────────────────────────────────────────
ZE SKRIPTU
────────────────────────────────────────────────────────────────────────
from Knihovny.telegram_user import posli_jako_ja, zeptej_se_jako
posli_jako_ja("me", "Pipeline 08 hotová", session="recepty")
odp = zeptej_se_jako("recepty", "Našel jsem 3 sporné záznamy. Pokračovat?")
if odp and odp.strip().lower() == "ano":
...
"""
import argparse
import os
import sys
import time
from pathlib import Path
# telethon.sync zpřístupní metody synchronně (bez async/await)
from telethon.sync import TelegramClient
from telethon.errors import SessionPasswordNeededError, PhoneNumberUnoccupiedError
def _load_env():
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
def _api_id() -> int:
val = os.environ.get("TELEGRAM_API_ID")
if not val:
raise RuntimeError("Chybí TELEGRAM_API_ID v Medevio/.env (z https://my.telegram.org)")
return int(val)
def _api_hash() -> str:
val = os.environ.get("TELEGRAM_API_HASH")
if not val:
raise RuntimeError("Chybí TELEGRAM_API_HASH v Medevio/.env (z https://my.telegram.org)")
return val
def _session_path(jmeno: str | None) -> Path:
base = f"agent_telegram_{jmeno}" if jmeno else "agent_telegram"
return Path(__file__).resolve().parent.parent / "Medevio" / base
def _new_client(session: str | None = None) -> TelegramClient:
return TelegramClient(str(_session_path(session)), _api_id(), _api_hash())
def prihlas(session: str | None = None) -> None:
"""
Jednorázové přihlášení dané session. Interaktivně se zeptá na kód z SMS
a případně na heslo dvoufázového ověření. Vytvoří session soubor.
SPOUŠTĚJ V TERMINÁLU (potřebuje input).
"""
client = _new_client(session)
client.start(phone=os.environ.get("TELEGRAM_PHONE") or (lambda: input("Telefon (+420...): ")))
me = client.get_me()
print(f"Session '{session or 'default'}' přihlášena jako "
f"{me.first_name or ''} (@{me.username}) id={me.id}")
client.disconnect()
def _phone() -> str:
val = os.environ.get("TELEGRAM_PHONE")
if not val:
raise RuntimeError("Chybí TELEGRAM_PHONE v Medevio/.env")
return val
def login_posli_kod(session: str | None = None) -> None:
"""
1. krok přihlášení (řízeného na dálku): vyžádá si od Telegramu kód.
Vytiskne `PHONE_CODE_HASH=...`, který je potřeba pro 2. krok.
"""
client = _new_client(session)
client.connect()
try:
sent = client.send_code_request(_phone())
print("PHONE_CODE_HASH=" + sent.phone_code_hash)
finally:
client.disconnect()
def login_dokonci(code, phone_code_hash: str, session: str | None = None,
password: str | None = None) -> None:
"""
2. krok přihlášení: dokončí login zadaným kódem (a případně heslem 2FA).
Při úspěchu uloží session soubor.
"""
client = _new_client(session)
client.connect()
try:
try:
client.sign_in(phone=_phone(), code=str(code), phone_code_hash=phone_code_hash)
except SessionPasswordNeededError:
if not password:
print("NEED_PASSWORD")
return
client.sign_in(password=password)
except PhoneNumberUnoccupiedError:
print("UCET_NEEXISTUJE - nejdriv zaregistruj cislo v aplikaci Telegram")
return
me = client.get_me()
print(f"OK prihlaseno jako {me.first_name or ''} (@{me.username}) id={me.id}")
finally:
client.disconnect()
def posli_jako_ja(komu, text: str, *, session: str | None = None):
"""
Pošle zprávu jménem přihlášeného účtu z dané session.
:param komu: "me" (Saved Messages) | "@username" | telefon | int id
:param text: text zprávy
:param session: jméno session (které přihlášení použít)
:return: odeslaná zpráva (Telethon Message)
"""
with _new_client(session) as client:
if not client.is_user_authorized():
raise RuntimeError(
f"Session '{session or 'default'}' není přihlášena — "
f"spusť: python -m Knihovny.telegram_user login"
+ (f" --jako {session}" if session else "")
)
return client.send_message(komu, text)
def precti_zpravy(komu, limit: int = 10, *, session: str | None = None):
"""
Vrátí posledních `limit` zpráv z daného chatu.
:return: list dictů {"id", "text", "odeslal_ja", "reply_na", "datum"}
"""
out = []
with _new_client(session) as client:
if not client.is_user_authorized():
raise RuntimeError(f"Session '{session or 'default'}' není přihlášena.")
for msg in client.iter_messages(komu, limit=limit):
out.append({
"id": msg.id,
"text": msg.text or "",
"odeslal_ja": bool(msg.out),
"reply_na": msg.reply_to_msg_id,
"datum": msg.date,
})
return out
def zeptej_se_jako(
agent: str,
otazka: str,
*,
komu="me",
session: str | None = None,
timeout: int = 300,
poll_interval: int = 3,
vyzaduj_reply: bool = True,
) -> str | None:
"""
Pošle označenou otázku ("[agent] otázka") a BLOKUJÍCÍ čeká na odpověď.
Při více agentech naráz se odpovědi rozlišují přes Telegram **Reply**:
bere jen tu příchozí zprávu, která je Reply na právě odeslanou otázku.
:param agent: jméno agenta (objeví se v textu otázky jako štítek)
:param otazka: text otázky
:param komu: kam poslat ("me" = Saved Messages | "@username" | id)
:param session: jméno session; výchozí = `agent` (každý agent svůj soubor)
:param timeout: celkové čekání v sekundách (pak vrátí None)
:param poll_interval: jak často kontrolovat nové zprávy (s)
:param vyzaduj_reply: True = bere jen Reply na svou otázku (bezpečné pro víc agentů);
False = vezme první příchozí zprávu (jen pro 1 agenta)
:return: text odpovědi, nebo None při timeoutu
"""
session = session or agent
with _new_client(session) as client:
if not client.is_user_authorized():
raise RuntimeError(
f"Session '{session}' není přihlášena — "
f"spusť: python -m Knihovny.telegram_user login --jako {session}"
)
sent = client.send_message(komu, f"[{agent}] {otazka}")
qid = sent.id
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
# jen zprávy novější než naše otázka, od nejstarší
for msg in client.iter_messages(komu, min_id=qid, reverse=True):
if msg.out:
continue # naše vlastní zpráva
if vyzaduj_reply:
if msg.reply_to_msg_id == qid:
return msg.text
else:
return msg.text
zbyva = deadline - time.monotonic()
if zbyva <= 0:
break
time.sleep(min(poll_interval, max(1, zbyva)))
return None
def _safe_print(text: str):
try:
print(text)
except UnicodeEncodeError:
print(text.encode("ascii", "replace").decode("ascii"))
def _main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(prog="telegram_user", description="Telegram user účet (Telethon)")
sub = parser.add_subparsers(dest="cmd", required=True)
p_login = sub.add_parser("login", help="jednorázové přihlášení session")
p_login.add_argument("--jako", dest="jako", default=None, help="jméno session/agenta")
p_send = sub.add_parser("send", help="poslat zprávu")
p_send.add_argument("komu", help='"me" | "@username" | telefon | id')
p_send.add_argument("text", help="text zprávy")
p_send.add_argument("--jako", dest="jako", default=None, help="jméno session")
p_ask = sub.add_parser("ask", help="poslat otázku a počkat na Reply odpověď")
p_ask.add_argument("agent", help="jméno agenta (štítek + výchozí session)")
p_ask.add_argument("text", help="text otázky")
p_ask.add_argument("--komu", dest="komu", default="me", help='kam (výchozí "me")')
p_ask.add_argument("--timeout", dest="timeout", type=int, default=240, help="čekání v s")
args = parser.parse_args()
if args.cmd == "login":
prihlas(args.jako)
elif args.cmd == "send":
posli_jako_ja(args.komu, args.text, session=args.jako)
_safe_print("Odesláno OK")
elif args.cmd == "ask":
odp = zeptej_se_jako(args.agent, args.text, komu=args.komu, timeout=args.timeout)
if odp is None:
_safe_print("(bez odpovědi — vypršel timeout)")
sys.exit(2)
_safe_print(odp)
if __name__ == "__main__":
_main()
+5 -4
View File
@@ -3,6 +3,7 @@
from requests_pkcs12 import Pkcs12Adapter from requests_pkcs12 import Pkcs12Adapter
import requests import requests
import sys
import uuid import uuid
from datetime import date from datetime import date
@@ -108,14 +109,14 @@ class VZPB2BClient:
headers = {"Content-Type": "text/xml; charset=utf-8"} headers = {"Content-Type": "text/xml; charset=utf-8"}
print(f"Calling: {endpoint}") print(f"Calling: {endpoint}", file=sys.stderr, flush=True)
response = self.session.post( response = self.session.post(
endpoint, endpoint,
data=soap.encode("utf-8"), data=soap.encode("utf-8"),
headers=headers, headers=headers,
timeout=30 timeout=30
) )
print("HTTP:", response.status_code) print("HTTP:", response.status_code, file=sys.stderr, flush=True)
return response.text return response.text
def stav_pojisteni(self, rc: str, k_datu: str = None, prijmeni: str = None): def stav_pojisteni(self, rc: str, k_datu: str = None, prijmeni: str = None):
@@ -156,10 +157,10 @@ class VZPB2BClient:
"SOAPAction": "process" "SOAPAction": "process"
} }
print(f"Calling: {endpoint}") print(f"Calling: {endpoint}", file=sys.stderr, flush=True)
resp = self.session.post(endpoint, data=soap.encode("utf-8"), resp = self.session.post(endpoint, data=soap.encode("utf-8"),
headers=headers, timeout=30) headers=headers, timeout=30)
print("HTTP:", resp.status_code) print("HTTP:", resp.status_code, file=sys.stderr, flush=True)
return resp.text return resp.text
def registrace_lekare(self, rc: str, k_datu: str = None, def registrace_lekare(self, rc: str, k_datu: str = None,
+77
View File
@@ -0,0 +1,77 @@
"""
MailStore Server API Explorer
Připojí se k API, zjistí konfiguraci a vypíše klíčové info.
Spusť: python explore_api.py
"""
import requests
import json
import base64
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ── Konfigurace ───────────────────────────────────────────────
HOST = "https://192.168.1.53:8463" # nebo https://mailstore.buzalka.cz pokud funguje
USER = "admin"
PASS = "*$N(B)vMUym!%"
# ─────────────────────────────────────────────────────────────
session = requests.Session()
session.auth = (USER, PASS)
session.verify = False
session.headers.update({"Content-Type": "application/x-www-form-urlencoded"})
def call(fn, **params):
r = session.post(f"{HOST}/api/invoke/{fn}", data=params or {})
r.raise_for_status()
data = r.json()
if data.get("error"):
raise Exception(data["error"]["message"])
return data.get("result")
def pp(label, data):
print(f"\n{'='*60}")
print(f" {label}")
print('='*60)
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
if __name__ == "__main__":
print("Připojuji se k MailStore API...")
# 1. Server info
info = call("GetServerInfo")
pp("Server Info", info)
# 2. Všechny dostupné API funkce
r = session.post(f"{HOST}/api/get-metadata")
metadata = r.json()
fn_names = [f["name"] for f in metadata.get("functions", [])]
pp("Dostupné API funkce", fn_names)
# 3. Uživatelé
users = call("GetUsers")
pp("Uživatelé", users)
# 4. Archive stores (úložiště)
stores = call("GetStoreInfos")
pp("Archive Stores (úložiště)", stores)
# 5. Archivační profily
try:
profiles = call("GetProfiles")
pp("Archivační profily", profiles)
except Exception as e:
print(f"\nProfily: {e}")
# 6. Složky (mailboxes) pro admin uživatele
try:
folders = call("GetFolderStatistics")
pp("Folder statistiky", folders)
except Exception as e:
print(f"\nFolder stats: {e}")
print("\n\nHotovo. Zkopíruj výstup výše a pošli mi ho.")
+20
View File
@@ -1 +1,21 @@
ANTHROPIC_API_KEY=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 ANTHROPIC_API_KEY=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
CENTRAL_LOG_TOKEN=b1e95b3ca9b64769d14bb80370a07882958cac95a0eb9d7758933f151a053c08
CENTRAL_LOG_GATEWAY=http://192.168.1.76:8770
# Telegram bot (ClaudeBot @Vlado_Claude_Bot) — notifikace o průběhu
TELEGRAM_BOT_TOKEN=8821687113:AAF9U9S989ZJ0OG2St3o8CyHUSKg7RqyYVM
TELEGRAM_CHAT_ID=6639316354
# Telegram USER účet (Telethon) — plnohodnotný účet agenta
# api_id/api_hash z https://my.telegram.org (přihlas se číslem nového účtu)
TELEGRAM_API_ID=39599696
TELEGRAM_API_HASH=f93ed362cdbfb4f5df85072a0350a8fc
TELEGRAM_PHONE=+420705920457
# PostgreSQL (ordinace) — pro DASTA loader
PG_HOST=192.168.1.76
PG_PORT=5432
PG_USER=vladimir.buzalka
PG_PASSWORD=Vlado7309208104++
PG_DB=ordinace
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Jednorázový skript: znovu stáhne přílohy pro 1c935d36-c9df-46a1-9ef2-b7f327f376c7 (Šmídová).
Přeskočí přílohy, které jsou již v medevio_downloads. Po úspěchu označí požadavek jako zpracovaný.
"""
import requests
import pymysql
import time
from pathlib import Path
TARGET_REQUEST_ID = "1c935d36-c9df-46a1-9ef2-b7f327f376c7"
RETRY_ATTEMPTS = 5
RETRY_DELAY = 3 # sekund mezi pokusy
TOKEN_PATH = Path(__file__).resolve().parent.parent / "token.txt"
DB_CONFIG = {
"host": "192.168.1.76",
"port": 3306,
"user": "root",
"password": "Vlado9674+",
"database": "medevio",
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
}
GRAPHQL_QUERY = r"""
query ClinicRequestDetail_GetPatientRequest2($requestId: UUID!) {
patientRequestMedicalRecords: listMedicalRecordsForPatientRequest(
attachmentTypes: [ECRF_FILL_ATTACHMENT, MESSAGE_ATTACHMENT, PATIENT_REQUEST_ATTACHMENT]
patientRequestId: $requestId
pageInfo: {first: 100, offset: 0}
) {
attachmentType
id
medicalRecord {
contentType
description
downloadUrl
id
url
visibleToPatient
}
}
}
"""
def extract_filename_from_url(url: str) -> str:
try:
return url.split("/")[-1].split("?")[0]
except Exception:
return "unknown_filename"
def read_token(p: Path) -> str:
tok = p.read_text(encoding="utf-8").strip()
return tok.split(" ", 1)[1] if tok.startswith("Bearer ") else tok
def download_with_retry(url: str, attempts: int, delay: int) -> bytes:
for attempt in range(1, attempts + 1):
try:
r = requests.get(url, timeout=60)
if r.status_code == 200:
return r.content
print(f" ⚠️ HTTP {r.status_code}, pokus {attempt}/{attempts}")
except Exception as e:
print(f" ⚠️ Chyba stahování (pokus {attempt}/{attempts}): {e}")
if attempt < attempts:
time.sleep(delay)
raise RuntimeError(f"Stahování selhalo po {attempts} pokusech: {url}")
def main():
token = read_token(TOKEN_PATH)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
conn = pymysql.connect(**DB_CONFIG)
# Načíst již stažené attachment_id pro tento požadavek
with conn.cursor() as cur:
cur.execute("SELECT attachment_id FROM medevio_downloads WHERE request_id = %s", (TARGET_REQUEST_ID,))
existing_ids = {row["attachment_id"] for row in cur.fetchall()}
print(f"🔍 Zpracovávám požadavek {TARGET_REQUEST_ID}")
print(f" Již staženo příloh: {len(existing_ids)}")
# GraphQL dotaz
payload = {
"operationName": "ClinicRequestDetail_GetPatientRequest2",
"query": GRAPHQL_QUERY,
"variables": {"requestId": TARGET_REQUEST_ID},
}
r = requests.post("https://api.medevio.cz/graphql", json=payload, headers=headers, timeout=30)
attachments = r.json().get("data", {}).get("patientRequestMedicalRecords", [])
print(f" Nalezeno příloh celkem: {len(attachments)}")
# Načíst createdAt pro INSERT
with conn.cursor() as cur:
cur.execute("SELECT createdAt FROM pozadavky WHERE id = %s", (TARGET_REQUEST_ID,))
row = cur.fetchone()
created_date = row["createdAt"] if row else None
saved = 0
skipped = 0
errors = 0
with conn.cursor() as cur:
for a in attachments:
m = a.get("medicalRecord") or {}
att_id = a.get("id")
if att_id in existing_ids:
print(f" ⏭️ Přeskočeno (již existuje): {att_id}")
skipped += 1
continue
url = m.get("downloadUrl")
if not url:
print(f" ⚠️ Příloha {att_id} nemá downloadUrl, přeskakuji")
skipped += 1
continue
try:
content = download_with_retry(url, RETRY_ATTEMPTS, RETRY_DELAY)
filename = extract_filename_from_url(url)
cur.execute("""
INSERT INTO medevio_downloads (
request_id, attachment_id, attachment_type,
filename, content_type, file_size,
created_at, file_content
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
""", (TARGET_REQUEST_ID, att_id, a.get("attachmentType"), filename,
m.get("contentType"), len(content), created_date, content))
existing_ids.add(att_id)
print(f" 💾 Uloženo: {filename} ({len(content) / 1024:.1f} kB)")
saved += 1
time.sleep(0.5)
except Exception as e:
print(f" ❌ Chyba: {e}")
errors += 1
conn.commit()
if errors == 0:
with conn.cursor() as cur:
cur.execute("UPDATE pozadavky SET attachmentsProcessed = NOW() WHERE id = %s", (TARGET_REQUEST_ID,))
conn.commit()
print(f"\n✅ Hotovo. Uloženo: {saved}, přeskočeno: {skipped}. Požadavek označen jako zpracovaný.")
else:
print(f"\n⚠️ Hotovo s chybami. Uloženo: {saved}, přeskočeno: {skipped}, chyby: {errors}.")
print(" Požadavek NEBYL označen jako zpracovaný (opakujte po kontrole).")
conn.close()
if __name__ == "__main__":
main()
+32 -30
View File
@@ -2,46 +2,48 @@
Agent pro zpracování naskenovaných lékařských zpráv (PDF i JPG/PNG). Agent pro zpracování naskenovaných lékařských zpráv (PDF i JPG/PNG).
## Skripty ## Hlavní skript
### `extract_patient_info.py` — hlavní agent ### `Extract_pacient_info_v1.0.py`
Spuštění: `python extract_patient_info.py` (bez argumentů = celá složka ToProcess) Verze: 1.0 | Datum: 2026-06-02 | Autor: Vladimír Bužalka
Spuštění: `python Extract_pacient_info_v1.0.py` (bez argumentů = celá složka KeZpracování)
**Workflow:** **Workflow:**
1. Načte soubory z `ToProcess/` 1. Načte soubory z `KeZpracování/`
2. Claude Vision API (sonnet-4-6) extrahuje: jméno, RČ, datum, typ dokumentu, poznámku, navržený název, rotaci 2. Claude Vision API (sonnet-4-6) — 1. volání s obrázkem: extrahuje jméno, RČ, datum, typ dokumentu, poznámku, navržený název, rotaci
3. Ověří pacienta v Medicus Firebird (tabulka KAR, pole RODCIS/PRIJMENI/JMENO) 3. Claude API — 2. volání (jen text): vygeneruje 5 variant názvu dle naming_rules.md, deduplikuje vůči 1. návrhu
4. Fuzzy matching RČ při nenalezení: vynechání cifry + záměna podobných (0↔8, 1↔7, 5↔6, 3↔8) + checksum /11 4. Ověří pacienta v Medicus Firebird (tabulka KAR); fuzzy matching RČ + fallback na jméno
- Fallback: pokud RČ stále nenalezeno, vyhledá dle příjmení+jméno (z Claude) — status `by_name` / `by_name_multi` 5. Zkontroluje duplicity v archivu `Dokumentace_zpracovaná/`
5. Upozorní na duplicitu v `U:\Dropbox\Ordinace\Dokumentace_zpracovaná\` 6. Zobrazí hlavní viewer (tkinter, celá šířka monitoru):
6. Interaktivní schválení / oprava názvu - Horní část: náhled originálu | náhled vybrané duplicity | seznam duplicit (Text widget s wrappingem)
7. JPG/PNG → skutečné PDF (správná orientace, DPI=150, quality=80) - Spodní panel: info o pacientovi | textbox pro název (multiline, bílý) | návrhy pojmenování
8. Přesun do `Processed/`, smazání z `ToProcess/` 7. Po schválení názvu nabídne 5 kompresních variant (300/200/150/120/96 DPI) k výběru
9. Opravy názvů se ukládají do `corrections.json` jako few-shot příklady 8. Uloží vybranou variantu do `Zpracováno/`, smaže originál
9. Opravy názvů se ukládají do `corrections.json` jako few-shot příklady (jen pokud se liší od všech navržených variant)
**Formát názvu souboru:** **EKG větev:** PDFCreator metadata → rotace o 90° → Tesseract OCR → Medicus ověření
`{RČ} {YYYY-MM-DD} {Příjmení}, {Jméno} [{typ dokumentu}] [{poznámka}].pdf`
Příklady typů: `LZ chirurgie`, `LZ kardiologie`, `Laboratoř`, `CT břicha`, `kolonoskopie`, `poukaz FT` **Formát názvu souboru:** viz `naming_rules.md`
### `jpg_to_pdf.py` — konverze obrázku na PDF ## Konfigurační soubory
```
python jpg_to_pdf.py soubor.jpg [vystup.pdf] [rotace_ccw] | Soubor | Účel |
``` |---|---|
- Opravuje EXIF orientaci | `naming_rules.md` | Pravidla pro pojmenování — předávají se Claudovi v každém volání |
- Rotace: 0 / 90 / 180 / 270 (CCW) | `corrections.json` | Few-shot příklady korekcí názvů z minulých běhů |
- A4, DPI=150, quality=80, bez okrajů | `layout_settings.json` | Pozice a rozměry oken podle hostname počítače |
- Používá se i interně z `extract_patient_info.py`
## Složky ## Složky
| Složka | Účel | | Složka | Účel |
|---|---| |---|---|
| `ToProcess/` | Sem se házejí nové skeny (PDF, JPG, PNG) | | `KeZpracování/` | Vstupní skeny (PDF, JPG, PNG) |
| `Processed/` | Správně pojmenované PDF po schválení | | `Zpracováno/` | Správně pojmenované PDF po schválení |
| `U:\Dropbox\Ordinace\Dokumentace_zpracovaná\` | Finální archiv | | `Dokumentace_zpracovaná/` | Finální archiv (Dropbox) — prohledává se kvůli duplicitám |
| `Testy/` | Archiv starších verzí skriptů |
## Konfigurace ## Konfigurace
- API klíč: `U:\Medevio\.env``ANTHROPIC_API_KEY` - API klíč: `Medevio/.env``ANTHROPIC_API_KEY`
- Medicus: `localhost:c:\medicus 3\data\medicus.fdb` (Firebird, SYSDBA) - Medicus Firebird: `reporter:c:\medicus\medicus.fdb` (SYSDBA)
- Few-shot korekce: `corrections.json` - Layout oken: `layout_settings.json` — klíč = hostname.upper() (aktuálně: `Z230`)
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,291 @@
"""
Kombinovaný viewer: náhled originálu + náhled duplicit + listbox duplicit.
Nahrazuje preview_viewer.py pro hlavní dokument.
Spouští se jako subprocess z extract_patient_info_novy_test.py.
Argumenty: duplicity_viewer.py <json_soubor> [--write-geometry=<cesta>]
JSON vstup: {
"original": "cesta/k/originalu.pdf", # povinné
"duplicity": ["cesta1.pdf", ...], # volitelné, může být prázdné
"labels": ["název1.pdf", ...] # zobrazované názvy v listboxu
}
Výstup geometrie (spodní hrana) do --write-geometry souboru pro rename_dialog.
"""
import json
import sys
from pathlib import Path
import tkinter as tk
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def _open_doc(path_str: str):
"""Otevře PDF nebo obrázek, vrátí (fitz.Document nebo None, PIL.Image nebo None)."""
import fitz
from PIL import Image
p = Path(path_str)
if not p.exists():
return None, None
suffix = p.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
return None, Image.open(p)
return fitz.open(str(p)), None
def _render_page(doc, pil_img, page_n: int, max_w: int, max_h: int):
"""Vykreslí stránku, vrátí PIL.Image."""
import fitz
from PIL import Image
if doc is not None:
page = doc[page_n]
zoom = min(max_w / page.rect.width, max_h / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
elif pil_img is not None:
img = pil_img.copy()
img.thumbnail((max_w, max_h))
return img
return None
class PdfPane:
"""Jeden panel s náhledem PDF + navigací stránek."""
def __init__(self, parent, max_w: int, max_h: int, bg: str = "#222"):
self.max_w = max_w
self.max_h = max_h
self.doc = None
self.pil_img = None
self.page_n = 0
self.page_count = 1
self.photo_ref = None
self.frame = tk.Frame(parent, bg=bg, width=max_w)
self.frame.pack_propagate(False)
self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa",
font=("Segoe UI", 9), wraplength=max_w - 10)
self.lbl_title.pack(pady=(4, 2))
self.lbl_img = tk.Label(self.frame, bg=bg)
self.lbl_img.pack(fill="both", expand=True)
frame_nav = tk.Frame(self.frame, bg=bg)
frame_nav.pack(pady=4)
self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#ccc",
font=("Segoe UI", 9))
self.lbl_page.pack(side="left", padx=6)
self.btn_prev = tk.Button(frame_nav, text="", bg="#444", fg="#fff",
relief="flat", padx=6,
command=self._prev)
self.btn_prev.pack(side="left", padx=2)
self.btn_next = tk.Button(frame_nav, text="", bg="#444", fg="#fff",
relief="flat", padx=6,
command=self._next)
self.btn_next.pack(side="left", padx=2)
def load(self, path_str: str, title: str = ""):
from PIL import ImageTk
if self.doc:
try:
self.doc.close()
except Exception:
pass
self.doc, self.pil_img = _open_doc(path_str)
self.page_count = len(self.doc) if self.doc else 1
self.lbl_title.config(text=title or Path(path_str).name, fg="#ddd")
self._show(0)
def clear(self, msg: str = ""):
if self.doc:
try:
self.doc.close()
except Exception:
pass
self.doc = None
self.pil_img = None
self.lbl_img.config(image="")
self.lbl_title.config(text=msg, fg="#666")
self.lbl_page.config(text="")
self.btn_prev.config(state="disabled")
self.btn_next.config(state="disabled")
self.photo_ref = None
def _show(self, n: int):
from PIL import ImageTk
self.page_n = n
img = _render_page(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60)
if img:
self.photo_ref = ImageTk.PhotoImage(img)
self.lbl_img.config(image=self.photo_ref)
self.lbl_page.config(text=f"{n + 1} / {self.page_count}" if self.page_count > 1 else "")
self.btn_prev.config(state="normal" if n > 0 else "disabled")
self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled")
def _prev(self):
if self.page_n > 0:
self._show(self.page_n - 1)
def _next(self):
if self.page_n < self.page_count - 1:
self._show(self.page_n + 1)
def close(self):
if self.doc:
try:
self.doc.close()
except Exception:
pass
def main():
if len(sys.argv) < 2:
sys.exit(1)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
original_path = data.get("original") or ""
duplicity_paths = data.get("duplicity") or []
labels = data.get("labels") or [Path(p).name for p in duplicity_paths]
write_geom = None
for arg in sys.argv:
if arg.startswith("--write-geometry="):
write_geom = Path(arg.split("=", 1)[1])
try:
from PIL import Image, ImageTk
import fitz
except ImportError as e:
print(f"[duplicity_viewer] Chybí knihovna: {e}", file=sys.stderr)
sys.exit(2)
# ── Layout z JSON ─────────────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).parent))
try:
from window_layout import get_layout
layout = get_layout()
lw = layout.get("duplicity_viewer") or {}
except Exception:
lw = {}
WIN_X = lw.get("x", 0)
WIN_Y = lw.get("y", 0)
WIN_W = lw.get("w", 2200)
WIN_H = lw.get("h", 1080)
LISTBOX_W = 380
PANE_W = (WIN_W - LISTBOX_W - 20) // 2
PANE_H = WIN_H - 40
# ── Okno ──────────────────────────────────────────────────────────────────
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
root.title("Náhled dokumentů")
root.configure(bg="#1a1a1a")
root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}")
# ── Tři sloupce ───────────────────────────────────────────────────────────
# Vlevo: originál
pane_orig = PdfPane(root, max_w=PANE_W, max_h=PANE_H, bg="#1e1e2e")
pane_orig.frame.pack(side="left", fill="both", expand=False, padx=(6, 3), pady=6)
# Uprostřed: duplicita
pane_dup = PdfPane(root, max_w=PANE_W, max_h=PANE_H, bg="#2e1e1e")
pane_dup.frame.pack(side="left", fill="both", expand=False, padx=(3, 3), pady=6)
# Vpravo: listbox
frame_right = tk.Frame(root, bg="#1a1a1a", width=LISTBOX_W)
frame_right.pack(side="left", fill="y", padx=(3, 6), pady=6)
frame_right.pack_propagate(False)
tk.Label(frame_right, text="Existující dokumenty:", anchor="w",
bg="#1a1a1a", fg="#cc4444", font=("Segoe UI", 9, "bold")).pack(
fill="x", padx=4, pady=(8, 2))
sb = tk.Scrollbar(frame_right, orient="vertical")
lb = tk.Listbox(
frame_right,
yscrollcommand=sb.set,
font=("Segoe UI", 8),
selectmode="single",
activestyle="dotbox",
bg="#2a1a1a",
fg="#ddd",
selectbackground="#cc4444",
selectforeground="#fff",
cursor="hand2",
)
sb.config(command=lb.yview)
sb.pack(side="right", fill="y")
lb.pack(side="left", fill="both", expand=True, padx=(4, 0))
if not duplicity_paths:
lb.insert(tk.END, "(žádné duplicity)")
lb.config(state="disabled")
else:
for label in labels:
lb.insert(tk.END, Path(label).name if Path(label).exists() else label)
# ── Načti originál ────────────────────────────────────────────────────────
if original_path and Path(original_path).exists():
pane_orig.load(original_path, title=Path(original_path).name)
else:
pane_orig.clear(msg="(originál nedostupný)")
pane_dup.clear(msg="← vyberte duplicitu vlevo" if duplicity_paths else "(žádné duplicity)")
# ── Výběr duplicity ───────────────────────────────────────────────────────
def on_select(event):
sel = lb.curselection()
if not sel:
return
idx = sel[0]
p = duplicity_paths[idx]
pane_dup.load(p, title=labels[idx] if idx < len(labels) else Path(p).name)
lb.bind("<<ListboxSelect>>", on_select)
# Automaticky vyber první duplicitu
if duplicity_paths:
lb.selection_set(0)
lb.event_generate("<<ListboxSelect>>")
# ── Zavření ───────────────────────────────────────────────────────────────
def on_close():
pane_orig.close()
pane_dup.close()
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
# ── Geometrie pro rename_dialog ───────────────────────────────────────────
root.update_idletasks()
if write_geom:
_y = root.winfo_y()
_h = root.winfo_height()
write_geom.write_text(
json.dumps({"x": WIN_X, "y": _y, "w": WIN_W, "h": _h}),
encoding="utf-8"
)
root.lift()
root.attributes("-topmost", True)
root.after(1500, lambda: root.attributes("-topmost", False))
root.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1,462 @@
"""
Hlavní okno pro zpracování naskenovaných dokumentů.
Kombinuje náhled originálu, náhled duplicit, listbox duplicit a rename panel.
Spouští se jako subprocess z extract_patient_info_novy_test.py.
Argumenty: main_viewer.py <json_soubor>
JSON vstup: {
"original": "cesta/k/originalu.pdf",
"duplicity": ["cesta1.pdf", ...], # plné cesty
"labels": ["název1.pdf", ...], # zobrazované názvy
"nazev": "navrzeny_nazev.pdf",
"info_lines": ["✓ Medicus: ...", ...],
"varianty": ["varianta1.pdf", ...]
}
JSON výstup (stdout): { "value": "schvaleny nazev" } nebo { "value": null }
"""
import json
import sys
from pathlib import Path
import tkinter as tk
from tkinter import font as tkfont
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
# ── PDF rendering ─────────────────────────────────────────────────────────────
def _open_doc(path_str: str):
import fitz
from PIL import Image
p = Path(path_str)
if not p.exists():
return None, None
suffix = p.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
return None, Image.open(p)
return fitz.open(str(p)), None
def _render(doc, pil_img, page_n: int, max_w: int, max_h: int):
import fitz
from PIL import Image
if doc is not None:
page = doc[page_n]
zoom = min(max_w / page.rect.width, max_h / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
elif pil_img is not None:
img = pil_img.copy()
img.thumbnail((max_w, max_h))
return img
return None
class PdfPane:
def __init__(self, parent, max_w: int, max_h: int, bg: str = "#1e1e2e", label_text: str = ""):
self.max_w = max_w
self.max_h = max_h
self.doc = None
self.pil_img = None
self.page_n = 0
self.page_count = 1
self.photo_ref = None
self.frame = tk.Frame(parent, bg=bg, width=max_w, height=max_h)
self.frame.pack_propagate(False)
if label_text:
tk.Label(self.frame, text=label_text, bg=bg, fg="#888",
font=("Segoe UI", 8, "bold")).pack(pady=(2, 0))
self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa",
font=("Segoe UI", 8), wraplength=max_w - 10)
self.lbl_title.pack(pady=(2, 0))
self.lbl_img = tk.Label(self.frame, bg=bg)
self.lbl_img.pack(fill="both", expand=True)
frame_nav = tk.Frame(self.frame, bg=bg)
frame_nav.pack(pady=2)
self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#aaa", font=("Segoe UI", 8))
self.lbl_page.pack(side="left", padx=4)
self.btn_prev = tk.Button(frame_nav, text="", bg="#333", fg="#fff",
relief="flat", padx=4, font=("Segoe UI", 8),
command=self._prev)
self.btn_prev.pack(side="left", padx=1)
self.btn_next = tk.Button(frame_nav, text="", bg="#333", fg="#fff",
relief="flat", padx=4, font=("Segoe UI", 8),
command=self._next)
self.btn_next.pack(side="left", padx=1)
def load(self, path_str: str, title: str = ""):
from PIL import ImageTk
if self.doc:
try: self.doc.close()
except Exception: pass
self.doc, self.pil_img = _open_doc(path_str)
self.page_count = len(self.doc) if self.doc else 1
self.lbl_title.config(text=title or Path(path_str).name, fg="#ccc")
self._show(0)
def clear(self, msg: str = ""):
if self.doc:
try: self.doc.close()
except Exception: pass
self.doc = None
self.pil_img = None
self.lbl_img.config(image="")
self.lbl_title.config(text=msg, fg="#555")
self.lbl_page.config(text="")
self.btn_prev.config(state="disabled")
self.btn_next.config(state="disabled")
self.photo_ref = None
def _show(self, n: int):
from PIL import ImageTk
self.page_n = n
img = _render(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60)
if img:
self.photo_ref = ImageTk.PhotoImage(img)
self.lbl_img.config(image=self.photo_ref)
self.lbl_page.config(text=f"{n+1} / {self.page_count}" if self.page_count > 1 else "")
self.btn_prev.config(state="normal" if n > 0 else "disabled")
self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled")
def _prev(self):
if self.page_n > 0: self._show(self.page_n - 1)
def _next(self):
if self.page_n < self.page_count - 1: self._show(self.page_n + 1)
def close(self):
if self.doc:
try: self.doc.close()
except Exception: pass
# ── Pomocné funkce pro layout ─────────────────────────────────────────────────
def _get_layout() -> dict:
"""Načte layout oken pro aktuální hostname z layout_settings.json."""
import json as _json, socket as _socket
settings_file = Path(__file__).parent / "layout_settings.json"
hostname = _socket.gethostname().upper()
_default = {"duplicity_viewer": None}
if not settings_file.exists():
return _default
try:
settings = _json.loads(settings_file.read_text(encoding="utf-8"))
return settings.get(hostname, _default)
except Exception:
return _default
# ── Hlavní funkce ─────────────────────────────────────────────────────────────
def show(
original_path: str = "",
duplicity_paths: list = None,
labels: list = None,
nazev: str = "",
info_lines: list = None,
varianty: list = None,
) -> str | None:
"""
Zobrazí hlavní viewer přímo (bez subprocesů).
Vrátí schválený název souboru (bez .pdf) nebo None.
"""
duplicity_paths = duplicity_paths or []
labels = labels or [Path(p).name for p in duplicity_paths]
info_lines = info_lines or []
varianty = varianty or []
result = {"value": None}
try:
from PIL import Image, ImageTk
import fitz
except ImportError as e:
print(f"[main_viewer] Chybí knihovna: {e}", file=sys.stderr)
return None
# ── Layout ────────────────────────────────────────────────────────────────
try:
lw = _get_layout().get("duplicity_viewer") or {}
except Exception:
lw = {}
WIN_X = lw.get("x", 0)
WIN_Y = lw.get("y", 0)
WIN_W = lw.get("w", 3840)
WIN_H = lw.get("h", 1700)
BOTTOM_H = 260 # výška spodního panelu
TOP_H = WIN_H - BOTTOM_H - 8
GAP = 4 # mezera mezi náhledy (zmenšená)
LISTBOX_W = 560 # širší listbox duplicit
PANE_W = (WIN_W - LISTBOX_W - GAP - 20) // 2
BG = "#1a1a1a" # jednotné tmavé pozadí pro celé okno
COL_INFO = int(WIN_W * 0.15)
COL_MID = int(WIN_W * 0.45)
COL_VAR = WIN_W - COL_INFO - COL_MID
# ── Okno ──────────────────────────────────────────────────────────────────
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
root.title("Zpracování dokumentu")
root.configure(bg=BG)
root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}")
root.resizable(True, True)
# ═══════════════════════════════════════════════════════════════════════════
# HORNÍ ČÁST — náhledy + listbox
# ═══════════════════════════════════════════════════════════════════════════
frame_top = tk.Frame(root, bg=BG, height=TOP_H)
frame_top.pack(side="top", fill="x", expand=False)
frame_top.pack_propagate(False)
# Náhled originálu
pane_orig = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H,
bg=BG, label_text="ORIGINÁL")
pane_orig.frame.pack(side="left", fill="y", padx=(6, GAP), pady=4)
# Náhled duplicity
pane_dup = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H,
bg=BG, label_text="DUPLICITA")
pane_dup.frame.pack(side="left", fill="y", padx=(GAP, GAP), pady=4)
# Listbox duplicit vpravo
frame_lb = tk.Frame(frame_top, bg=BG, width=LISTBOX_W)
frame_lb.pack(side="left", fill="y", padx=(3, 6), pady=4)
frame_lb.pack_propagate(False)
tk.Label(frame_lb, text="Existující dokumenty:", anchor="w",
bg=BG, fg="#cc4444", font=("Segoe UI", 9, "bold")).pack(
fill="x", padx=6, pady=(8, 2))
sb_dup = tk.Scrollbar(frame_lb, orient="vertical")
txt_dup = tk.Text(
frame_lb, yscrollcommand=sb_dup.set,
font=("Segoe UI", 9), bg=BG, fg="#ddd",
bd=0, highlightthickness=0, relief="flat",
wrap="word", cursor="hand2", state="normal",
selectbackground="#ffffff", selectforeground="#000000",
)
sb_dup.config(command=txt_dup.yview)
sb_dup.pack(side="right", fill="y")
txt_dup.pack(side="left", fill="both", expand=True, padx=(6, 0))
# Tag pro výběr (zvýraznění vybraného řádku)
txt_dup.tag_config("selected", background="#ffffff", foreground="#000000")
txt_dup.tag_config("normal", background=BG, foreground="#ddd")
def _shorten_dup_label(name: str) -> str:
import re as _re
m = _re.match(r"\d{9,10}\s+(\d{4}-\d{2}-\d{2})\s+[^[]+(\[.+)", name)
if m:
return f"{m.group(1)} {m.group(2)}"
return name
dup_line_indices = [] # (line_start, line_end) pro každou duplicitu
if not duplicity_paths:
txt_dup.insert("end", "(žádné duplicity)")
txt_dup.config(state="disabled")
else:
for i, label in enumerate(labels):
short = _shorten_dup_label(Path(label).name if not Path(label).exists() else label)
line_start = txt_dup.index("end")
txt_dup.insert("end", short + "\n\n")
line_end = txt_dup.index("end")
dup_line_indices.append((line_start, line_end))
txt_dup.tag_add("normal", line_start, line_end)
txt_dup.config(state="disabled")
selected_dup = [None]
def _dup_click(event):
txt_dup.config(state="normal")
idx = txt_dup.index(f"@{event.x},{event.y}")
for i, (ls, le) in enumerate(dup_line_indices):
if txt_dup.compare(ls, "<=", idx) and txt_dup.compare(idx, "<", le):
# Odznač předchozí
if selected_dup[0] is not None:
prev_ls, prev_le = dup_line_indices[selected_dup[0]]
txt_dup.tag_remove("selected", prev_ls, prev_le)
txt_dup.tag_add("normal", prev_ls, prev_le)
# Označ nový
txt_dup.tag_remove("normal", ls, le)
txt_dup.tag_add("selected", ls, le)
selected_dup[0] = i
txt_dup.config(state="disabled")
# Načti PDF
if i < len(duplicity_paths):
pane_dup.load(duplicity_paths[i],
title=labels[i] if i < len(labels) else "")
return
txt_dup.config(state="disabled")
txt_dup.bind("<Button-1>", _dup_click)
# Oddělovač
tk.Frame(root, bg="#333", height=2).pack(fill="x")
# ═══════════════════════════════════════════════════════════════════════════
# SPODNÍ ČÁST — info | entry+tlačítka | návrhy
# ═══════════════════════════════════════════════════════════════════════════
frame_bot = tk.Frame(root, bg=BG, height=BOTTOM_H)
frame_bot.pack(side="top", fill="both", expand=True)
frame_bot.pack_propagate(False)
# ── Vlevo: info o pacientovi (15%) ────────────────────────────────────────
frame_info = tk.Frame(frame_bot, bg=BG, width=COL_INFO)
frame_info.pack(side="left", fill="y", padx=(10, 4), pady=8)
frame_info.pack_propagate(False)
tk.Label(frame_info, text="Informace o pacientovi", anchor="w",
bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x")
for line in info_lines:
color = "#cc4444" if line.startswith("") or line.startswith("") else \
"#44cc44" if line.startswith("") else "#aaa"
tk.Label(frame_info, text=line, anchor="w", bg=BG,
fg=color, font=("Segoe UI", 9), wraplength=COL_INFO - 20,
justify="left").pack(fill="x", pady=1)
# ── Uprostřed: název + tlačítka (45%) ────────────────────────────────────
frame_mid = tk.Frame(frame_bot, bg=BG, width=COL_MID)
frame_mid.pack(side="left", fill="y", padx=(4, 20), pady=8)
frame_mid.pack_propagate(False)
tk.Label(frame_mid, text="Název souboru (bez .pdf):", anchor="w",
bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x")
nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev
# Multiline Text widget — zalamuje dlouhé názvy
txt = tk.Text(frame_mid, font=("Segoe UI", 10), bg="#ffffff", fg="#000000",
insertbackground="#000000", relief="flat", height=3,
wrap="word", padx=6, pady=4, bd=0, highlightthickness=0)
txt.pack(fill="x", pady=(4, 0))
txt.insert("1.0", nazev_bez)
txt.mark_set("insert", "end")
txt.focus_set()
def get_txt_value() -> str:
return txt.get("1.0", "end").strip()
frame_btn = tk.Frame(frame_mid, bg=BG)
frame_btn.pack(pady=(6, 0))
def schvalit(event=None):
result["value"] = get_txt_value()
pane_orig.close()
pane_dup.close()
root.destroy()
def preskocit(event=None):
result["value"] = None
pane_orig.close()
pane_dup.close()
root.destroy()
tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit,
bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"),
padx=16, pady=6, relief="flat").pack(side="left", padx=8)
tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit,
bg="#7a2a2a", fg="white", font=("Segoe UI", 10),
padx=16, pady=6, relief="flat").pack(side="left", padx=8)
# Enter schvalí jen pokud není focus v Text widgetu (tam Enter = nový řádek)
root.bind("<Escape>", preskocit)
# Ctrl+Enter vždy schválí
root.bind("<Control-Return>", schvalit)
# ── Vpravo: návrhy od Claudea (40%) ──────────────────────────────────────
frame_right = tk.Frame(frame_bot, bg=BG, width=COL_VAR)
frame_right.pack(side="left", fill="y", padx=(4, 10), pady=8)
frame_right.pack_propagate(False)
tk.Label(frame_right, text="Návrhy pojmenování (kliknutím vyberte):", anchor="w",
bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x")
sb_var = tk.Scrollbar(frame_right, orient="vertical")
lb_var = tk.Listbox(
frame_right, yscrollcommand=sb_var.set,
font=("Segoe UI", 10), selectmode="single", activestyle="none",
bg=BG, fg="#ddd", bd=0, highlightthickness=0,
selectbackground="#ffffff", selectforeground="#000000",
cursor="hand2",
)
sb_var.config(command=lb_var.yview)
sb_var.pack(side="right", fill="y")
lb_var.pack(side="left", fill="both", expand=True)
for v in varianty:
v_bez = v[:-4] if v.endswith(".pdf") else v
lb_var.insert(tk.END, v_bez)
def on_varianta(event):
sel = lb_var.curselection()
if sel:
txt.delete("1.0", "end")
txt.insert("1.0", lb_var.get(sel[0]))
lb_var.bind("<<ListboxSelect>>", on_varianta)
# ═══════════════════════════════════════════════════════════════════════════
# Načtení dokumentů
# ═══════════════════════════════════════════════════════════════════════════
if original_path and Path(original_path).exists():
pane_orig.load(original_path, title=Path(original_path).name)
else:
pane_orig.clear(msg="(originál nedostupný)")
pane_dup.clear(msg="← vyberte duplicitu" if duplicity_paths else "(žádné duplicity)")
# Automaticky zobraz první duplicitu
if duplicity_paths:
pane_dup.load(duplicity_paths[0], title=labels[0] if labels else "")
if dup_line_indices:
txt_dup.config(state="normal")
ls, le = dup_line_indices[0]
txt_dup.tag_remove("normal", ls, le)
txt_dup.tag_add("selected", ls, le)
selected_dup[0] = 0
txt_dup.config(state="disabled")
# ── Zobrazení ─────────────────────────────────────────────────────────────
root.protocol("WM_DELETE_WINDOW", preskocit)
root.lift()
root.attributes("-topmost", True)
root.after(1500, lambda: root.attributes("-topmost", False))
root.mainloop()
return result["value"]
if __name__ == "__main__":
# Subprocess mód — čte data z JSON souboru předaného jako argument
if len(sys.argv) < 2:
print(json.dumps({"value": None}))
sys.exit(0)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
value = show(
original_path = data.get("original") or "",
duplicity_paths = data.get("duplicity") or [],
labels = data.get("labels") or [],
nazev = data.get("nazev") or "",
info_lines = data.get("info_lines") or [],
varianty = data.get("varianty") or [],
)
print(json.dumps({"value": value}, ensure_ascii=False))
@@ -0,0 +1,22 @@
"""
Test skript — zobrazí počet monitorů, jejich rozlišení a který je primární.
"""
import sys
try:
from screeninfo import get_monitors
monitors = get_monitors()
print(f"Počet monitorů: {len(monitors)}\n")
for i, m in enumerate(monitors):
primary = " ← PRIMÁRNÍ" if getattr(m, "is_primary", False) else ""
print(f" Monitor {i+1}: {m.width}x{m.height} | pozice x={m.x}, y={m.y}{primary} | název: {getattr(m, 'name', '?')}")
except ImportError:
print("Knihovna 'screeninfo' není nainstalována — instaluji...")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "screeninfo", "--break-system-packages", "-q"])
from screeninfo import get_monitors
monitors = get_monitors()
print(f"Počet monitorů: {len(monitors)}\n")
for i, m in enumerate(monitors):
primary = " ← PRIMÁRNÍ" if getattr(m, "is_primary", False) else ""
print(f" Monitor {i+1}: {m.width}x{m.height} | pozice x={m.x}, y={m.y}{primary} | název: {getattr(m, 'name', '?')}")
@@ -33,6 +33,19 @@ def main():
root.tk.call("encoding", "system", "utf-8") root.tk.call("encoding", "system", "utf-8")
sh = root.winfo_screenheight() sh = root.winfo_screenheight()
# Zjisti cílovou šířku z layoutu (pokud existuje), jinak výchozí 700px
try:
import sys as _sys_tmp
_sys_tmp.path.insert(0, str(Path(__file__).parent))
from window_layout import get_layout as _get_layout
_lw = _get_layout().get("preview_viewer")
RENDER_W = (_lw.get("w", 700) - 20) if _lw else 700
RENDER_H = (_lw.get("h", sh) - 80) if _lw else (sh - 150)
except Exception:
RENDER_W = 700
RENDER_H = sh - 150
page_count = len(doc) if doc else 1 page_count = len(doc) if doc else 1
current = [0] current = [0]
photo_ref = [None] photo_ref = [None]
@@ -40,12 +53,12 @@ def main():
def render(n) -> Image.Image: def render(n) -> Image.Image:
if doc is not None: if doc is not None:
page = doc[n] page = doc[n]
zoom = min(700 / page.rect.width, (sh - 150) / page.rect.height) zoom = min(RENDER_W / page.rect.width, RENDER_H / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples) return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
else: else:
img = pil_img.copy() img = pil_img.copy()
img.thumbnail((700, sh - 150), Image.LANCZOS) img.thumbnail((RENDER_W, RENDER_H), Image.LANCZOS)
return img return img
def on_close(): def on_close():
@@ -62,8 +75,6 @@ def main():
root.destroy() root.destroy()
root.title(pdf_path.stem) root.title(pdf_path.stem)
root.attributes("-topmost", True)
root.resizable(False, False)
root.protocol("WM_DELETE_WINDOW", on_close) root.protocol("WM_DELETE_WINDOW", on_close)
lbl_img = tk.Label(root) lbl_img = tk.Label(root)
@@ -91,23 +102,41 @@ def main():
show(0) show(0)
root.update_idletasks() root.update_idletasks()
sw = root.winfo_screenwidth() try:
w = root.winfo_width() import sys as _sys
h = root.winfo_height() _sys.path.insert(0, str(Path(__file__).parent))
x = (sw - w) // 2 from window_layout import get_layout, apply_geometry
root.geometry(f"+{x}+0") _layout = get_layout()
def _fallback_prev():
sw = root.winfo_screenwidth()
w = root.winfo_width()
x = (sw - w) // 2
root.geometry(f"+{x}+0")
apply_geometry(root, _layout, "preview_viewer", fallback_fn=_fallback_prev)
except Exception:
sw = root.winfo_screenwidth()
w = root.winfo_width()
x = (sw - w) // 2
root.geometry(f"+{x}+0")
# Zapiš geometrii do souboru pokud byl předán argument --write-geometry=<cesta> # Zapiš geometrii do souboru pokud byl předán argument --write-geometry=<cesta>
import json as _json import json as _json
for arg in sys.argv: for arg in sys.argv:
if arg.startswith("--write-geometry="): if arg.startswith("--write-geometry="):
geom_path = Path(arg.split("=", 1)[1]) geom_path = Path(arg.split("=", 1)[1])
geom_path.write_text(_json.dumps({"x": x, "y": 0, "w": w, "h": h}), encoding="utf-8") root.update_idletasks()
_x = root.winfo_x()
_y = root.winfo_y()
_w = root.winfo_width()
_h = root.winfo_height()
geom_path.write_text(_json.dumps({"x": _x, "y": _y, "w": _w, "h": _h}), encoding="utf-8")
break break
root.lift() root.lift()
root.focus_force() root.attributes("-topmost", True)
root.after(100, lambda: root.focus_force()) root.after(1500, lambda: root.attributes("-topmost", False))
root.mainloop() root.mainloop()
@@ -0,0 +1,221 @@
"""
Standalone dialog pro schválení / opravu názvu souboru.
Spouští se jako subprocess z extract_patient_info_novy_test.py.
Argumenty: rename_dialog_test.py <json_soubor>
JSON vstup: {
"nazev": "...",
"info_lines": [...],
"duplicity": [...], # seznam názvů existujících souborů pro stejné RC+datum
"varianty": [...] # seznam návrhů názvu od Claude (unikátní, seřazené od nejlepší)
}
JSON výstup: { "value": "..." } nebo { "value": null }
"""
import json
import os
import sys
from pathlib import Path
import tkinter as tk
from tkinter import font as tkfont
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def main():
if len(sys.argv) < 2:
print(json.dumps({"value": None}))
sys.exit(0)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
nazev = data.get("nazev") or ""
info_lines = data.get("info_lines") or []
duplicity = data.get("duplicity") or []
varianty = data.get("varianty") or []
result = {"value": None}
root = tk.Tk()
root.title("Schválení názvu souboru")
root.resizable(True, False)
root.attributes("-topmost", True)
pad = {"padx": 12, "pady": 4}
font_ui = ("Segoe UI", 10)
font_bold = ("Segoe UI", 9, "bold")
font_small = ("Segoe UI", 9)
# ── 1. Info panel (Medicus status) ────────────────────────────────────────
frame_info = tk.Frame(root, bg="#f0f0f0", bd=1, relief="sunken")
frame_info.pack(fill="x", **pad)
for line in info_lines:
color = "#b00000" if line.startswith("") or line.startswith("") else \
"#2a7a2a" if line.startswith("") else "#333"
tk.Label(frame_info, text=line, anchor="w", bg="#f0f0f0",
fg=color, font=font_ui).pack(fill="x", padx=8, pady=1)
# ── 2. Listbox 1 — duplicity ──────────────────────────────────────────────
if duplicity:
tk.Label(root, text="⚠ Již existující dokumenty pro toto datum:",
anchor="w", fg="#b00000", font=font_bold).pack(fill="x", padx=12, pady=(8, 2))
frame_dup = tk.Frame(root)
frame_dup.pack(fill="x", padx=12, pady=(0, 4))
sb_dup = tk.Scrollbar(frame_dup, orient="vertical")
lb_duplicity = tk.Listbox(
frame_dup,
yscrollcommand=sb_dup.set,
font=font_small,
height=min(len(duplicity), 4),
selectmode="single",
activestyle="dotbox",
bg="#fff8f0",
selectbackground="#e0b000",
selectforeground="#000",
)
sb_dup.config(command=lb_duplicity.yview)
sb_dup.pack(side="right", fill="y")
lb_duplicity.pack(side="left", fill="x", expand=True)
for d in duplicity:
lb_duplicity.insert(tk.END, d)
# OnClick — připraveno pro budoucí funkcionalitu
def on_duplicita_click(event):
pass # TODO: budoucí akce při výběru duplicity
lb_duplicity.bind("<<ListboxSelect>>", on_duplicita_click)
# ── 3. Listbox 2 — návrhy Claudea ─────────────────────────────────────────
nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev
var = tk.StringVar(value=nazev_bez)
if varianty:
tk.Label(root, text="Návrhy pojmenování (kliknutím vyberte):",
anchor="w", font=font_bold).pack(fill="x", padx=12, pady=(8, 2))
frame_var = tk.Frame(root)
frame_var.pack(fill="x", padx=12, pady=(0, 4))
sb_var = tk.Scrollbar(frame_var, orient="vertical")
lb_varianty = tk.Listbox(
frame_var,
yscrollcommand=sb_var.set,
font=font_small,
height=min(len(varianty), 6),
selectmode="single",
activestyle="dotbox",
bg="#f0f8ff",
selectbackground="#2a7a2a",
selectforeground="#fff",
)
sb_var.config(command=lb_varianty.yview)
sb_var.pack(side="right", fill="y")
lb_varianty.pack(side="left", fill="x", expand=True)
for v in varianty:
v_bez = v[:-4] if v.endswith(".pdf") else v
lb_varianty.insert(tk.END, v_bez)
# Klik → přepsat Entry
def on_varianta_click(event):
sel = lb_varianty.curselection()
if sel:
var.set(lb_varianty.get(sel[0]))
lb_varianty.bind("<<ListboxSelect>>", on_varianta_click)
# ── 4. Entry — definitivní název ──────────────────────────────────────────
tk.Label(root, text="Název souboru (bez .pdf):", anchor="w",
font=font_bold).pack(fill="x", padx=12, pady=(10, 2))
entry = tk.Entry(root, textvariable=var, font=font_ui, width=135)
entry.pack(fill="x", padx=12, pady=(0, 10))
entry.icursor(tk.END)
entry.focus_set()
# ── 5. Tlačítka ───────────────────────────────────────────────────────────
frame_btn = tk.Frame(root)
frame_btn.pack(pady=(0, 12))
def schvalit(event=None):
result["value"] = var.get().strip()
root.destroy()
def preskocit(event=None):
result["value"] = None
root.destroy()
tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit,
bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"),
padx=16, pady=6).pack(side="left", padx=8)
tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit,
bg="#7a2a2a", fg="white", font=font_ui,
padx=16, pady=6).pack(side="left", padx=8)
root.bind("<Return>", schvalit)
root.bind("<Escape>", preskocit)
# ── Pozicování okna ───────────────────────────────────────────────────────
root.update_idletasks()
sw = root.winfo_screenwidth()
w = root.winfo_width()
h = root.winfo_height()
x = (sw - w) // 2
try:
sys.path.insert(0, str(Path(__file__).parent))
from window_layout import get_layout, apply_geometry
_layout = get_layout()
def _fallback_dlg():
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
work_bottom = rect.bottom
below_y = None
for arg in sys.argv:
if arg.startswith("--below-y="):
below_y = int(arg.split("=", 1)[1])
break
if below_y is not None and below_y + h + 10 <= work_bottom:
y = below_y
else:
y = max(0, work_bottom - h - 10)
root.geometry(f"+{x}+{y}")
apply_geometry(root, _layout, "rename_dialog", fallback_fn=_fallback_dlg)
except Exception:
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
work_bottom = rect.bottom
below_y = None
for arg in sys.argv:
if arg.startswith("--below-y="):
below_y = int(arg.split("=", 1)[1])
break
if below_y is not None and below_y + h + 10 <= work_bottom:
y = below_y
else:
y = max(0, work_bottom - h - 10)
root.geometry(f"+{x}+{y}")
root.lift()
root.focus_force()
root.after(100, lambda: root.focus_force())
root.after(200, lambda: root.attributes("-topmost", True))
root.mainloop()
print(json.dumps({"value": result["value"]}, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -13,11 +13,8 @@ from PIL import Image, ImageTk
import fitz import fitz
def main(): def show(variants: list) -> str | None:
if len(sys.argv) < 2: """Zobrazí picker přímo (bez subprocesů). Vrátí cestu k vybrané variantě nebo None."""
sys.exit(1)
variants = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
chosen = {"path": None} chosen = {"path": None}
docs = [fitz.open(v["path"]) for v in variants] docs = [fitz.open(v["path"]) for v in variants]
current = [0] current = [0]
@@ -145,7 +142,15 @@ def main():
except Exception: except Exception:
pass pass
print(json.dumps({"chosen": chosen["path"]}, ensure_ascii=False)) return chosen["path"]
def main():
if len(sys.argv) < 2:
sys.exit(1)
variants = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
result = show(variants)
print(json.dumps({"chosen": result}, ensure_ascii=False))
if __name__ == "__main__": if __name__ == "__main__":
@@ -0,0 +1,99 @@
"""
Pomocný modul pro pozicování oken podle hostname počítače.
Nastavení se načítá z layout_settings.json ve stejném adresáři.
Použití:
from window_layout import get_layout
layout = get_layout()
geom = layout["preview_viewer"] # {"x": 1100, "y": 0, "w": 1400, "h": 2100}
root.geometry(f"{geom['w']}x{geom['h']}+{geom['x']}+{geom['y']}")
Pro okna s "anchor": "bottom" (rename_dialog) použij get_bottom_y():
y = layout["rename_dialog"]["x"] # jen x, y se počítá dynamicky
"""
import json
import socket
from pathlib import Path
_SETTINGS_FILE = Path(__file__).parent / "layout_settings.json"
# Výchozí fallback layout (jeden monitor, původní chování)
_DEFAULT = {
"duplicity_viewer": None, # None = nepoužívat pevnou geometrii
"preview_viewer": None,
"rename_dialog": None,
}
def get_hostname() -> str:
return socket.gethostname().upper()
def get_layout() -> dict:
"""
Vrátí slovník s geometrií oken pro aktuální počítač.
Pokud hostname není v settings, vrátí fallback (None = původní chování).
"""
hostname = get_hostname()
if not _SETTINGS_FILE.exists():
return dict(_DEFAULT)
try:
settings = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return dict(_DEFAULT)
return settings.get(hostname, dict(_DEFAULT))
def apply_geometry(root, layout: dict, window: str, fallback_fn=None):
"""
Aplikuje geometrii okna z layoutu.
- root: tkinter Tk nebo Toplevel
- layout: výsledek get_layout()
- window: klíč ("duplicity_viewer", "preview_viewer", "rename_dialog")
- fallback_fn: callable() volaný pokud pro toto okno není layout definován
"""
geom = layout.get(window)
if not geom:
if fallback_fn:
fallback_fn()
return
w = geom.get("w")
h = geom.get("h")
x = geom.get("x", 0)
y = geom.get("y", 0)
if geom.get("anchor") == "bottom":
# Výška se ignoruje, okno se přilepí ke spodnímu okraji work area
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
root.update_idletasks()
h_actual = root.winfo_height()
y = rect.bottom - h_actual - 10
if w:
root.geometry(f"+{x}+{y}")
else:
root.geometry(f"+{x}+{y}")
else:
if w and h:
root.geometry(f"{w}x{h}+{x}+{y}")
elif w:
root.update_idletasks()
root.geometry(f"{w}x{root.winfo_height()}+{x}+{y}")
else:
root.update_idletasks()
root.geometry(f"+{x}+{y}")
if __name__ == "__main__":
hostname = get_hostname()
layout = get_layout()
print(f"Hostname: {hostname}")
if all(v is None for v in layout.values()):
print("Žádné nastavení pro tento počítač — používá se výchozí chování.")
print(f"Přidej klíč '{hostname}' do {_SETTINGS_FILE}")
else:
print(f"Layout pro '{hostname}':")
for k, v in layout.items():
print(f" {k}: {v}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
# Pravidla pro přejmenování souborů
Tato pravidla platí vždy při generování polí `poznamka` a `nazev_souboru`.
1. Název souboru má vždy tvar: `RODNECISLO YYYY-MM-DD Příjmení, Jméno [TYP ODBORNOST] [popis].pdf`
- TYP je vždy buď `LZ` (lékařská zpráva / ambulantní zpráva) nebo `PZ` (propouštěcí zpráva z hospitalizace).
- Jiné typy dokumentů (Laboratoř, CT, MRI, kolonoskopie, poukaz FT apod.) nemají TYP prefix — píší se celým názvem: `[Laboratoř]`, `[CT břicha]` atd.
- Příklady: `[LZ chirurgie]`, `[PZ interna]`, `[Laboratoř]`, `[CT břicha]`
2. Když je typ dokumentu PZ (propouštěcí zpráva), umísti do druhé závorky jako první věc data hospitalizace ve tvaru `DDMMMYYYYDDMMMYYYY` (měsíc třemi písmeny anglicky, velká, bez mezer), za pomlčkou pak popis.
- Příklad: `[PZ interna] [1215APR2026 srdeční selhání]`
- Pokud je datum přijetí a propuštění ve stejném měsíci, stačí: `[1215APR2026 ...]`
- Pokud datum hospitalizace nelze určit, druhou závorku napiš bez datumu.
3. Když je dokument typ "Laboratoř", do `poznamka` uváděj POUZE hodnoty mimo normu (patologické nálezy) — hodnoty v normě vynech. **Osmolalitu séra (Osmolalita, Osm, osmolality) NIKDY nezmiňuj — ani když je mimo normu, ani v jakékoli zkratce.** Toto je absolutní výjimka: osmolalita se do názvu souboru ani do poznámky nepíše nikdy za žádných okolností. Chybně: `C_Osmolalita 293 (↑)` — správně: tuto hodnotu zcela vynech.
4. Pokud laboratorní výsledky obsahují glomerulární filtraci — bývá označena jako eGFR, CKD-EPI nebo CK-EPI — do `poznamka` nikdy nepiš číselnou hodnotu eGFR. Místo toho uveď pouze klasifikaci dle stadií CHRIG1CHRIG5.
- **Jednotka:** Nejprve zkontroluj jednotku uvedenou v laboratoři:
- Pokud je hodnota v **ml/s** nebo **ml/sec** (typicky malá čísla jako 0.8, 1.14, 1.5…), přenásob ×60 pro převod na ml/min.
- Pokud je hodnota v **ml/min** nebo **ml/min/1.73m²** (typicky velká čísla jako 55, 68, 90…), použij přímo.
- **Klasifikace** (v ml/min/1.73m²): ≥ 90 → CHRIG1, 6089 → CHRIG2, 4559 → CHRIG3a, 3044 → CHRIG3b, 1529 → CHRIG4, < 15 → CHRIG5.
- Prahové hodnoty pro orientaci při jednotce ml/s: ≥ 1.50 → G1, 1.001.49 → G2, 0.750.99 → G3a, 0.500.74 → G3b, 0.250.49 → G4, < 0.25 → G5.
- Klasifikaci uváděj pouze pokud je CHRIG2 nebo horší (tj. eGFR < 90 ml/min nebo < 1.50 ml/s) — CHRIG1 je v normě, nezmiňuj ho.
5. Když je dokument typ "Laboratoř" a zpráva obsahuje diagnózu (dg., dg:, diagnóza), umísti ji do `nazev_souboru` jako první část druhé závorky, tedy: `[Laboratoř] [dg. XY00 - stručná poznamka]`.
6. Zkratky a pojmenování: slovo „sono" (sonografie/ultrazvuk) piš vždy malými písmeny — `sono břicha`, `sono ŠŽ`, nikoli `SONO`. Štítnou žlázu označuj vždy zkratkou `ŠŽ`. Sonografii prsu/prsů (sono mamm., sono mamografie, sono mamma apod.) piš vždy jako `sono prsů`. Denzitometrii (DEXA, DXA, denzitometrie) piš vždy pouze jako `[DXA]` — bez prefixu LZ. Algologii piš vždy jako `[LZ léčba bolesti]`. Dermatovenerologii (dermatologie, dermatovenerologie, kožní) piš vždy jako `[LZ kožní]`. Angiologii piš vždy jako `[LZ cévní]`.
7. V číselných hodnotách VŽDY používej desetinnou tečku, nikoli desetinnou čárku. Toto pravidlo platí absolutně pro všechna čísla v `poznamka` i `nazev_souboru` — např. `TG 4.73`, nikoli `TG 4,73`.
8. Rozpoznávání vzorců — sideropenická anémie: Pokud laboratorní výsledky splňují typický obraz sideropenické (železo-deficitní) anémie, přidej diagnózu jako první část druhé závorky ve tvaru `[sideropenická anémie, ...]`.
Typický obraz (stačí kombinace několika z těchto nálezů):
- Krevní obraz: ↓ Hb, ↓ Htk, ↓ MCV (mikrocytóza), ↓ MCH nebo ↓ MCHC (hypochromie), ↑ RDW (anisocytóza)
- Metabolismus železa: ↓ sérové Fe (železo), ↓ ferritin, ↑ transferrin (nebo TIBC), ↓ saturace transferrinu
- Diagnózu uveď pouze pokud je obraz dostatečně přesvědčivý (alespoň ↓ Hb + ↓ MCV nebo ↓ Fe/ferritin).
- Příklad výsledného názvu: `[Laboratoř] [sideropenická anémie, Hb 98, MCV 71, Fe 5.2]`
9. Jaterní enzymy (ALT, AST, GGT, ALP, LD/LDH) a bilirubin — hodnoty pod dolní hranicí normy (snížené) nezmiňuj v `poznamka` ani v `nazev_souboru`. Uváděj pouze hodnoty nad normu (zvýšené).
10. Druhá závorka pro LZ a PZ — obsah a pořadí: Pro dokumenty typu LZ (lékařská zpráva) a PZ (propouštěcí zpráva) tvoří druhou závorku tyto části v tomto pořadí (oddělené čárkou):
a) **Typ návštěvy** — uveď pouze pokud je explicitně rozpoznatelný ze zprávy:
- `kontrola` — plánovaná kontrola (např. „plánovaná kontrola", „přichází na kontrolu")
- `neplánovaná kontrola` — pokud je výslovně uvedeno, že kontrola nebyla plánovaná
- `akutní` — pacient přichází do akutní ambulance nebo cestou RZS/záchranné služby
- Pokud typ návštěvy není ve zprávě uveden, tuto část zcela vynech (nepsat žádný fallback).
b) **Hlavní diagnóza** — získej z části „Diagnózy", „Závěr" nebo „Dg." — uveď první (hlavní) diagnózu, která je obvykle důvodem návštěvy. Stručně, výstižně.
c) **Termín příští plánované kontroly** — pokud je na konci dokumentu uveden konkrétní plánovaný termín příští kontroly (např. „jaro 2027", „za 3 měsíce", „ročně"), umísti ho jako **poslední část druhé závorky**.
- Uváděj pouze explicitně naplánované termíny — formát: `ko` + termín bez mezery, např. `ko jaro2027`, `ko za6m`, `ko ročně`.
- **Nezahrn** podmíněné návštěvy jako „dle obtíží", „při zhoršení", „při hematurii ihned" apod. — ty jsou samozřejmé a do názvu nepatří.
- Pokud dokument žádný plánovaný termín neobsahuje, tuto část vynech.
- Příklad (s typem návštěvy): `[LZ kardiologie] [kontrola, ICHS, ko za3m]`
- Příklad (bez typu návštěvy): `[LZ neurologie] [migréna, pokračovat v léčbě]`
- Příklad akutní: `[LZ interna] [akutní, dekompenzovaná hypertenze, hospitalizace]`
- Příklad s termínem kontroly: `[LZ urologie] [kontrola, hematurie microsc., angiomyolipoma renis, ko jaro2027]`
- Pro PZ zůstává datum hospitalizace jako první (před typem návštěvy), viz pravidlo 2.
11. Datum v názvu souboru nesmí být v budoucnosti: Pokud datum nalezené na zprávě a navrhované pro název souboru je pozdější než dnešní datum, je to chyba (např. špatně rozpoznané číslo). Hledej na zprávě jiné datum. Pokud žádné vhodné datum nenajdeš, použij dnešní datum.
12. Poukaz domácí péče (DP): Dokument nadepsaný „POUKAZ NA VYŠETŘENÍ / OŠETŘENÍ DP" nebo „poukaz domácí péče" se pojmenovává takto:
- První závorka: vždy `[domácí péče]` (bez prefixu LZ/PZ).
- Datum souboru: pole „Datum" na poukazu (datum vystavení), ve formátu YYYY-MM-DD.
- Druhá závorka obsahuje v tomto pořadí, odděleno čárkou:
a) **Číslo poukazu** — pole „Pořadové číslo poukazu" (celé číslo, např. `1`).
b) **Platnost** — „do DDMMMYYYY" kde datum je z pole „Platnost do" (měsíc třemi velkými písmeny anglicky, bez mezer), např. `do 30JUN2026`.
c) **Výkony** — každý výkon (kód ze sloupce „Požadováno") se uvede jako:
- `{kód} ad hoc` — pokud je u výkonu uvedeno **0x týdně** (bez ohledu na četnost denně); znamená to výkon pouze dle potřeby, ne na pravidelné bázi.
- `{kód} {N}xd{M}xt` — pokud je týdenní četnost M > 0; N = četnost denně, M = četnost týdně. Např. pro 1x denně 3x týdně: `06313 1xd3xt`.
- Příklad (oba výkony ad hoc): `[domácí péče] [1 do 30JUN2026, 06313 ad hoc, 06323 ad hoc]`
- Příklad (pravidelné výkony): `[domácí péče] [2 do 31AUG2026, 06313 1xd5xt, 06321 2xd7xt]`
+468
View File
@@ -1674,5 +1674,473 @@
{ {
"original": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [40b nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf", "original": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [40b nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf",
"corrected": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [35b nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf" "corrected": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [35b nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf"
},
{
"original": "435720013 2026-05-25 Lišková, Jaroslava [LZ kardiologie] [EKG: zrychlená Tf, bez čerstvých lož. změn, repolarizace bez poruch, bez isch.].pdf",
"corrected": "435720013 2026-05-25 Lišková, Jaroslava [LZ kardiologie] [EKG zrychlená Tf, bez čerstvých lož. změn, repolarizace bez poruch, bez isch.].pdf"
},
{
"original": "455925093 2026-04-08 Fialová, Růžena [LZ kardiologie] [AH, non-dipper, LBBB, aort. reg. 2+, EF LK 59%, diastol. dysfunkce, TK 176/99].pdf",
"corrected": "455925093 2026-04-08 Fialová, Růžena [LZ kardiologie] [AH, non-dipper, LBBB, aort. reg. 2+, EF LK 59%, diastol. dysfunkce, TK 17699].pdf"
},
{
"original": "475424136 2026-05-26 Müllerová, Miluše [LZ endokrinologie] [Izoechogenní uzly v PL ŠŽ, bez růst.progrese, supresní terapie, TSH <0.010].pdf",
"corrected": "475424136 2026-05-26 Müllerová, Miluše [LZ endokrinologie] [Izoechogenní uzly v PL ŠŽ, bez růst.progrese, supresní terapie, TSH 0.010].pdf"
},
{
"original": "485507406 2026-05-25 Jourová, Eva [Laboratoř] [dg. I839 - D-dimery 0.52 mg/l FEU (nad std. cut-off 0.50, pod věk-adj. 0.78)].pdf",
"corrected": "485507406 2026-05-25 Jourová, Eva [Laboratoř] [dg. I839 - D-dimery 0.52 mgl FEU (nad std. cut-off 0.50, pod věk-adj. 0.78)].pdf"
},
{
"original": "486111054 2026-05-22 Pelcová, Ludmila [LZ neurologie] [myasthenia gravis, AntiACHR >20 mmol/l, po plazmaferézách, Prednison 45mg, Imuran].pdf",
"corrected": "486111054 2026-05-22 Pelcová, Ludmila [LZ neurologie] [myasthenia gravis, AntiACHR 20 mmoll, po plazmaferézách, Prednison 45mg, Imuran].pdf"
},
{
"original": "495227264 2026-05-27 Hajšmanová, Marcela [TK deník] [květen 2026, Telmisartan ratiopharm 1/2 tbl, TK ráno 120-135, večer 139-148].pdf",
"corrected": "495227264 2026-05-27 Hajšmanová, Marcela [domácí měření TK] [květen 2026, Telmisartan ratiopharm 12 tbl, TK ráno 120-135, večer 139-148].pdf"
},
{
"original": "505516240 2022-05-04 Michková, Miroslava [LZ interna] [EKG norma, veget. stigmata, bez čerstvých ischem. změn a dysrytmií].pdf",
"corrected": "505516240 2022-05-04 Michková, Miroslava [EKG] [EKG norma, veget. stigmata, bez čerstvých ischem. změn a dysrytmií].pdf"
},
{
"original": "5455142143 2026-05-27 Setničková, Jitka [řidičský posudek] [zdravotně způsobilá s podmínkou - brýle sk. B, platnost do 27.05.2028].pdf",
"corrected": "5455142143 2026-05-27 Setničková, Jitka [Posudek ŘP] [zdravotně způsobilá s podmínkou - brýle sk. B, platnost do 27.05.2028].pdf"
},
{
"original": "5455142143 2026-05-27 Setničková, Jitka [Prohlášení zdravotní způsobilosti ŘP] [zdravotně způsobilá, sk. B brýle, medikace cukrovka a ŠŽ].pdf",
"corrected": "5455142143 2026-05-27 Setničková, Jitka [Prohlášení ŘP] [zdravotně způsobilá, sk. B brýle, medikace cukrovka a ŠŽ].pdf"
},
{
"original": "6559102187 2017-03-15 Nikitina, Svitlana [EKG] [sinusový rytmus 75/min, vertikální poloha, bez sign. změn ST, fyziol. záznam].pdf",
"corrected": "6559102187 2017-03-15 Nikitina, Svitlana [EKG] [sinusový rytmus 75min, vertikální poloha, bez sign. změn ST, fyziol. záznam].pdf"
},
{
"original": "6857112064 2026-05-26 Dundrová, Markéta [deník pacienta ABPM] [8:30 pěšky do práce, 16:10 pěšky z práce, 17:05 jízda autem, 19:25 večeře, 20:15 práce na zahradě].pdf",
"corrected": "6857112064 2026-05-26 Dundrová, Markéta [Holter TK] [].pdf"
},
{
"original": "6907220320 2026-04-20 Výprachtický, Ondřej [LZ diabetologie] [Recentní DM 2.typu, intenzif. inzulin. režim, vstupní polyurie/polydipsie, DLP na dietě].pdf",
"corrected": "6907220320 2026-04-20 Výprachtický, Ondřej [LZ diabetologie] [Recentní DM 2.typu, intenzif. inzulin. režim, vstupní polyuriepolydipsie, DLP na dietě].pdf"
},
{
"original": "7755230450 2025-05-26 Moučková, Eva [domácí měření TK] [deník TK a tepu, březenkvěten 2025, TK 94-137/58-89, tep 52-73].pdf",
"corrected": "7755230450 2025-05-26 Moučková, Eva [domácí měření TK] [deník TK a tepu, březenkvěten 2025, TK 94-13758-89, tep 52-73].pdf"
},
{
"original": "7856230448 2024-09-30 Kulhánková, Eliška [LZ kardiologie] [EF 64%, diastol. dysfunkce pseudonorm., stopová MR/TR/PR, perikard výpotek 1mm (hypothyreosa?)].pdf",
"corrected": "7856230448 2024-09-30 Kulhánková, Eliška [LZ kardiologie] [EF 64%, diastol. dysfunkce pseudonorm., stopová MRTRPR, perikard výpotek 1mm (hypothyreosa)].pdf"
},
{
"original": "5455142143 2026-01-26 Setničková, Jitka [LZ diabetologie] [DM2 dg 5/2019, MTF+DPP4i+gliflozin, kompenzace uspokojivá, MAU-ACR 4g/mol].pdf",
"corrected": "5455142143 2026-01-26 Setničková, Jitka [LZ diabetologie] [DM2 dg 52019, MTF+DPP4i+gliflozin, kompenzace uspokojivá, MAU-ACR 4gmol].pdf"
},
{
"original": "5610101959 null Olach, Milan [EHIC] [OZP 20701, platnost do 25052035].pdf",
"corrected": "5610101959 2026-02-06 Olach, Milan [Evropský průkaz zdravotního pojištění] [OZP 207, platnost do 25MAY2035].pdf"
},
{
"original": "320312460 2026-05-20 Vlachovský, Ladislav [LZ kardiologie] [kontrola, EF 65%, diastol. dysfunkce, lehká MR, RBBB, kontrola za 6 měs.].pdf",
"corrected": "320312460 2026-05-20 Vlachovský, Ladislav [LZ kardiologie] [kontrola, EF 65% diastol. dysfunkce RBBB lehká MR, konzervat. postup kontrola 6 měs.].pdf"
},
{
"original": "325309100 2026-08-26 Maturová, Jaroslava [LZ interna] [kontrola, HFmrEF při ICHS elevace BNP, perzist. FiS eufrekvenční, ICHS].pdf",
"corrected": "325309100 2026-04-21 Maturová, Jaroslava [LZ interna] [kontrola, HFmrEF při ICHS elevace BNP, perzist. FiS eufrekvenční, ICHS].pdf"
},
{
"original": "325505726 2026-05-27 Mimrová, Ružena [poukaz DP] [omezená mobilita nejistá chůze, aplikace injekcí a odběr krve doma].pdf",
"corrected": "325505726 2026-05-27 Mimrová, Ružena [Domácí péče] [7 do 31MAY2026, 06313 ad hoc, 06323 ad hoc, omezená mobilita nejistá chůze, aplikace injekcí a odběr krve doma].pdf"
},
{
"original": "435225133 2026-05-14 Tichá, Věra [PZ oddělení] [14MAY2026 generalizovaná ateroskleróza, Ca endometria, metastázy OS, fraktura femuru].pdf",
"corrected": "435225133 2026-05-25 Tichá, Věra [PZ následná péče] [14MAY-25MAY2026 generalizovaná ateroskleróza, Ca endometria, metastázy OS, fraktura femuru, exitus letalis 25MAY2026].pdf"
},
{
"original": "455530096 2026-05-27 Vlachovská, Miroslava [LZ ortopedie] [kontrola, St.p. TEP coxae bilat., RTG bez uvolnění, Depo/Meso P SI kl.].pdf",
"corrected": "455530096 2026-05-27 Vlachovská, Miroslava [LZ ortopedie] [kontrola, St.p. TEP coxae bilat., RTG bez uvolnění, DepoMeso P SI kl.].pdf"
},
{
"original": "5655300222 2026-05-27 Kreibichová, Magdalena [Laboratoř] [dg. E789, P_Glukóza 6.5 (↑), CKD-EPI 1.47 ml/s → CHRIG2].pdf",
"corrected": "5655300222 2026-05-27 Kreibichová, Magdalena [Laboratoř] [dg. E789, P_Glukóza 6.5 (↑), CKD-EPI 1.47 mls → CHRIG2].pdf"
},
{
"original": "8956039037 2026-05-12 Slavíková, Zuzana [LZ revmatologie] [kontrola, primární SjS a vaskulitida, remise MALT lymfomu parotidy po Rituximabu, ko jaro2027].pdf",
"corrected": "8956039037 2026-05-12 Slavíková, Zuzana [LZ revmatologie] [kontrola, primární SjS a vaskulitida, remise MALT lymfomu parotidy po Rituximabu, ko +6m].pdf"
},
{
"original": "496219079 2026-06-02 Jindrová, Jiskra [EKG] [bez hodnocení].pdf",
"corrected": "496219079 2026-06-02 Jindrová, Jiskra [EKG] [bez hodnocení].pdf"
},
{
"original": "0161270054 2026-06-02 Škopková, Denisa [EKG] [bez hodnocení].pdf",
"corrected": "0161270054 2026-06-02 Škopková, Denisa [EKG] [bez hodnocení].pdf"
},
{
"original": "1006055083 2010-10-12 Šmíd, Jiří [Očkovací průkaz] [Prevenar 13: I. 12-10-2010, II. 01-12-2010, III. 25-01-2011, IV. 06-09-2011].pdf",
"corrected": "1006055083 2026-06-03 Šmíd, Jiří [Očkovací průkaz] [Prevenar 13 I. 12-10-2010, II. 01-12-2010, III. 25-01-2011, IV. 06-09-2011].pdf"
},
{
"original": "460614110 2026-04-20 Galus, Karel [PZ kožní] [1720APR2026 scabies dermatoskopicky verifikovaný, léčba sirnou kúrou].pdf",
"corrected": "460614110 2026-04-20 Galus, Karel [PZ kožní] [1720APR2026 scabies SVRAB dermatoskopicky verifikovaný, léčba sirnou kúrou].pdf"
},
{
"original": "465917444 2026-05-25 Trojková, Jana [DXA] [BMD bed. páteř T-skore -2.5 osteoporóza, krček femuru bilat. T-skore -1.6/-1.9 osteopenie].pdf",
"corrected": "465917444 2026-05-25 Trojková, Jana [DXA] [BMD bed. páteř T-skore -2.5 osteoporóza, krček femuru bilat. T-skore -1.6-1.9 osteopenie].pdf"
},
{
"original": "465917444 2026-05-26 Trojková, Jana [LZ urologie] [kontrola, ca renis dx. pT1bN0M0, recid. IMC, CT 03/25 bez recidivy, ko za6m].pdf",
"corrected": "465917444 2026-05-26 Trojková, Jana [LZ urologie] [kontrola, ca renis dx. pT1bN0M0, recid. IMC, CT 0325 bez recidivy, ko za6m].pdf"
},
{
"original": "5811180100 2026-05-28 Dalecký, Milan [LZ nefrologie] [kontrola, IgA nefropatie, CKD, kreatinin 116, ACR stabilní, TK holter 133/70].pdf",
"corrected": "5811180100 2026-05-28 Dalecký, Milan [LZ nefrologie] [kontrola, IgA nefropatie, CKD, kreatinin 116, ACR stabilní, TK holter 13370].pdf"
},
{
"original": "6861010288 Štefanská, Renáta split_016.pdf",
"corrected": "6861010288 2026-06-02 Štefanská, Renáta [LZ plicní] [akutní exacerbace chronické bronchitidy, atb].pdf"
},
{
"original": "split_012.pdf",
"corrected": "460614110 2026-06-03 Galus, Karel [přehled užívané medikace] [od pacienta].pdf"
},
{
"original": "split_021.pdf",
"corrected": "7101062386 2026-06-03 Schod, Pavel [domácí měření TK] [zjevná hypertenze].pdf"
},
{
"original": "5751211807 2026-06-02 Hnízdová, Eva [PZ kardiologie] [0102JUN2026 EFV/RFA pro susp. FAT, AVNRT po RFA pomalé dráhy 0925].pdf",
"corrected": "5751211807 2026-06-02 Hnízdová, Eva [PZ kardiologie] [0102JUN2026 EFVRFA pro susp. FAT, AVNRT po RFA pomalé dráhy 0925].pdf"
},
{
"original": "6110241324 2026-06-03 Pažitný, Josef [EKG] [bez hodnocení].pdf",
"corrected": "6110241324 2026-06-03 Pažitný, Josef [EKG] [bez hodnocení].pdf"
},
{
"original": "0760245079 2026-06-03 [EKG] [bez hodnocení].pdf",
"corrected": "0760245079 Ryšavá, Denisa 2026-06-03 [EKG] [bez hodnocení].pdf"
},
{
"original": "366103079 2026-06-01 Čížkovská, Jaroslava [domácí péče] [1 do 30JUN2026].pdf",
"corrected": "366103079 2026-06-01 Čížkovská, Jaroslava [K od psychiatra] [že máme zařídit domácí péči, kterou pacientka domů nepustí].pdf"
},
{
"original": "435520110 2026-06-01 Nechodomová, Marie [LZ gastroenterologie] [Inkompetence kardie, pseudopolyp subkardiálně, biliární reflux, atrofie fornixu, hyperémie antra].pdf",
"corrected": "435520110 2026-06-01 Nechodomová, Marie [LZ gastroenterologie] [gastroskopie Inkompetence kardie, pseudopolyp subkardiálně, biliární reflux, atrofie fornixu, hyperémie antra].pdf"
},
{
"original": "5404211967 2014-11-19 Zich, Jiří [operační protokol neurochirurgie] [Stenóza L4/5, miniinvazivní over-the-top dekomprese L4/5 zleva].pdf",
"corrected": "5404211967 2014-11-19 Zich, Jiří [operační protokol neurochirurgie] [Stenóza L45, miniinvazivní over-the-top dekomprese L45 zleva].pdf"
},
{
"original": "5404211967 2014-11-24 Zich, Jiří [PZ neurochirurgie] [1824NOV2014 degenerativní stenóza L4/5, miniinvazivní over-the-top dekomprese L4/5 zleva].pdf",
"corrected": "5404211967 2014-11-24 Zich, Jiří [PZ neurochirurgie] [1824NOV2014 degenerativní stenóza L45, miniinvazivní over-the-top dekomprese L45 zleva].pdf"
},
{
"original": "5404211967 Zich, Jiří [dopis pacientovi] [žádost o typ kovové výztuhy bérce z r. 2001 před op. kolena].pdf",
"corrected": "5404211967 2026-06-05 Zich, Jiří [dopis pacienta] [žádost o typ kovové výztuhy bérce z r. 2001 před op. kolena].pdf"
},
{
"original": "5404211967 2022-06-22 Zich, Jiří [PZ ortopedicko-traumatologická] [2022JUN2022 extrakce OS hřebu bérce vlevo, gonartróza L kolena, TEP plánována].pdf",
"corrected": "5404211967 2022-06-22 Zich, Jiří [PZ ortopedie] [2022JUN2022 extrakce OS hřebu bérce vlevo, gonartróza L kolena, TEP plánována].pdf"
},
{
"original": "5404211967 2026-06-01 Zich, Jiří [řidičský průkaz zdravotní způsobilost] [zdravotně způsobilý sk. B, platnost do 01.06.2028].pdf",
"corrected": "5404211967 2026-06-01 Zich, Jiří [Posudek ŘP] [zdravotně způsobilý sk. B, platnost do 01.06.2028].pdf"
},
{
"original": "5404211967 2026-06-01 Zich, Jiří [Prohlášení zdravotní způsobilosti řidiče] [sk. B, cítí se zdráv, užívá Tamsulosin na prostatu].pdf",
"corrected": "5404211967 2026-06-01 Zich, Jiří [Prohlášení ŘP] [sk. B, cítí se zdráv, užívá Tamsulosin na prostatu].pdf"
},
{
"original": "7857103232 2014-07-14 Dubová, Zita [LZ neurologie] [noční můry od dětství, RLS, spánková anamnéza, ESS 15/24].pdf",
"corrected": "7857103232 2014-07-14 Dubová, Zita [LZ neurologie] [noční můry od dětství, RLS, spánková anamnéza, ESS 1524].pdf"
},
{
"original": "8157220159 2007-03-27 Vrňáková, Lucie [LZ hematologie] [kontrola, kompletní remise HL, gonadotox, subklinická hypothyreóza, diskrétní plicní tox].pdf",
"corrected": "8157220159 2007-06-27 Vrňáková, Lucie [LZ hematologie] [kontrola, kompletní remise HL, gonadotox, subklinická hypothyreóza, diskrétní plicní tox].pdf"
},
{
"original": "8157220159 2009-06-30 Vrňáková, Lucie [LZ hematologie] [kontrola, kompletní remise 7 let po terapii, incip. poradiační hypothyreóza, ko endokrinologie].pdf",
"corrected": "8157220159 2009-07-01 Vrňáková, Lucie [LZ hematologie] [kontrola, kompletní remise 7 let po terapii, incip. poradiační hypothyreóza, ko endokrinologie].pdf"
},
{
"original": "8157220159 2022-03-16 Vrňáková, Lucie [LZ kardiologie] [kontrola, stp. CHT a AR 2001, EF 73%, norm. diastol. fce, stopová MR/TR/PR, bez zn. plicní HTN, ko za 3-5 let].pdf",
"corrected": "8157220159 2022-03-16 Vrňáková, Lucie [LZ kardiologie] [kontrola, stp. CHT a AR 2001, EF 73%, norm. diastol. fce, stopová MRTRPR, bez zn. plicní HTN, ko za 3-5 let].pdf"
},
{
"original": "8812310408 2026-06-04 Sekrt, Zdeněk [EKG] [bez hodnocení].pdf",
"corrected": "8812310408 2026-06-04 Sekrt, Zdeněk [EKG] [bez hodnocení].pdf"
},
{
"original": "5459051862 2026-06-04 Vortelová, Eva [EKG] [bez hodnocení].pdf",
"corrected": "5459051862 2026-06-04 Vortelová, Eva [EKG] [bez hodnocení].pdf"
},
{
"original": "5962050149 2026-06-04 Jelínková, Eva [EKG] [bez hodnocení].pdf",
"corrected": "5962050149 2026-06-04 Jelínková, Eva [EKG] [bez hodnocení].pdf"
},
{
"original": "405712023 2026-06-05 Pilná, Marta [EKG] [bez hodnocení].pdf",
"corrected": "405712023 2026-06-05 Pilná, Marta [EKG] [bez hodnocení].pdf"
},
{
"original": "400510088 2026-05-18 Kameník, Jaroslav [LZ léčba bolesti] [kontrola, M511 radikulopatie + polyneuropatie NS, vířivka isotherm. obě DK 7x, ko za půl roku].pdf",
"corrected": "400510088 2026-05-18 Kameník, Jaroslav [LZ rehabilitace] [kontrola, M511 radikulopatie + polyneuropatie NS, vířivka isotherm. obě DK 7x, ko za půl roku].pdf"
},
{
"original": "496219079 2025-08-14 Jindrová, Jiskra [LZ kardiologie] [Nevýznamná aortální regurgitace 1-2/4 ke sledování, ko za půl roku].pdf",
"corrected": "496219079 2025-08-14 Jindrová, Jiskra [LZ kardiologie] [Nevýznamná aortální regurgitace 1-24 ke sledování, ko za půl roku].pdf"
},
{
"original": "496219079 2025-10-01 Jindrová, Jiskra [LZ cévní] [CVD CEAP 4s, povrch. žíly bez významné insuficience, ko 3/2026].pdf",
"corrected": "496219079 2025-10-01 Jindrová, Jiskra [LZ cévní] [CVD CEAP 4s, povrch. žíly bez významné insuficience, ko 32026].pdf"
},
{
"original": "7755035376 2026-06-08 Yates, Hana [EKG] [bez hodnocení].pdf",
"corrected": "7755035376 2026-06-08 Yates, Hana [EKG] [bez hodnocení].pdf"
},
{
"original": "6158201456 2026-06-08 Hradilová, Zdenka [EKG] [bez hodnocení].pdf",
"corrected": "6158201456 2026-06-08 Hradilová, Zdenka [EKG] [bez hodnocení].pdf"
},
{
"original": "0356030983 2022-05-11 Pelcová, Eliška [Výpis ze zdravotní dokumentace] [Eutrof. 58.7kg/165cm, polyvalentní alergie, migrény, atop. ekzém, funk. blokáda C páteře].pdf",
"corrected": "0356030983 2022-05-11 Pelcová, Eliška [Výpis ze zdravotní dokumentace] [Eutrof. 58.7kg165cm, polyvalentní alergie, migrény, atop. ekzém, funk. blokáda C páteře].pdf"
},
{
"original": "480529219 2026-06-04 Nytra, Vlastimil [LZ urologie] [PIRADS 4 k fúzní Bx prostaty, PSA 06/2026 8.611, ko 23JUL2026].pdf",
"corrected": "480529219 2026-06-04 Nytra, Vlastimil [LZ urologie] [PIRADS 4 k fúzní Bx prostaty, PSA 062026 8.611, ko 23JUL2026].pdf"
},
{
"original": "6162102023 2018-08-20 Vandirkova, Tetjana [EKG] [předoper, sinusový rytmus 60/min, intermed poloha, fyziologický záznam].pdf",
"corrected": "6162102023 2018-08-20 Vandirkova, Tetjana [EKG] [předoper, sinusový rytmus 60min, intermed poloha, fyziologický záznam].pdf"
},
{
"original": "6405250808 2026-05-29 Švéda, Jan [Laboratoř] [dg. Z000, CKD-EPI 1.25 ml/s CHRIG2, HDL 0.94 (↓), P_Glukóza 5.9 (↑)].pdf",
"corrected": "6405250808 2026-05-29 Švéda, Jan [Laboratoř] [dg. Z000, CKD-EPI 1.25 mls CHRIG2, HDL 0.94 (↓), P_Glukóza 5.9 (↑)].pdf"
},
{
"original": "6758120446 2026-06-01 Bečicová, Markéta [Laboratoř] [dg. N309 - kultivace moči: kontaminace, směs mikroflóry 10E3 CFU/ml].pdf",
"corrected": "6758120446 2026-06-01 Bečicová, Markéta [Laboratoř] [dg. N309 - kultivace moči kontaminace, směs mikroflóry 10E3 CFUml].pdf"
},
{
"original": "8004110081 2016-03-07 Čuda, Petr [LZ ORL] [stp tonsilitidem, lipoma ? epiglottidis, zítra incize útvaru na ling. ploše epiglottis].pdf",
"corrected": "8004110081 2016-03-07 Čuda, Petr [LZ ORL] [stp tonsilitidem, lipoma epiglottidis, zítra incize útvaru na ling. ploše epiglottis].pdf"
},
{
"original": "9062110431 2026-06-03 Chriti Vinš, Jeanette [Laboratoř] [dg. Z000, S_AST <0.13 (↓)].pdf",
"corrected": "9062110431 2026-06-03 Chriti Vinš, Jeanette [Laboratoř] [dg. Z000, S_AST 0.13 (↓)].pdf"
},
{
"original": "410413024 2026-06-05 Stehno, Oldřich [LZ léčba bolesti] [VAS LS, Coxartroza l.dx pokročilá, TENS L,LS,SIK, RTG LSp a pánve].pdf",
"corrected": "410413024 2026-06-05 Stehno, Oldřich [LZ rehabilitace] [VAS LS, Coxartroza l.dx pokročilá, TENS L,LS,SIK, RTG LSp a pánve].pdf"
},
{
"original": "7755035376 2022-11-22 Yates, Hana [LZ cévní] [křečové žíly LDK, CEAP 2, doporučena ligace/koagulace a miniflebectomie].pdf",
"corrected": "7755035376 2022-11-22 Yates, Hana [LZ cévní] [křečové žíly LDK, CEAP 2, doporučena ligacekoagulace a miniflebectomie].pdf"
},
{
"original": "8362022900 2026-04-22 Ekhard, Petra [LZ kardiologie] [Norm. kinetika LKS, trojcípá jemná AoV, stop. AR, bez dilatace PS/LS, nezáv. MR+TR 1+, norm. diast. funkce, bubble test neg.].pdf",
"corrected": "8362022900 2026-04-22 Ekhard, Petra [LZ kardiologie] [Norm. kinetika LKS, trojcípá jemná AoV, stop. AR, bez dilatace PSLS, nezáv. MR+TR 1+, norm. diast. funkce, bubble test neg.].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [EKG] [bez hodnocení].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [EKG] [bez hodnocení].pdf"
},
{
"original": "8351112693 2026-06-10 Zelenková, Petra [EKG] [bez hodnocení].pdf",
"corrected": "8351112693 2026-06-10 Zelenková, Petra [EKG] [bez hodnocení].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [deník TK] [záznamy 5.25.6, hodnoty 118132/5467 mmHg].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [domácí měření TK] [záznamy 5.25.6, hodnoty 1181325467 mmHg].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [lékařský posudek řidič] [zdravotně způsobilý s podmínkou A1, AM, B1, B s brýlemi, platnost do 09.06.2028].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [posudek ŘP] [zdravotně způsobilý s podmínkou A1, AM, B1, B s brýlemi, platnost do 09.06.2028].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [Prohlášení zdravotní způsobilosti] [cítí se zdráv, užívá metformin, ACI, léky na kyselinu močovou, na tuky].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [prohlášení ŘP] [cítí se zdráv, užívá metformin, ACI, léky na kyselinu močovou, na tuky].pdf"
},
{
"original": "491118063 2026-05-28 Sedláček, Jaroslav [LZ diabetologie] [DM2 kontrola, HbA1c 49, kompenzace zlepšena, CKD G3a, kombinovaná hyperlipidémie, ko konec10-zač11/2026].pdf",
"corrected": "491118063 2026-05-28 Sedláček, Jaroslav [LZ diabetologie] [DM2 kontrola, HbA1c 49, kompenzace zlepšena, CKD G3a, kombinovaná hyperlipidémie, ko konec10-zač112026].pdf"
},
{
"original": "505805215 2026-05-12 Sedláčková, Vlasta [LZ ORL] [postnasal drip].pdf",
"corrected": "505805215 2026-05-12 Sedláčková, Vlasta [LZ ORL] [postnasal drip, dlouhodobý kašel, Mommox].pdf"
},
{
"original": "7606050518 2026-06-09 Novotný, Pavel [domácí měření TK] [záznamy 28.59.6, hodnoty TK 100116/6579 mmHg, TF 6792].pdf",
"corrected": "7606050518 2026-06-09 Novotný, Pavel [domácí měření TK] [záznamy 28.59.6, hodnoty TK 1001166579 mmHg, TF 6792].pdf"
},
{
"original": "7606050518 Novotný, Pavel split_004.pdf",
"corrected": "7606050518 2026-06-04 Novotný, Pavel [domácí měření TK] [pěkná kompenzace].pdf"
},
{
"original": "330613108 2026-06-01 Schořálek, Jaroslav [domácí péče] [4 do 30JUN2026, 06311 ad hoc, 06315 1xd3xt, 06329 1xd3xt].pdf",
"corrected": "330613108 2026-06-01 Schořálek, Jaroslav [domácí péče updated] [4 do 30JUN2026, 06311 ad hoc, 06315 1xd3xt, 06329 1xd3xt].pdf"
},
{
"original": "436225107 2026-02-09 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza-osteopénie, CHOPN, art. hypertenze, prolia 60mg, ko 6/2026].pdf",
"corrected": "436225107 2026-02-09 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza-osteopénie, CHOPN, art. hypertenze, prolia 60mg, ko 62026].pdf"
},
{
"original": "436225107 2025-12-22 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza (nyní osteopénie dle DEXA), CHOPN, art. hypertenze, aplikace Prolia 60mg s.c., ko 6/2026].pdf",
"corrected": "436225107 2025-12-22 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza (nyní osteopénie dle DXA), CHOPN, art. hypertenze, aplikace Prolia 60mg s.c., ko 62026].pdf"
},
{
"original": "465418044 2026-06-10 Dvořáková, Zdeňka [Laboratoř] [moč: URO +1, PRO +/-, GLU +4 (111 mmol/L)].pdf",
"corrected": "465418044 2026-06-10 Dvořáková, Zdeňka [Uritex] [moč URO +1, PRO +-, GLU +4 (111 mmolL)].pdf"
},
{
"original": "5606051143 2026-05-19 Zána, Jan [PZ lázeňská] [21APR202619MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf",
"corrected": "5606051143 2026-05-19 Zána, Jan [PZ lázně] [21APR202619MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf"
},
{
"original": "6561150607 2026-06-05 Tipplová, Michaela [Laboratoř] [moč: Streptococcus agalactiae 10E5 CFU/ml, citlivý na amoxicilin, cotrimoxazol, nitrofurantoin].pdf",
"corrected": "6561150607 2026-06-05 Tipplová, Michaela [Laboratoř] [moč Streptococcus agalactiae 10E5 CFUml, citlivý na amoxicilin, cotrimoxazol, nitrofurantoin].pdf"
},
{
"original": "6708101114 2026-04-22 Pospíšil, Jiří [EKG] [SR 84/min, PQ 150ms, QRS 85ms, QTc 404ms, bez patologických změn ST-T].pdf",
"corrected": "6708101114 2026-04-22 Pospíšil, Jiří [EKG] [SR 84min, PQ 150ms, QRS 85ms, QTc 404ms, bez patologických změn ST-T].pdf"
},
{
"original": "9651301253 2026-06-10 Kut Citores, Markéta [Uritex] [moč GLU +- 5.5 mmolL, ostatní v normě].pdf",
"corrected": "9651301253 2026-06-10 KutCitores, Markéta [Uritex] [moč GLU +- 5.5 mmolL, ostatní v normě].pdf"
},
{
"original": "5606051143 2026-06-10 Zána, Jan [EKG] [bez hodnocení].pdf",
"corrected": "5606051143 2026-06-10 Zána, Jan [EKG] [bez hodnocení].pdf"
},
{
"original": "0057130183 2026-05-27 Kreibichová, Jiřina [Souhlas s úhradou] [G809 Mozková obrna NS, léčebně rehab. péče, platnost do 26AUG2026].pdf",
"corrected": "0057130183 2026-05-27 Kreibichová, Jiřina [schválení lázně] [G809 Mozková obrna NS, léčebně rehab. péče, platnost do 26AUG2026].pdf"
},
{
"original": "6358097207 2026-06-08 Broulímová, Marija [rozhodnutí ZP] [návrh schválen, lázně VI/3 kořenové syndromy, 21 dní, platnost do 08.12.2026].pdf",
"corrected": "6358097207 2026-06-08 Broulímová, Marija [schválení lázně] [návrh schválen, lázně VI3 kořenové syndromy, 21 dní, platnost do 08.12.2026].pdf"
},
{
"original": "štoček.pdf",
"corrected": "8910193336 2026-06-03 Štoček, Martin [výpis z dokumentace] [od předchozího PL].pdf"
},
{
"original": "7353270419 2026-03-16 Rusková, Jaroslava [Laboratoř] [dg. D830 — výsledky bez viditelných hodnot (strana neúplná)].pdf",
"corrected": "7353270419 2026-03-16 Rusková, Jaroslava [Laboratoř] [dg. D830 — alergologie výsledky na dalších stranách].pdf"
},
{
"original": "7353270419 2026-06-11 Rusková, Jaroslava [EKG] [bez hodnocení].pdf",
"corrected": "7353270419 2026-06-11 Rusková, Jaroslava [EKG] [bez hodnocení].pdf"
},
{
"original": "7602044780 2026-04-19 Suchý, Vladimír [domácí péče] [1 do 18JUL2026, 06315 1xd3xt, 06330 1xd3xt, 06137 ad hoc].pdf",
"corrected": "7602044780 2026-04-19 Suchý, Vladimír [domácí péče] [1 do 31MAY2026, 06315 1xd3xt, 06330 1xd3xt, 06137 ad hoc].pdf"
},
{
"original": "460509135 2026-04-29 Novotný, Miroslav [domácí péče] [6 do 28JUL2026, 06315 1xd3xt, 06329 1xd3xt, 06137 ad hoc].pdf",
"corrected": "460509135 2026-04-29 Novotný, Miroslav [domácí péče] [6 do 30JUN2026 06315 1xd3xt, 06329 1xd3xt, 06137 ad hoc].pdf"
},
{
"original": "380314026 2026-06-05 Chomát, Jiří [Laboratoř] [dg. M5449, S_Urea 9.32↑, CHRIG5, S_ALP 2.17↑].pdf",
"corrected": "380314026 2026-06-05 Chomát, Jiří [Laboratoř] [dg. M5449, S_Urea 9.32↑, CHRIG2, S_ALP 2.17↑].pdf"
},
{
"original": "391111080 2026-06-03 Veltruský, Jaroslav [Laboratoř] [dg. I10, Urea 18.15↑, Krea 122↑, CHRIG3b, GGT 3.61↑, ALP 2.32↑, VitB12 710↑, NT-proBNP 615↑, Hb 124↓, Trombo 144↓].pdf",
"corrected": "391111080 2026-06-03 Veltruský, Jaroslav [Laboratoř] [dg. I10, Urea 18.15↑, Krea 122↑, CHRIG3a, GGT 3.61↑, ALP 2.32↑, VitB12 710↑, NT-proBNP 615↑, Hb 124↓, Trombo 144↓].pdf"
},
{
"original": "401120069 2026-05-28 Císař, Petr [LZ hematologie] [kontrola, CLL z B-lymfocytů, B-CLL/SLL 28% malých monoklon. B lymfocytů, del 13q14].pdf",
"corrected": "401120069 2026-05-28 Císař, Petr [LZ hematologie] [kontrola, CLL z B-lymfocytů, B-CLLSLL 28% malých monoklon. B lymfocytů, del 13q14].pdf"
},
{
"original": "425915482 2026-05-24 Lebedová, Zdenka [PZ lázeňská] [26APR202624MAY2026, st.p. fract. femoris+humeri l.dx., vertebrogenní sy, DM2, polyneuropatie DKK].pdf",
"corrected": "425915482 2026-05-24 Lebedová, Zdenka [PZ lázně] [26APR202624MAY2026, st.p. fract. femoris+humeri l.dx., vertebrogenní sy, DM2, polyneuropatie DKK].pdf"
},
{
"original": "476014105 2026-03-24 Šmídová, Zdeňka [předoperační příprava] [TEP kolenního kloubu, nástup 22.06.2026, výkon 23.06.2026, albumin mimo normu].pdf",
"corrected": "476014105 2026-03-24 Šmídová, Zdeňka [žádost o předoperační vyšetření] [TEP kolenního kloubu, nástup 22.06.2026, výkon 23.06.2026, albumin mimo normu].pdf"
},
{
"original": "476014105 2026-05-25 Šmídová, Zdeňka [LZ gynekologie] [osteopenie, mírný sestup přední stěny poševní, ko 10/26].pdf",
"corrected": "476014105 2026-05-25 Šmídová, Zdeňka [LZ gynekologie] [osteopenie, mírný sestup přední stěny poševní, ko 1026].pdf"
},
{
"original": "5458071212 2026-06-05 Zívrová, Helena [LZ gastroenterologie] [kontrola, CN extenzivní postižení ilea, switch na ustekinumab 3/2025].pdf",
"corrected": "5458071212 2026-06-05 Zívrová, Helena [LZ gastroenterologie] [kontrola, CN extenzivní postižení ilea, switch na ustekinumab 32025].pdf"
},
{
"original": "5853126928 2026-06-09 Fialová, Marta [Laboratoř] [dg. E78, C_CKD-EPI 1.45 ml/s → CHRIG2, S_Na 141↑].pdf",
"corrected": "5853126928 2026-06-09 Fialová, Marta [Laboratoř] [dg. E78, C_CKD-EPI 1.45 mls → CHRIG2, S_Na 141↑].pdf"
},
{
"original": "7356020441 2026-06-09 Billouz, Hana [Laboratoř] [Stěr/Výtěr nos primokultivace: Negativní].pdf",
"corrected": "7356020441 2026-06-09 Billouz, Hana [Laboratoř] [StěrVýtěr nos primokultivace Negativní].pdf"
},
{
"original": "8001030422 2026-05-15 Kalous, Petr [Laboratoř] [dg. M790, S_Anti-CCP IgG <1.0 negativní].pdf",
"corrected": "8001030422 2026-05-15 Kalous, Petr [Laboratoř] [dg. M790, S_Anti-CCP IgG 1.0 negativní].pdf"
},
{
"original": "425915482 2026-05-04 Lebedová, Zdenka [deník krevního tlaku] [27APR04MAY2026, Prestance 5/5mg ráno, Agen 100mg večer].pdf",
"corrected": "425915482 2026-05-04 Lebedová, Zdenka [domácí měření TK] [27APR04MAY2026, Prestance 55mg ráno, Agen 100mg večer].pdf"
},
{
"original": "536117166 2026-06-15 Jiráková, Božena [EKG] [bez hodnocení].pdf",
"corrected": "536117166 2026-06-15 Jiráková, Božena [EKG] [bez hodnocení].pdf"
},
{
"original": "7355180789 2026-06-15 Švecová, Jitka [EKG] [bez hodnocení].pdf",
"corrected": "7355180789 2026-06-15 Švecová, Jitka [EKG] [bez hodnocení].pdf"
},
{
"original": "7857173940 2026-06-15 Bytsiv, Lyubov [EKG] [bez hodnocení].pdf",
"corrected": "7857173940 2026-06-15 Bytsiv, Lyubov [EKG] [bez hodnocení].pdf"
},
{
"original": "0562280048 2026-06-16 [EKG] [bez hodnocení].pdf",
"corrected": "0562280048 2026-06-16 [EKG] [bez hodnocení].pdf"
},
{
"original": "7751120333 2026-06-10 Šmídová, Šárka [Laboratoř] [B_MPV 11 (↑), S_anti-HBs >1000 arbj (↑), eGFR , vit.D 43.8 nmol/l].pdf",
"corrected": "7751120333 2026-06-10 Šmídová, Šárka [Laboratoř] [B_MPV 11 (↑), S_anti-HBs 1000 arbj (↑), eGFR , vit.D 43.8 nmoll].pdf"
},
{
"original": "891209 2026-06-15 [domácí měření TK] [18MAY15JUN2026, průměr 13980, hypertenze 11d, zvýšený TK 8d].pdf",
"corrected": "891209 2026-06-15 [Holter TK] [18MAY15JUN2026, průměr 139_80, hypertenze 11d, zvýšený TK 8d].pdf"
},
{
"original": "7952090443 Kalousová, Eva split_011.pdf",
"corrected": "7952090443 2026-06-09 Kalousová, Eva [LZ urologie] [recidivující IMC].pdf"
},
{
"original": "7952090443 Kalousová, Eva split_012.pdf",
"corrected": "7952090443 2026-06-02 Kalousová, Eva [kultivace moč] [negativní].pdf"
},
{
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr: Astenie, BMI 16.43].pdf",
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr Astenie, BMI 16.43].pdf"
},
{
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 117/74].pdf",
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 11774].pdf"
},
{
"original": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62/min, norma, LK norm, způsobilý ke sportu].pdf",
"corrected": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62min, norma, LK norm, způsobilý ke sportu].pdf"
},
{
"original": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG: sinus fr 67/min, bez abnorm. nálezů, způsobilý ke sportu].pdf",
"corrected": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG sinus fr 67min, bez abnorm. nálezů, způsobilý ke sportu].pdf"
},
{
"original": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AU/ml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf",
"corrected": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AUml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf"
},
{
"original": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH <0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf",
"corrected": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH 0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf"
} }
] ]
@@ -0,0 +1,7 @@
{
"Z230": {
"_comment": "3 monitory: primární 3840x2160 (x=0), vlevo 1920x1200 (x=-1920), vpravo 1920x1200 (x=3840)",
"duplicity_viewer": { "x": 0, "y": 0, "w": 3840, "h": 1800 },
"rename_dialog": { "x": 0, "anchor": "bottom" }
}
}
+44 -5
View File
@@ -14,12 +14,14 @@ Tato pravidla platí vždy při generování polí `poznamka` a `nazev_souboru`.
3. Když je dokument typ "Laboratoř", do `poznamka` uváděj POUZE hodnoty mimo normu (patologické nálezy) — hodnoty v normě vynech. **Osmolalitu séra (Osmolalita, Osm, osmolality) NIKDY nezmiňuj — ani když je mimo normu, ani v jakékoli zkratce.** Toto je absolutní výjimka: osmolalita se do názvu souboru ani do poznámky nepíše nikdy za žádných okolností. Chybně: `C_Osmolalita 293 (↑)` — správně: tuto hodnotu zcela vynech. 3. Když je dokument typ "Laboratoř", do `poznamka` uváděj POUZE hodnoty mimo normu (patologické nálezy) — hodnoty v normě vynech. **Osmolalitu séra (Osmolalita, Osm, osmolality) NIKDY nezmiňuj — ani když je mimo normu, ani v jakékoli zkratce.** Toto je absolutní výjimka: osmolalita se do názvu souboru ani do poznámky nepíše nikdy za žádných okolností. Chybně: `C_Osmolalita 293 (↑)` — správně: tuto hodnotu zcela vynech.
4. Pokud laboratorní výsledky obsahují glomerulární filtraci — bývá označena jako eGFR, CKD-EPI nebo CK-EPI — do `poznamka` nikdy nepiš číselnou hodnotu eGFR. Místo toho uveď pouze klasifikaci dle stadií CHRIG1CHRIG5. 4. Pokud laboratorní výsledky obsahují glomerulární filtraci — bývá označena jako eGFR, CKD-EPI nebo CK-EPI — do `poznamka` nikdy nepiš číselnou hodnotu eGFR. Místo toho uveď pouze klasifikaci dle stadií CHRIG1CHRIG5.
- **Jednotka:** Nejprve zkontroluj jednotku uvedenou v laboratoři: - **NEJDŮLEŽITĚJŠÍ — jednotka:** Hodnota glomerulární filtrace bývá v ČR uvedena ve **dvou různých jednotkách** a klasifikace stadia se MUSÍ dělat až po převodu na ml/min:
- Pokud je hodnota v **ml/s** nebo **ml/sec** (typicky malá čísla jako 0.8, 1.14, 1.5…), přenásob ×60 pro převod na ml/min. - **ml/s** (resp. ml/sec, ml/s/1.73m²) — typicky malá čísla cca 0.22.3 (např. 0.8, **1.27**, 1.5). Tuto hodnotu **přenásob ×60**, abys dostal ml/min.
- Pokud je hodnota v **ml/min** nebo **ml/min/1.73m²** (typicky velká čísla jako 55, 68, 90…), použij přímo. - **ml/min** (resp. ml/min/1.73m²) — typicky velká čísla 5140 (např. 55, 68, 90). Použij přímo.
- **Klasifikace** (v ml/min/1.73m²): ≥ 90 → CHRIG1, 6089 → CHRIG2, 4559 → CHRIG3a, 3044 → CHRIG3b, 1529 → CHRIG4, < 15 → CHRIG5. - **POZOR na typickou chybu:** malé číslo jako `1.27` je v **ml/s**, tj. `1.27 × 60 = 76 ml/min → CHRIG2`. NIKDY ho neklasifikuj jako by bylo v ml/min (76 by jinak vyšlo špatně jako CHRIG5). Pokud je hodnota menší než ~3, je téměř jistě v ml/s a patří přenásobit ×60.
- Prahové hodnoty pro orientaci při jednotce ml/s: ≥ 1.50 → G1, 1.001.49 → G2, 0.750.99 → G3a, 0.500.74 → G3b, 0.250.49 → G4, < 0.25 → G5. - **Klasifikace** (vždy až v ml/min/1.73m²): ≥ 90 → CHRIG1, 6089 → CHRIG2, 4559 → CHRIG3a, 3044 → CHRIG3b, 1529 → CHRIG4, < 15 → CHRIG5.
- Prahové hodnoty pro orientaci přímo při jednotce ml/s: ≥ 1.50 → G1, 1.001.49 → **G2**, 0.750.99 → G3a, 0.500.74 → G3b, 0.250.49 → G4, < 0.25 → G5.
- Klasifikaci uváděj pouze pokud je CHRIG2 nebo horší (tj. eGFR < 90 ml/min nebo < 1.50 ml/s) — CHRIG1 je v normě, nezmiňuj ho. - Klasifikaci uváděj pouze pokud je CHRIG2 nebo horší (tj. eGFR < 90 ml/min nebo < 1.50 ml/s) — CHRIG1 je v normě, nezmiňuj ho.
- Příklady: `1.27 ml/s → CHRIG2`, `0.92 ml/s → CHRIG3a`, `0.55 ml/s → CHRIG3b`, `68 ml/min → CHRIG2`, `38 ml/min → CHRIG3b`.
5. Když je dokument typ "Laboratoř" a zpráva obsahuje diagnózu (dg., dg:, diagnóza), umísti ji do `nazev_souboru` jako první část druhé závorky, tedy: `[Laboratoř] [dg. XY00 - stručná poznamka]`. 5. Když je dokument typ "Laboratoř" a zpráva obsahuje diagnózu (dg., dg:, diagnóza), umísti ji do `nazev_souboru` jako první část druhé závorky, tedy: `[Laboratoř] [dg. XY00 - stručná poznamka]`.
6. Zkratky a pojmenování: slovo „sono" (sonografie/ultrazvuk) piš vždy malými písmeny — `sono břicha`, `sono ŠŽ`, nikoli `SONO`. Štítnou žlázu označuj vždy zkratkou `ŠŽ`. Sonografii prsu/prsů (sono mamm., sono mamografie, sono mamma apod.) piš vždy jako `sono prsů`. Denzitometrii (DEXA, DXA, denzitometrie) piš vždy pouze jako `[DXA]` — bez prefixu LZ. Algologii piš vždy jako `[LZ léčba bolesti]`. Dermatovenerologii (dermatologie, dermatovenerologie, kožní) piš vždy jako `[LZ kožní]`. Angiologii piš vždy jako `[LZ cévní]`. 6. Zkratky a pojmenování: slovo „sono" (sonografie/ultrazvuk) piš vždy malými písmeny — `sono břicha`, `sono ŠŽ`, nikoli `SONO`. Štítnou žlázu označuj vždy zkratkou `ŠŽ`. Sonografii prsu/prsů (sono mamm., sono mamografie, sono mamma apod.) piš vždy jako `sono prsů`. Denzitometrii (DEXA, DXA, denzitometrie) piš vždy pouze jako `[DXA]` — bez prefixu LZ. Algologii piš vždy jako `[LZ léčba bolesti]`. Dermatovenerologii (dermatologie, dermatovenerologie, kožní) piš vždy jako `[LZ kožní]`. Angiologii piš vždy jako `[LZ cévní]`.
7. V číselných hodnotách VŽDY používej desetinnou tečku, nikoli desetinnou čárku. Toto pravidlo platí absolutně pro všechna čísla v `poznamka` i `nazev_souboru` — např. `TG 4.73`, nikoli `TG 4,73`. 7. V číselných hodnotách VŽDY používej desetinnou tečku, nikoli desetinnou čárku. Toto pravidlo platí absolutně pro všechna čísla v `poznamka` i `nazev_souboru` — např. `TG 4.73`, nikoli `TG 4,73`.
@@ -32,3 +34,40 @@ Tato pravidla platí vždy při generování polí `poznamka` a `nazev_souboru`.
- Příklad výsledného názvu: `[Laboratoř] [sideropenická anémie, Hb 98, MCV 71, Fe 5.2]` - Příklad výsledného názvu: `[Laboratoř] [sideropenická anémie, Hb 98, MCV 71, Fe 5.2]`
9. Jaterní enzymy (ALT, AST, GGT, ALP, LD/LDH) a bilirubin — hodnoty pod dolní hranicí normy (snížené) nezmiňuj v `poznamka` ani v `nazev_souboru`. Uváděj pouze hodnoty nad normu (zvýšené). 9. Jaterní enzymy (ALT, AST, GGT, ALP, LD/LDH) a bilirubin — hodnoty pod dolní hranicí normy (snížené) nezmiňuj v `poznamka` ani v `nazev_souboru`. Uváděj pouze hodnoty nad normu (zvýšené).
10. Druhá závorka pro LZ a PZ — obsah a pořadí: Pro dokumenty typu LZ (lékařská zpráva) a PZ (propouštěcí zpráva) tvoří druhou závorku tyto části v tomto pořadí (oddělené čárkou):
a) **Typ návštěvy** — uveď pouze pokud je explicitně rozpoznatelný ze zprávy:
- `kontrola` — plánovaná kontrola (např. „plánovaná kontrola", „přichází na kontrolu")
- `neplánovaná kontrola` — pokud je výslovně uvedeno, že kontrola nebyla plánovaná
- `akutní` — pacient přichází do akutní ambulance nebo cestou RZS/záchranné služby
- Pokud typ návštěvy není ve zprávě uveden, tuto část zcela vynech (nepsat žádný fallback).
b) **Hlavní diagnóza** — získej z části „Diagnózy", „Závěr" nebo „Dg." — uveď první (hlavní) diagnózu, která je obvykle důvodem návštěvy. Stručně, výstižně.
c) **Termín příští plánované kontroly** — pokud je na konci dokumentu uveden konkrétní plánovaný termín příští kontroly (např. „jaro 2027", „za 3 měsíce", „ročně"), umísti ho jako **poslední část druhé závorky**.
- Uváděj pouze explicitně naplánované termíny — formát: `ko` + termín bez mezery, např. `ko jaro2027`, `ko za6m`, `ko ročně`.
- **Nezahrn** podmíněné návštěvy jako „dle obtíží", „při zhoršení", „při hematurii ihned" apod. — ty jsou samozřejmé a do názvu nepatří.
- Pokud dokument žádný plánovaný termín neobsahuje, tuto část vynech.
- Příklad (s typem návštěvy): `[LZ kardiologie] [kontrola, ICHS, ko za3m]`
- Příklad (bez typu návštěvy): `[LZ neurologie] [migréna, pokračovat v léčbě]`
- Příklad akutní: `[LZ interna] [akutní, dekompenzovaná hypertenze, hospitalizace]`
- Příklad s termínem kontroly: `[LZ urologie] [kontrola, hematurie microsc., angiomyolipoma renis, ko jaro2027]`
- Pro PZ zůstává datum hospitalizace jako první (před typem návštěvy), viz pravidlo 2.
11. Datum v názvu souboru nesmí být v budoucnosti: Pokud datum nalezené na zprávě a navrhované pro název souboru je pozdější než dnešní datum, je to chyba (např. špatně rozpoznané číslo). Hledej na zprávě jiné datum. Pokud žádné vhodné datum nenajdeš, použij dnešní datum.
12. Poukaz domácí péče (DP): Dokument nadepsaný „POUKAZ NA VYŠETŘENÍ / OŠETŘENÍ DP" nebo „poukaz domácí péče" se pojmenovává takto:
- První závorka: vždy `[domácí péče]` (bez prefixu LZ/PZ).
- Datum souboru: pole „Datum" na poukazu (datum vystavení), ve formátu YYYY-MM-DD.
- Druhá závorka obsahuje v tomto pořadí, odděleno čárkou:
a) **Číslo poukazu** — pole „Pořadové číslo poukazu" (celé číslo, např. `1`).
b) **Platnost** — „do DDMMMYYYY" kde datum je z pole „Platnost do" (měsíc třemi velkými písmeny anglicky, bez mezer), např. `do 30JUN2026`.
c) **Výkony** — každý výkon (kód ze sloupce „Požadováno") se uvede jako:
- `{kód} ad hoc` — pokud je u výkonu uvedeno **0x týdně** (bez ohledu na četnost denně); znamená to výkon pouze dle potřeby, ne na pravidelné bázi.
- `{kód} {N}xd{M}xt` — pokud je týdenní četnost M > 0; N = četnost denně, M = četnost týdně. Např. pro 1x denně 3x týdně: `06313 1xd3xt`.
- Příklad (oba výkony ad hoc): `[domácí péče] [1 do 30JUN2026, 06313 ad hoc, 06323 ad hoc]`
- Příklad (pravidelné výkony): `[domácí péče] [2 do 31AUG2026, 06313 1xd5xt, 06321 2xd7xt]`
13. Posudek na řidičské oprávnění: Lékařský posudek o zdravotní způsobilosti k řízení motorových vozidel má v první závorce vždy `[posudek ŘP]` — bez prefixu LZ, nikdy varianty jako `[lékařský posudek řidič]`, `[posudek řidičský průkaz]` apod.
- Příklad: `400828108 2026-06-09 Šebek, Josef [posudek ŘP] [zdravotně způsobilý s podmínkou A1, AM, B1, B s brýlemi, platnost do 09.06.2028]`
14. Prohlášení posuzované osoby ke zdravotní způsobilosti pro řidičské oprávnění: První závorka je vždy `[prohlášení ŘP]` — nikdy varianty jako `[Prohlášení zdravotní způsobilosti]`, `[prohlášení posuzované osoby]` apod.
- Příklad: `400828108 2026-06-09 Šebek, Josef [prohlášení ŘP] [cítí se zdráv, užívá metformin, ACI, léky na kyselinu močovou, na tuky]`
+31
View File
@@ -0,0 +1,31 @@
# ============================================================================
# central_logging.py — stabilní import shim
# Verze: 1.0
# Datum: 2026-06-08
# Popis: Importovatelný název pro knihovnu centrálního logování.
# Vlastní implementace je ve verzovaném souboru
# central_logging_v1.0.py (konvence: verze ve jméně). Python však
# neumí importovat název s tečkou, takže ho zde načteme přes
# importlib a re-exportujeme veřejné API.
#
# Při vydání nové verze stačí přepnout VERSION_FILE níže.
#
# Použití ve skriptech:
# from central_logging import setup_logging
# log = setup_logging("muj_skript")
# ============================================================================
import importlib.util
from pathlib import Path
VERSION_FILE = "central_logging_v1.0.py" # <- při upgrade přepni sem novou verzi
_path = Path(__file__).resolve().parent / VERSION_FILE
_spec = importlib.util.spec_from_file_location("central_logging_impl", _path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
# re-export veřejného API
setup_logging = _mod.setup_logging
CentralLogHandler = _mod.CentralLogHandler
__all__ = ["setup_logging", "CentralLogHandler"]
+320
View File
@@ -0,0 +1,320 @@
# ============================================================================
# central_logging_v1.0.py
# Verze: 1.0
# Datum: 2026-06-08
# Autor: Vladimír Buzalka
# Popis: Drop-in knihovna pro centrální logování do Grafana Loki přes
# FastAPI bránu (log_gateway). Přidává se VEDLE stávajícího
# souborového logování — jediným voláním setup_logging().
#
# Návrh (proč takhle):
# - JEN standardní knihovna (urllib) — nevyžaduje pip install ve všech
# skriptech projektu.
# - Neblokující: emit() jen vloží záznam do fronty, odesílá vlákno na
# pozadí v dávkách (batch). Skript se logováním nezdrží.
# - Odolné proti výpadku: když je gateway nedostupná, dávka spadne do
# lokálního spool souboru (.ndjson) a pošle se při příštím úspěchu.
# => žádné logy se neztratí, i kdyby server byl chvíli dole.
# - keep_file=True ponechá původní souborové logování. Po měsíci, až
# bude centrál ověřený, stačí zavolat s keep_file=False (nebo nastavit
# ENV CENTRAL_LOG_KEEP_FILE=0) a soubory se přestanou psát.
#
# Použití (minimum):
# from central_logging_v1.0 import setup_logging
# log = setup_logging("parse_emails_graph")
# log.info("start")
# log.error("něco selhalo: %s", err)
#
# Konfigurace přes ENV (s rozumnými defaulty):
# CENTRAL_LOG_GATEWAY http://192.168.1.76:8770
# CENTRAL_LOG_TOKEN sdílené tajemství (musí sedět s gateway)
# CENTRAL_LOG_ENV prod | test | dev (default prod)
# CENTRAL_LOG_KEEP_FILE 1 | 0 (default 1 = piš i soubory)
# CENTRAL_LOG_LEVEL INFO | ERROR | ... (default INFO)
# CENTRAL_LOG_SPOOL_DIR adresář pro spool (default vedle skriptu)
# ============================================================================
from __future__ import annotations
import os
import sys
import json
import time
import atexit
import socket
import logging
import threading
import urllib.request
import urllib.error
from collections import deque
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional
# ---------------------------------------------------------------------------
# Výchozí konfigurace
# ---------------------------------------------------------------------------
DEFAULT_GATEWAY = os.environ.get("CENTRAL_LOG_GATEWAY", "http://192.168.1.76:8770")
DEFAULT_TOKEN = os.environ.get("CENTRAL_LOG_TOKEN", "change-this-shared-secret")
DEFAULT_ENV = os.environ.get("CENTRAL_LOG_ENV", "prod")
DEFAULT_LEVEL = os.environ.get("CENTRAL_LOG_LEVEL", "INFO").upper()
FLUSH_INTERVAL = 2.0 # s — jak často odeslat nasbíranou dávku
BATCH_MAX = 200 # max záznamů v jedné dávce
QUEUE_MAX = 50_000 # ochrana proti přetečení paměti
HTTP_TIMEOUT = 5.0 # s — timeout odeslání do gateway
SPOOL_REPLAY_MAX = 1000 # max záznamů přehraných ze spoolu na jeden cyklus
class _GatewaySender:
"""Vlákno na pozadí: sbírá záznamy z fronty a posílá je do gateway
v dávkách. Při neúspěchu zapisuje do spool souboru a později přehraje."""
def __init__(self, app_name: str, gateway: str, token: str, env: str, spool_dir: Path):
self.app_name = app_name
self.host = socket.gethostname()
self.gateway = gateway.rstrip("/")
self.token = token
self.env = env
self.spool_file = spool_dir / f"central_logging_spool_{app_name}.ndjson"
spool_dir.mkdir(parents=True, exist_ok=True)
self._queue: Deque[Dict[str, Any]] = deque(maxlen=QUEUE_MAX)
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, name=f"central-log-{app_name}", daemon=True)
self._thread.start()
# -- veřejné --------------------------------------------------------
def submit(self, record: Dict[str, Any]) -> None:
with self._lock:
self._queue.append(record)
def flush_and_stop(self, timeout: float = 5.0) -> None:
self._stop.set()
self._thread.join(timeout=timeout)
# poslední pokus o odeslání toho, co zbylo
self._drain_once(final=True)
# -- vnitřní --------------------------------------------------------
def _run(self) -> None:
while not self._stop.is_set():
time.sleep(FLUSH_INTERVAL)
try:
self._replay_spool()
self._drain_once()
except Exception: # noqa: BLE001 — logování se nikdy nesmí zhroutit
pass
def _pop_batch(self) -> List[Dict[str, Any]]:
batch: List[Dict[str, Any]] = []
with self._lock:
while self._queue and len(batch) < BATCH_MAX:
batch.append(self._queue.popleft())
return batch
def _drain_once(self, final: bool = False) -> None:
while True:
batch = self._pop_batch()
if not batch:
return
ok = self._send(batch)
if not ok:
self._spool(batch)
if final and not self._queue:
return
def _send(self, records: List[Dict[str, Any]]) -> bool:
payload = json.dumps({
"app": self.app_name,
"host": self.host,
"env": self.env,
"records": records,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"{self.gateway}/log/batch",
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.token}",
},
)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return 200 <= resp.status < 300
except Exception: # noqa: BLE001 — síť/timeout/HTTP error -> spool
return False
# -- spool (fallback při výpadku) -----------------------------------
def _spool(self, records: List[Dict[str, Any]]) -> None:
try:
with open(self.spool_file, "a", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001
pass
def _replay_spool(self) -> None:
if not self.spool_file.exists() or self.spool_file.stat().st_size == 0:
return
# načti dávku ze spoolu
try:
with open(self.spool_file, "r", encoding="utf-8") as f:
lines = f.readlines()
except Exception: # noqa: BLE001
return
if not lines:
return
chunk = lines[:SPOOL_REPLAY_MAX]
records = []
for ln in chunk:
ln = ln.strip()
if ln:
try:
records.append(json.loads(ln))
except Exception: # noqa: BLE001
pass
if records and self._send(records):
# úspěch -> odeber přehrané řádky ze spoolu
remaining = lines[SPOOL_REPLAY_MAX:]
try:
if remaining:
with open(self.spool_file, "w", encoding="utf-8") as f:
f.writelines(remaining)
else:
self.spool_file.unlink(missing_ok=True)
except Exception: # noqa: BLE001
pass
class CentralLogHandler(logging.Handler):
"""logging.Handler, který předává záznamy senderu na pozadí."""
def __init__(self, sender: _GatewaySender):
super().__init__()
self._sender = sender
def emit(self, record: logging.LogRecord) -> None:
try:
# msg = jen samotná zpráva; čas/úroveň/logger jdou do labelů a polí
rec: Dict[str, Any] = {
"ts": record.created,
"level": record.levelname,
"msg": record.getMessage(),
"logger": record.name,
"func": record.funcName,
"line": record.lineno,
}
# POZOR: formatException je metoda Formatteru, ne Handleru —
# proto použij vlastní Formatter, jinak by AttributeError shodil
# celý záznam (a tracebacky by se ztrácely).
if record.exc_info:
rec["exc"] = logging.Formatter().formatException(record.exc_info)
self._sender.submit(rec)
except Exception: # noqa: BLE001 — handler nikdy nesmí shodit aplikaci
pass
def setup_logging(
app_name: str,
*,
log_file: Optional[str] = None,
keep_file: Optional[bool] = None,
level: Optional[str] = None,
gateway: Optional[str] = None,
token: Optional[str] = None,
env: Optional[str] = None,
quiet_loggers: Optional[List[str]] = None,
fmt: str = "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt: str = "%Y-%m-%d %H:%M:%S",
spool_dir: Optional[str] = None,
) -> logging.Logger:
"""Nastaví root logger se dvěma cíli:
1) souborový handler (RotatingFileHandler) — stávající chování,
2) centrální handler do Loki přes gateway (na pozadí).
Args:
app_name: label aplikace v Loki (např. "parse_emails_graph").
log_file: cesta k log souboru. Default <app_name>.log vedle skriptu.
keep_file: piš i do souboru? Default z ENV CENTRAL_LOG_KEEP_FILE (1).
Po měsíci ověřování nastav False -> jen centrál.
level: min. úroveň, default ENV CENTRAL_LOG_LEVEL nebo INFO.
gateway/token/env: override ENV defaultů.
Returns:
nakonfigurovaný root logger (lze i logging.getLogger()).
"""
# Konfiguraci čteme z os.environ AŽ TADY (call-time), ne při importu modulu.
# Důvod: skripty často načítají vlastní .env (do os.environ) až po importu
# této knihovny — kdybychom četli při importu, token/gateway bychom minuli.
gw = gateway or os.environ.get("CENTRAL_LOG_GATEWAY", "http://192.168.1.76:8770")
tok = token or os.environ.get("CENTRAL_LOG_TOKEN", "change-this-shared-secret")
ev = env or os.environ.get("CENTRAL_LOG_ENV", "prod")
lvl_name = (level or os.environ.get("CENTRAL_LOG_LEVEL", "INFO")).upper()
lvl = getattr(logging, lvl_name, logging.INFO)
if keep_file is None:
keep_file = os.environ.get("CENTRAL_LOG_KEEP_FILE", "1") not in ("0", "false", "False")
root = logging.getLogger()
root.setLevel(lvl)
# Ztiš upovídané knihovny (jinak root@INFO chytá jejich šum do centrálu).
# Předej quiet_loggers=[] pro vypnutí, nebo vlastní seznam.
_default_quiet = ["httpx", "httpcore", "urllib3", "anthropic", "openai",
"PIL", "asyncio", "fdb", "fontTools", "pdfminer"]
for _name in (_default_quiet if quiet_loggers is None else quiet_loggers):
logging.getLogger(_name).setLevel(logging.WARNING)
# odstraň případné staré handlery (idempotentní setup)
for h in list(root.handlers):
root.removeHandler(h)
formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
# 1) Souborový handler (stávající způsob) -------------------------------
if keep_file:
if log_file is None:
base = Path(sys.argv[0]).resolve().parent if sys.argv and sys.argv[0] else Path.cwd()
log_file = str(base / f"{app_name}.log")
fh = RotatingFileHandler(log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8")
fh.setLevel(lvl)
fh.setFormatter(formatter)
root.addHandler(fh)
# 2) Centrální handler do Loki (na pozadí) ------------------------------
spool_base = Path(spool_dir) if spool_dir else (
Path(sys.argv[0]).resolve().parent if sys.argv and sys.argv[0] else Path.cwd()
)
sender = _GatewaySender(
app_name=app_name,
gateway=gw,
token=tok,
env=ev,
spool_dir=spool_base / "_log_spool",
)
ch = CentralLogHandler(sender)
ch.setLevel(lvl)
ch.setFormatter(formatter)
root.addHandler(ch)
# při ukončení skriptu dolij frontu
atexit.register(sender.flush_and_stop)
root.info("central_logging v1.0 inicializováno (app=%s, keep_file=%s, gateway=%s)",
app_name, keep_file, gw)
return root
if __name__ == "__main__":
# rychlý self-test
log = setup_logging("central_logging_selftest", level="DEBUG")
log.info("ahoj z self-testu")
log.warning("varování %d", 42)
try:
1 / 0
except ZeroDivisionError:
log.exception("zachycená výjimka")
print("Self-test odeslán. Zkontroluj Grafanu / spool soubor.")
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RECON ONLY — nic nezakládá, nic neodesílá.
Otevře testovacího pacienta Vladko, otevře "Nový požadavek",
zachytí dostupné typy požadavků a podívá se na formulář "Recept".
Ukládá: screenshoty, HTML, plný GraphQL provoz (request + response).
"""
from pathlib import Path
from datetime import datetime
import sys, json, time
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HERE = Path(__file__).resolve().parent
STATE_FILE = HERE.parent / "medevio_storage.json"
PATIENT_UUID = "0210db7b-8fb0-4b47-b1d8-ec7a10849a63" # Vladko - testovaci aplikace
PATIENT_URL = f"https://my.medevio.cz/mudr-buzalkova/klinika/pacienti?pacient={PATIENT_UUID}"
OUT = HERE / "recon_recept"
OUT.mkdir(exist_ok=True)
GQL_LOG = OUT / f"graphql_{int(time.time())}.jsonl"
def log(msg):
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=150)
context = browser.new_context(storage_state=str(STATE_FILE))
page = context.new_page()
# ---- capture GraphQL request + response bodies ----
def on_response(resp):
try:
req = resp.request
if "graphql" in req.url and req.method == "POST":
rec = {"op": None, "request": None, "response": None,
"status": resp.status}
try:
rec["request"] = json.loads(req.post_data or "{}")
rec["op"] = rec["request"].get("operationName")
except Exception:
pass
try:
rec["response"] = resp.json()
except Exception:
pass
with open(GQL_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
page.on("response", on_response)
log(f"Otevírám kartu pacienta…")
page.goto(PATIENT_URL, wait_until="networkidle")
time.sleep(2)
page.screenshot(path=str(OUT / "01_card.png"), full_page=True)
# ---- detect login / session expiry ----
url_now = page.url
if "login" in url_now or "prihlaseni" in url_now or "auth" in url_now:
log(f"!!! Vypadá to na odhlášení / propadlou session. URL: {url_now}")
(OUT / "_SESSION_EXPIRED.txt").write_text(url_now, encoding="utf-8")
browser.close()
return
# is the card actually visible?
card_ok = False
try:
page.get_by_text("Historie požadavků").wait_for(timeout=8000)
card_ok = True
log("Karta pacienta načtena (vidím 'Historie požadavků').")
except PWTimeout:
log("!!! Nevidím 'Historie požadavků' — možná jiný layout nebo session.")
(OUT / "01_card.html").write_text(page.content(), encoding="utf-8")
if not card_ok:
browser.close()
return
# ---- open "Nový požadavek" ----
try:
page.get_by_role("button", name="Nový požadavek").click()
time.sleep(1.0)
page.screenshot(path=str(OUT / "02_new_request_open.png"), full_page=True)
(OUT / "02_new_request_open.html").write_text(page.content(), encoding="utf-8")
log("Kliknuto 'Nový požadavek'.")
except Exception as e:
log(f"!!! Nepodařilo se kliknout 'Nový požadavek': {e}")
browser.close()
return
# ---- capture all available request-type options (empty query) ----
try:
opts = page.locator("[role='option']").all_text_contents()
(OUT / "03_all_options.txt").write_text(
"\n".join(opts), encoding="utf-8")
log(f"Dostupných typů (bez filtru): {len(opts)}")
except Exception as e:
log(f"options(all) chyba: {e}")
# ---- type 'recept' and capture filtered options ----
try:
page.keyboard.type("recept")
time.sleep(1.0)
opts2 = page.locator("[role='option']").all_text_contents()
(OUT / "04_recept_options.txt").write_text(
"\n".join(opts2), encoding="utf-8")
page.screenshot(path=str(OUT / "04_recept_options.png"), full_page=True)
(OUT / "04_recept_options.html").write_text(page.content(), encoding="utf-8")
log(f"Po napsání 'recept' nabízí: {opts2}")
except Exception as e:
log(f"options(recept) chyba: {e}")
log("RECON hotovo — NIC nezaloženo. Zavírám za 3s.")
time.sleep(3)
browser.close()
log(f"Artefakty v: {OUT}")
log(f"GraphQL log: {GQL_LOG}")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Otevře přihlašovací okno Medevia. PŘIHLAŠ SE RUČNĚ.
Skript sám pozná, že už nejsi na přihlašovací stránce, počká na ustálení
a uloží session do medevio_storage.json. Žádné stisknutí Enter není třeba.
"""
from pathlib import Path
from datetime import datetime
import sys, time
from playwright.sync_api import sync_playwright
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HERE = Path(__file__).resolve().parent
STATE_FILE = HERE.parent / "medevio_storage.json"
LOGIN_URL = "https://my.medevio.cz/prihlaseni"
TIMEOUT_S = 300 # 5 minut na přihlášení
def log(msg):
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
def is_logged_in(url: str) -> bool:
return ("medevio.cz" in url
and "prihlaseni" not in url
and "auth" not in url
and "login" not in url)
def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=80)
context = browser.new_context()
page = context.new_page()
page.goto(LOGIN_URL, wait_until="load")
log("=== PŘIHLAS SE v otevřeném okně Medevia ===")
log("Skript čeká, až opustíš přihlašovací stránku…")
deadline = time.time() + TIMEOUT_S
logged = False
while time.time() < deadline:
try:
if is_logged_in(page.url):
# počkej na ustálení redirectů
time.sleep(4)
if is_logged_in(page.url):
logged = True
break
except Exception:
pass
time.sleep(2)
if not logged:
log("!!! Nepřihlášeno do limitu. Session NEULOŽENA.")
browser.close()
return
log(f"Přihlášeno (URL: {page.url}). Ukládám session…")
context.storage_state(path=str(STATE_FILE))
log(f"Session uložena: {STATE_FILE}")
time.sleep(1)
browser.close()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1 @@
https://my.medevio.cz/prihlaseni
+63
View File
@@ -241,6 +241,69 @@ request {
} }
``` ```
### Request Creation (Vytvoření požadavku "Recept na léky") — ODCHYCENO/OVĚŘENO 2026-06-13
Přes API **lze založit požadavek s plně vyplněným pacientským dotazníkem** (oba fieldy),
takže vypadá jako reálné podání pacientem. Funkce: `mcp_medevio.zaloz_pozadavek_recept`.
(Pozn.: lékařské UI „Nový požadavek" pole dotazníku NEzobrazí — ale API je přijme.)
**Dvoukrok (+ volitelně štítek):**
```graphql
# 1) vyplň ECRF formulář → vrátí ecrfFill.id
mutation Step_FillECRFForm($input: FillECRFFormInput!) {
patientEcrfFill: fillECRFForm(input: $input) { id }
}
# input: {
# patientId, sid: "ERECEPT_SIMPLEST_BEZ_DAVKOVANI", stepId: "erecept-gp-request",
# byDoctor: false,
# fields: [{ fieldName: "nazev-leku", value: "<léky>", checkedEnumerations: [] }]
# } → pole "Název léků" v dotazníku
# 2) vytvoř požadavek
mutation ...CreatePatientRequestWithoutReservation($clinicSlug: String!, $input: ...) {
patientRequest: createPatientRequestWithoutReservation(clinicSlug: $clinicSlug, input: $input) { id }
}
# input: {
# patientId, userECRFId, ecrfFillIds: [<id z kroku 1>], medicalRecordIds: [], challengeId: null,
# userNote: "<poznámka>", ← zobrazí se jako pole "Poznámka" v dotazníku
# createdByDoctor: false
# }
```
POZOR: `createPatientRequest` (bez „WithoutReservation") požadavek vytvoří, ale
NEZOBRAZÍ se v žádné frontě — používat `createPatientRequestWithoutReservation`.
| Klíč | Hodnota |
|------|---------|
| ECRF „Recept na léky" `userECRFId` | `79488e86-e9e5-47e3-8b19-7e5229427f23` |
| ECRF `sid` | `ERECEPT_SIMPLEST_BEZ_DAVKOVANI` |
| ECRF `stepId` | `erecept-gp-request` |
| pole 1 `fieldName` | `nazev-leku` (→ „Název léků") |
| pole 2 | `userNote` v create inputu (→ „Poznámka") |
Seznam typů požadavků: `UserEcrfAutocomplete_ListUserECRFsByClinic`.
### Tagy / štítky požadavku — ODCHYCENO 2026-06-13
```graphql
query TagRequestEditModal_ListTags($clinicSlug: String!, $requestId: UUID!) { ... } # seznam štítků + zda jsou přiřazené
mutation TagRequestEditModal_AssignTagToRequest($clinicSlug: String!, $requestId: UUID!, $tagId: UUID!) {
tagRequest: assignTagToPatientRequest(clinicSlug: $clinicSlug, patientRequestId: $requestId, tagId: $tagId) { id }
}
# Vytvoření nového štítku:
mutation TagEditModal_CreateTag($clinicSlug: String!, $input: CreateTagInput!) {
tag: createTag(clinicSlug: $clinicSlug, input: $input) { id name color icon important isOrganizationWide }
}
# input: { name, color (např. "SKY"/"ORCHID"), icon: null, important: false, type: "patient_request", isOrganizationWide: false }
```
| Štítek | tagId | barva |
|--------|-------|-------|
| `CLAUDE` | `c136aeca-0625-4c43-b81f-fc3949ec6ba6` | ORCHID |
| `OVĚŘIT PACIENTA` | `9d3271b3-309d-4d20-93ee-285f3e56ba42` | SKY |
| `NEZAPOMENOUT` | `5bced917-83d2-46db-896c-c8e615de1a69` | GREY |
### Request Detail ### Request Detail
| Operation | Variables | Response | | Operation | Variables | Response |
+8 -1
View File
@@ -709,12 +709,19 @@ ORDER BY h.DATUM DESC
| Nástroj | Popis | | Nástroj | Popis |
|---|---| |---|---|
| `get_patient(idpac)` | Základní info o pacientovi z KAR — jmeno, prijmeni, rc, datnar, pojistovna | | `get_patient(idpac)` | Základní info o pacientovi z KAR — jmeno, prijmeni, rc, datnar, pojistovna |
| `search_patients(query)` | Hledání pacienta podle příjmení/jména/RC, max 50 výsledků | | `search_patients(query, datum_narozeni?)` | Hledání pacienta podle jména/RC, max 50 výsledků |
| `get_patient_timeline(idpac, datum_od?, datum_do?)` | Chronologický přehled z DOCLIST — všechny záznamy pacienta | | `get_patient_timeline(idpac, datum_od?, datum_do?)` | Chronologický přehled z DOCLIST — všechny záznamy pacienta |
| `parse_histdoc_data(idhistdoc)` | Dekóduje DATA blob z HISTDOC — vrátí dict {Kod, Nazev, Pocet, Cena, Stav, Doklad…} | | `parse_histdoc_data(idhistdoc)` | Dekóduje DATA blob z HISTDOC — vrátí dict {Kod, Nazev, Pocet, Cena, Stav, Doklad…} |
| `get_table_info(table)` | Rozšířené info o tabulce — typy sloupců, nullable, PK, počet záznamů | | `get_table_info(table)` | Rozšířené info o tabulce — typy sloupců, nullable, PK, počet záznamů |
| `safe_query(sql, params?)` | SELECT s ochranou — varuje pro velké tabulky bez WHERE, limit 500 řádků | | `safe_query(sql, params?)` | SELECT s ochranou — varuje pro velké tabulky bez WHERE, limit 500 řádků |
### Nové nástroje (přidáno 2026-06-12)
| Nástroj | Popis |
|---|---|
| `search_patients(query, datum_narozeni?)`**rozšířeno** | Jméno nyní bez ohledu na diakritiku a pořadí slov („mateju petr" najde „Petr Matějů"); RC podle číslic; volitelný filtr data narození; nově vrací i datnar a vyrazen |
| `search_patient_by_contact(kontakt)` | Pacient podle e-mailu/telefonu z KARKONTAKT (TYP: 1=pevná, 2=mobil, 3=e-mail); telefony porovnává jen po číslicích, ignoruje +420 a mezery |
| `get_columns_overview(table, sample_rows?)` | Sémantika sloupců — ze vzorku N řádků (výchozí 1000) top 5 hodnot + četnosti per sloupec (např. zjistí, že RECEPT.STORNO je 'T'/'F') |
### Velké tabulky vyžadující WHERE (safe_query varuje automaticky) ### Velké tabulky vyžadující WHERE (safe_query varuje automaticky)
LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK
+106
View File
@@ -0,0 +1,106 @@
"""export_prilohy.py export příloh z Medicus FILES tabulky do souborů
Extrahuje všechny záznamy z FILES, uloží BODY blob jako soubor.
Detekuje dva formáty BODY:
- inline PDF (začíná %PDF)
- reference (začíná magic \xee\xbb\xaa\x0b) → čte z externí MEDICUS_FILES_YYYYMM.fdb
"""
import os
import struct
import sys
sys.path.insert(0, r'U:\OrdinaceProjekt')
from Knihovny.najdi_dropbox import get_dropbox_root
from Knihovny.medicus_db import get_medicus_connection
# ─── Konfigurace ─────────────────────────────────────────────────────────────
OUTPUT_DIR = os.path.join(get_dropbox_root(), r'!!!Days\Downloads Z230\Files')
RECORDS_COUNT = 0 # 0 = všechny záznamy
# Magic bytes identifikující referenci na externí FDB (na reporterovi nenastane)
MAGIC = b'\xee\xbb\xaa\x0b'
# ─── Pomocné funkce ───────────────────────────────────────────────────────────
def parse_body_ref(body_bytes):
"""Vrátí (uid, dbname) pokud BODY je reference na ext FDB, jinak None."""
if not body_bytes or len(body_bytes) < 8:
return None
if body_bytes[:4] != MAGIC:
return None
uid = body_bytes[4:36].decode('ascii')
dblen = struct.unpack_from('<I', body_bytes, 36)[0]
dbname = body_bytes[40:40 + dblen].decode('ascii')
return uid, dbname
def safe_filename(name):
"""Odstraní znaky nevhodné pro název souboru."""
for ch in r'\/:*?"<>|':
name = name.replace(ch, '_')
return name.strip()
# ─── Hlavní logika ────────────────────────────────────────────────────────────
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
conn = get_medicus_connection()
cur = conn.cursor()
limit = f"FIRST {RECORDS_COUNT} " if RECORDS_COUNT > 0 else ""
cur.execute(f"""
SELECT {limit}f.ID, f.IDPAC, f.DATUM, f.FILENAME, f.BODY, f.POZNAMKA
FROM FILES f
ORDER BY f.ID
""")
ok = 0
chyb = 0
for row in cur:
file_id, idpac, datum, filename, body_blob, poznamka = row
# Přečti blob
try:
if hasattr(body_blob, 'read'):
body_bytes = body_blob.read()
body_blob.close()
else:
body_bytes = body_blob or b''
except Exception as e:
print(f" [CHYBA] ID={file_id}: čtení BODY selhalo: {e}")
chyb += 1
continue
# Zjisti formát
ref = parse_body_ref(body_bytes)
if ref:
_, dbname = ref
print(f" [SKIP] ID={file_id}: reference na ext DB {dbname} — nepodporováno")
chyb += 1
continue
pdf_data = body_bytes
# Sestavení cílového názvu: ID_IDPAC_DATUM_filename
datum_str = datum.strftime('%Y-%m-%d') if datum else 'bez-data'
raw_fn = filename or f'soubor_{file_id}.bin'
out_name = f"{file_id:06d}_{idpac:06d}_{datum_str}_{safe_filename(raw_fn)}"
out_path = os.path.join(OUTPUT_DIR, out_name)
# Ulož
with open(out_path, 'wb') as f:
f.write(pdf_data)
print(f" ID={file_id} idpac={idpac} {datum_str} {raw_fn}{len(pdf_data):,} B")
ok += 1
conn.close()
print(f"\nHotovo: {ok} uloženo, {chyb} chyb")
print(f"Výstup: {OUTPUT_DIR}")
if __name__ == '__main__':
main()
+95
View File
@@ -0,0 +1,95 @@
# MedicusFirebird — Firebird 2.5 zrcadlo Medicus DB na toweru
Kontejnerizované zrcadlo ostré Medicus databáze (Firebird) na serveru **tower** (Unraid, 192.168.1.76).
Nahrazuje dosavadní restore na Windows VM **reporter** — tu lze po ověření na Firebird části vypnout.
## Proč
Všechny ostatní DB (MySQL, PostgreSQL, MongoDB, Redis) běží na toweru jako Docker.
Firebird sem logicky patří taky: jeden host, jeden režim záloh/monitoringu, žádná Windows VM navíc.
## Tok dat
```
Ordinace: gbak -> zip -> rsync na tower (~02:15)
Tower: /mnt/user/OrdinaceSynology/MedicusBackup/MEDICUS_RRMMDD_HHMM.zip (zalohy se HROMADI)
restore_medicus.sh (denne):
1) nejnovejsi MEDICUS_*.zip podle nazvu; pokud == last_restored.txt -> skip
2) pocka az velikost prestane rust (probiha-li jeste rsync) + overi unzip -t
3) unzip .fbk -> gbak -r do medicus_new.fdb -> stop+swap+start kontejneru
4) zapise marker (last_restored.txt)
5) GFS retence zaloh (prune_backups.sh)
Kontejner: firebird-medicus -> serve tower:3050 /firebird/data/medicus.fdb
```
Zdrojová DB: **Firebird 2.5.7**, ODS 11.2, dialect 3, page size 8192.
Image: `jacobalberty/firebird:2.5-ss` = **Firebird 2.5.9** (restore 2.5.7 → 2.5.9 v rámci řady OK).
## Soubory
| Soubor | Popis |
|--------|-------|
| `firebird_create.sh` | Jednorázové vytvoření / znovuvytvoření kontejneru |
| `restore_medicus.sh` | Denní rutina: obnova z nejnovější zálohy + retence (cron) |
| `prune_backups.sh` | GFS retence záloh (volá se z restore; lze i samostatně) |
| `verify_firebird.sh` | Kontrola: verze enginu, ODS, počet pacientů |
| `last_restored.txt` | Marker poslední úspěšně restorované zálohy (vzniká za běhu) |
| `restore.log` | Log denních běhů |
## Umístění na toweru
- Skripty: `/mnt/user/Scripts/MedicusFirebird/`
- Data kontejneru: `/mnt/user/appdata/firebird-medicus/fb` (→ `/firebird`, soubor `data/medicus.fdb`)
- Rozbalovací prostor pro `.fbk`: `/mnt/user/appdata/firebird-medicus/work` (→ `/work`)
## Kontejner
```
docker run -d --name firebird-medicus --restart unless-stopped \
-p 3050:3050 -e ISC_PASSWORD=masterkey -e TZ=Europe/Prague \
-v /mnt/user/appdata/firebird-medicus/fb:/firebird \
-v /mnt/user/appdata/firebird-medicus/work:/work \
jacobalberty/firebird:2.5-ss
```
Pozn.: `gbak`/`isql` jsou v `/usr/local/firebird/bin/` (nejsou v PATH → volat plnou cestou).
Hesla jsou v `security2.fdb` (nastaveno přes `ISC_PASSWORD`), ne v `medicus.fdb` — restore dat heslo nemění.
## Robustnost restoru
Zálohy se v adresáři **hromadí** a nejnovější se může právě **přenášet přes rsync**, proto:
- výběr nejnovější podle **názvu** (`RRMMDD_HHMM` → lexikálně = chronologicky)
- **stav** v `last_restored.txt` → když není nic novějšího, nic se nedělá
- **čeká na dokončení přenosu** (velikost se ustálí) a ověří integritu `unzip -t` — nikdy nezpracuje nekompletní soubor
- marker se zapíše **až po úspěšném** restoru; zámek (`flock`) proti souběhu
## Retence záloh (GFS, sekvenční, počítaná)
`prune_backups.sh` drží v adresáři záloh schéma:
1. **30 posledních dní** — nech všechny denní
2. **pak 8 týdnů** — z každého ISO-týdne 1× (nejnovější = konec týdne)
3. **pak 12 měsíců** — z každého měsíce 1× (nejnovější)
4. starší → smazat
Datum se čte **z názvu** (ne mtime). Neparsovatelné názvy se nikdy nemažou.
Bezpečnostní přepínač `DRY_RUN=1` (jen výpis) / `DRY_RUN=0` (maže). V denní rutině řízeno `RETENTION_DRYRUN`
v `restore_medicus.sh` (ostré = 0).
## Připojení klientů (fdb / DSN)
```
192.168.1.76:/firebird/data/medicus.fdb SYSDBA / masterkey, charset win1250
# nebo: tower:/firebird/data/medicus.fdb
```
V `Knihovny/medicus_db.py` je odpovídající záznam v `dsn_map` (klíč `TOWER`).
Cutover skriptů/MCP z reporteru (2.5.7) na tower (2.5.9) = otevřené rozhodnutí.
## Cron (na toweru)
Záloha přistává ~02:15; denní rutina poté. Plánovat přes **User Scripts plugin**
(vzor: `PostgreSQLRestoreFromBackup`), spouštět:
```
/mnt/user/Scripts/MedicusFirebird/restore_medicus.sh # napr. 06:30 denne
```
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Vytvori (nebo znovuvytvori) Firebird 2.5 kontejner = zrcadlo Medicus DB na toweru.
# Spousti se jednorazove pri zakladani / zmene konfigurace.
set -euo pipefail
NAME=firebird-medicus
IMAGE=jacobalberty/firebird:2.5-ss
APPDATA=/mnt/user/appdata/firebird-medicus
FBDIR="$APPDATA/fb" # -> /firebird (data, system, security2.fdb)
WORKDIR="$APPDATA/work" # -> /work (sem se rozbaluje .fbk pred restorem)
PASS=masterkey
mkdir -p "$FBDIR" "$WORKDIR"
# odstran stary kontejner, pokud existuje (data v appdata zustanou)
docker rm -f "$NAME" 2>/dev/null || true
docker run -d \
--name "$NAME" \
--restart unless-stopped \
-p 3050:3050 \
-e ISC_PASSWORD="$PASS" \
-e TZ=Europe/Prague \
-v "$FBDIR":/firebird \
-v "$WORKDIR":/work \
"$IMAGE"
echo "Kontejner $NAME vytvoren. Cekam na start serveru..."
sleep 10
docker ps --filter "name=$NAME" --format "{{.Names}} {{.Status}} {{.Ports}}"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# GFS retence PLNYCH zaloh Medicus (kazda zaloha je kompletni -> mazani ostatnich je bezpecne).
#
# SEKVENCNI, POCITANE tiery (jdou ZA sebou, neprekryvaji se), od nejnovejsiho zpet:
# 1) DENNI : poslednich 30 dni -> nech VSECHNY
# 2) TYDENNI : pak presne 8 tydnu dozadu -> z kazdeho ISO-tydne 1x (nejnovejsi = konec tydne)
# 3) MESICNI : pak presne 12 mesicu dozadu -> z kazdeho mesice 1x (nejnovejsi)
# 4) starsi : smazat
# Reference "ted" = datum NEJNOVEJSI zalohy. Datum se cte Z NAZVU MEDICUS_RRMMDD_HHMM.zip.
#
# BEZPECNOST: DRY_RUN=1 (default) jen vypisuje, NIC nemaze. DRY_RUN=0 skutecne maze.
# Neznamy/neparsovatelny nazev se NIKDY nemaze.
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/mnt/user/OrdinaceSynology/MedicusBackup}"
DAILY_DAYS="${DAILY_DAYS:-30}"
WEEKLY_WEEKS="${WEEKLY_WEEKS:-8}"
MONTHLY_MONTHS="${MONTHLY_MONTHS:-12}"
DRY_RUN="${DRY_RUN:-1}"
date_from_name() { local d="${1#MEDICUS_}"; d="${d:0:6}"; echo "20${d:0:2}-${d:2:2}-${d:4:2}"; }
mapfile -t FILES < <(cd "$BACKUP_DIR" && ls -1 MEDICUS_*.zip 2>/dev/null | sort -r) # nejnovejsi prvni
[ "${#FILES[@]}" -eq 0 ] && { echo "Zadne zalohy v $BACKUP_DIR"; exit 0; }
REF=$(date_from_name "${FILES[0]}")
date -d "$REF" >/dev/null 2>&1 || { echo "CHYBA: nelze precist datum z ${FILES[0]}"; exit 1; }
D_CUT=$(date -d "$REF -${DAILY_DAYS} days" +%F)
echo "REF=$REF denni>=$D_CUT, pak ${WEEKLY_WEEKS}x tydenni, pak ${MONTHLY_MONTHS}x mesicni (starsi smazat)"
declare -A KEEP seen_week seen_month
dn=0; w=0; m=0
for f in "${FILES[@]}"; do
dt=$(date_from_name "$f")
if ! date -d "$dt" >/dev/null 2>&1; then KEEP[$f]="?"; continue; fi # neparsovatelne -> ponechat
if [[ ! "$dt" < "$D_CUT" ]]; then KEEP[$f]="d"; dn=$((dn+1)); continue; fi # 1) denni (30 dni)
if [ "$w" -lt "$WEEKLY_WEEKS" ]; then # 2) tydenni (8x)
wk=$(date -d "$dt" +%G-%V)
[ -z "${seen_week[$wk]:-}" ] && { seen_week[$wk]=1; w=$((w+1)); KEEP[$f]="w"; }
continue
fi
if [ "$m" -lt "$MONTHLY_MONTHS" ]; then # 3) mesicni (12x)
mo=$(date -d "$dt" +%Y-%m)
[ -z "${seen_month[$mo]:-}" ] && { seen_month[$mo]=1; m=$((m+1)); KEEP[$f]="m"; }
continue
fi
done
mode="DRY-RUN (nic se nemaze)"; [ "$DRY_RUN" = "0" ] && mode="OSTRY (maze!)"
echo "=== GFS retence $mode | $BACKUP_DIR ==="
echo "schema: ${DAILY_DAYS}d / ${WEEKLY_WEEKS}t / ${MONTHLY_MONTHS}m | celkem: ${#FILES[@]} | ponechano: ${#KEEP[@]} (denni=$dn tydenni=$w mesicni=$m)"
del=0
for f in "${FILES[@]}"; do
if [ -n "${KEEP[$f]:-}" ]; then
printf ' KEEP [%s] %s\n' "${KEEP[$f]}" "$f"
else
printf ' DEL %s\n' "$f"; del=$((del+1))
[ "$DRY_RUN" = "0" ] && rm -f -- "$BACKUP_DIR/$f"
fi
done
echo "=== ke smazani: $del ==="

Some files were not shown because too many files have changed in this diff Show More