Compare commits
38 Commits
5b0f8aa08b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e981659621 | |||
| e5315b821e | |||
| 19036b58cc | |||
| 0beaffec45 | |||
| 26e44fc721 | |||
| dc07e19179 | |||
| 45c32a37c4 | |||
| 39d33b76f3 | |||
| 6e4305e182 | |||
| 9edfddae95 | |||
| 9b6f89f437 | |||
| 672ee26357 | |||
| e23d61de84 | |||
| 79216dfbdb | |||
| 8142de5216 | |||
| 2bdac59676 | |||
| 9133fe9497 | |||
| 2346ad7739 | |||
| ca39622ddd | |||
| bed5576efa | |||
| 51ee67c7f3 | |||
| f595e60d40 | |||
| a3b1e58a71 | |||
| 2028532eff | |||
| a7f33afb66 | |||
| 4723f9b174 | |||
| 178b0e4164 | |||
| 914452a96d | |||
| c9f94de286 | |||
| d850486eb9 | |||
| 592e6cd2a2 | |||
| 76e9427901 | |||
| d16038d09c | |||
| a29a6845a1 | |||
| e79458d670 | |||
| d5f2dc3925 | |||
| dddd28ae2a | |||
| c1a5909f65 |
@@ -0,0 +1 @@
|
||||
OPENAI_API_KEY=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
|
||||
+12
@@ -12,6 +12,10 @@ __pycache__/
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Secrets (.env s API klíči - nikdy do gitu!)
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Certifikáty (soukromé klíče - nikdy do gitu!)
|
||||
**/*.pfx
|
||||
**/*.p12
|
||||
@@ -26,9 +30,17 @@ __pycache__/
|
||||
# Logy
|
||||
*.log
|
||||
|
||||
# Dočasné zámkové soubory MS Office (vznikají při otevřeném Excelu/Wordu)
|
||||
~$*
|
||||
**/~$*
|
||||
|
||||
# Chrome profily (Playwright) — nikdy do gitu
|
||||
**/chrome_profile/
|
||||
|
||||
# Cookies (session tokeny)
|
||||
**/vozp_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.
@@ -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) |
|
||||
@@ -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í |
|
||||
| `medicus_db.py` | — | Připojení k databázi Medicus (Firebird) |
|
||||
| `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ů
|
||||
|
||||
@@ -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) |
|
||||
| `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) |
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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í.
|
||||
@@ -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`).
|
||||
@@ -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č)
|
||||
@@ -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} Kč "
|
||||
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()
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# stažený obsah a inventura — do gitu nepatří
|
||||
stazeno/
|
||||
euni_kurzy.json
|
||||
+120
@@ -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 1–9
|
||||
(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
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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} Kč"
|
||||
)
|
||||
|
||||
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} Kč")
|
||||
log_line("=" * 80)
|
||||
|
||||
log_line("\nHOTOVO")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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} Kč"
|
||||
)
|
||||
|
||||
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} Kč")
|
||||
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} Kč"
|
||||
)
|
||||
|
||||
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} Kč")
|
||||
log_line("=" * 80)
|
||||
|
||||
log_line("\nHOTOVO")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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} Kč"
|
||||
)
|
||||
|
||||
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} Kč")
|
||||
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)} RČ")
|
||||
|
||||
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
@@ -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í.
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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",
|
||||
"ref_cislo": "178258393",
|
||||
"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",
|
||||
"value": "323fa186a7c38b49f8f40e6798f019a1",
|
||||
"value": "786ed43afb46b3c7432371f7f2ee282e",
|
||||
"domain": ".portal.ozp.cz",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -14,7 +14,7 @@
|
||||
"value": "CERT",
|
||||
"domain": ".portal.ozp.cz",
|
||||
"path": "/",
|
||||
"expires": 1808541892,
|
||||
"expires": 1813202467,
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "SID",
|
||||
"value": "0589c59247aa8fa221c380eec74c9cef",
|
||||
"value": "1be176fa462a5f32ad908b07b0b380ac",
|
||||
"domain": ".portal.zpskoda.cz",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -14,7 +14,7 @@
|
||||
"value": "CERT",
|
||||
"domain": ".portal.zpskoda.cz",
|
||||
"path": "/",
|
||||
"expires": 1810234998,
|
||||
"expires": 1813134113,
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "SID",
|
||||
"value": "01bb61e3cd536ffbf7c4f2b74260466e",
|
||||
"value": "22319828cc5b7600290e217c8f533ca0",
|
||||
"domain": ".portal.rbp-zp.cz",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -14,7 +14,7 @@
|
||||
"value": "CERT",
|
||||
"domain": ".portal.rbp-zp.cz",
|
||||
"path": "/",
|
||||
"expires": 1808541922,
|
||||
"expires": 1813203627,
|
||||
"secure": true,
|
||||
"httpOnly": false,
|
||||
"sameSite": "Lax"
|
||||
|
||||
+77
-5
@@ -2,29 +2,101 @@
|
||||
# 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.
|
||||
|
||||
import os
|
||||
import socket
|
||||
import fdb
|
||||
|
||||
|
||||
def get_medicus_connection():
|
||||
"""
|
||||
Připojí se k Firebird medicus.fdb podle názvu počítače.
|
||||
Vrátí fdb.Connection nebo vyhodí RuntimeError pro neznámý počítač.
|
||||
Připojí se k Firebird medicus.fdb. DSN se vybere takto:
|
||||
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()
|
||||
dsn_map = {
|
||||
"LEKAR": r"localhost: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",
|
||||
"NTBVBHP470G10": r"reporter:c:\medicus\medicus.fdb",
|
||||
"Z230": 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"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
|
||||
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")
|
||||
|
||||
|
||||
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():
|
||||
"""Vrátí MedicusDB instanci s připojením podle názvu počítače."""
|
||||
conn = get_medicus_connection()
|
||||
|
||||
@@ -4,8 +4,12 @@ import pymysql
|
||||
|
||||
|
||||
def _print(msg):
|
||||
print(msg, file=sys.stdout, flush=True) if sys.stdout.encoding and sys.stdout.encoding.lower() in ("utf-8", "utf8") \
|
||||
else print(msg.encode("utf-8", errors="replace").decode("ascii", errors="replace"), flush=True)
|
||||
# Diagnostika jde na stderr — stdout je u MCP serverů vyhrazen pro JSON-RPC.
|
||||
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"}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from requests_pkcs12 import Pkcs12Adapter
|
||||
import requests
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
@@ -108,14 +109,14 @@ class VZPB2BClient:
|
||||
|
||||
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(
|
||||
endpoint,
|
||||
data=soap.encode("utf-8"),
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
print("HTTP:", response.status_code)
|
||||
print("HTTP:", response.status_code, file=sys.stderr, flush=True)
|
||||
return response.text
|
||||
|
||||
def stav_pojisteni(self, rc: str, k_datu: str = None, prijmeni: str = None):
|
||||
@@ -156,10 +157,10 @@ class VZPB2BClient:
|
||||
"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"),
|
||||
headers=headers, timeout=30)
|
||||
print("HTTP:", resp.status_code)
|
||||
print("HTTP:", resp.status_code, file=sys.stderr, flush=True)
|
||||
return resp.text
|
||||
|
||||
def registrace_lekare(self, rc: str, k_datu: str = None,
|
||||
|
||||
@@ -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.")
|
||||
@@ -1 +1,21 @@
|
||||
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
|
||||
@@ -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()
|
||||
@@ -2,46 +2,48 @@
|
||||
|
||||
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
|
||||
Spuštění: `python extract_patient_info.py` (bez argumentů = celá složka ToProcess)
|
||||
### `Extract_pacient_info_v1.0.py`
|
||||
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:**
|
||||
1. Načte soubory z `ToProcess/`
|
||||
2. Claude Vision API (sonnet-4-6) 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)
|
||||
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
|
||||
- Fallback: pokud RČ stále nenalezeno, vyhledá dle příjmení+jméno (z Claude) — status `by_name` / `by_name_multi`
|
||||
5. Upozorní na duplicitu v `U:\Dropbox\Ordinace\Dokumentace_zpracovaná\`
|
||||
6. Interaktivní schválení / oprava názvu
|
||||
7. JPG/PNG → skutečné PDF (správná orientace, DPI=150, quality=80)
|
||||
8. Přesun do `Processed/`, smazání z `ToProcess/`
|
||||
9. Opravy názvů se ukládají do `corrections.json` jako few-shot příklady
|
||||
1. Načte soubory z `KeZpracování/`
|
||||
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. Claude API — 2. volání (jen text): vygeneruje 5 variant názvu dle naming_rules.md, deduplikuje vůči 1. návrhu
|
||||
4. Ověří pacienta v Medicus Firebird (tabulka KAR); fuzzy matching RČ + fallback na jméno
|
||||
5. Zkontroluje duplicity v archivu `Dokumentace_zpracovaná/`
|
||||
6. Zobrazí hlavní viewer (tkinter, celá šířka monitoru):
|
||||
- Horní část: náhled originálu | náhled vybrané duplicity | seznam duplicit (Text widget s wrappingem)
|
||||
- Spodní panel: info o pacientovi | textbox pro název (multiline, bílý) | návrhy pojmenování
|
||||
7. Po schválení názvu nabídne 5 kompresních variant (300/200/150/120/96 DPI) k výběru
|
||||
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:**
|
||||
`{RČ} {YYYY-MM-DD} {Příjmení}, {Jméno} [{typ dokumentu}] [{poznámka}].pdf`
|
||||
**EKG větev:** PDFCreator metadata → rotace o 90° → Tesseract OCR → Medicus ověření
|
||||
|
||||
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
|
||||
```
|
||||
python jpg_to_pdf.py soubor.jpg [vystup.pdf] [rotace_ccw]
|
||||
```
|
||||
- Opravuje EXIF orientaci
|
||||
- Rotace: 0 / 90 / 180 / 270 (CCW)
|
||||
- A4, DPI=150, quality=80, bez okrajů
|
||||
- Používá se i interně z `extract_patient_info.py`
|
||||
## Konfigurační soubory
|
||||
|
||||
| Soubor | Účel |
|
||||
|---|---|
|
||||
| `naming_rules.md` | Pravidla pro pojmenování — předávají se Claudovi v každém volání |
|
||||
| `corrections.json` | Few-shot příklady korekcí názvů z minulých běhů |
|
||||
| `layout_settings.json` | Pozice a rozměry oken podle hostname počítače |
|
||||
|
||||
## Složky
|
||||
|
||||
| Složka | Účel |
|
||||
|---|---|
|
||||
| `ToProcess/` | Sem se házejí nové skeny (PDF, JPG, PNG) |
|
||||
| `Processed/` | Správně pojmenované PDF po schválení |
|
||||
| `U:\Dropbox\Ordinace\Dokumentace_zpracovaná\` | Finální archiv |
|
||||
| `KeZpracování/` | Vstupní skeny (PDF, JPG, PNG) |
|
||||
| `Zpracováno/` | Správně pojmenované PDF po schválení |
|
||||
| `Dokumentace_zpracovaná/` | Finální archiv (Dropbox) — prohledává se kvůli duplicitám |
|
||||
| `Testy/` | Archiv starších verzí skriptů |
|
||||
|
||||
## Konfigurace
|
||||
- API klíč: `U:\Medevio\.env` → `ANTHROPIC_API_KEY`
|
||||
- Medicus: `localhost:c:\medicus 3\data\medicus.fdb` (Firebird, SYSDBA)
|
||||
- Few-shot korekce: `corrections.json`
|
||||
- API klíč: `Medevio/.env` → `ANTHROPIC_API_KEY`
|
||||
- Medicus Firebird: `reporter:c:\medicus\medicus.fdb` (SYSDBA)
|
||||
- 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', '?')}")
|
||||
+41
-12
@@ -33,6 +33,19 @@ def main():
|
||||
root.tk.call("encoding", "system", "utf-8")
|
||||
|
||||
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
|
||||
current = [0]
|
||||
photo_ref = [None]
|
||||
@@ -40,12 +53,12 @@ def main():
|
||||
def render(n) -> Image.Image:
|
||||
if doc is not None:
|
||||
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))
|
||||
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
else:
|
||||
img = pil_img.copy()
|
||||
img.thumbnail((700, sh - 150), Image.LANCZOS)
|
||||
img.thumbnail((RENDER_W, RENDER_H), Image.LANCZOS)
|
||||
return img
|
||||
|
||||
def on_close():
|
||||
@@ -62,8 +75,6 @@ def main():
|
||||
root.destroy()
|
||||
|
||||
root.title(pdf_path.stem)
|
||||
root.attributes("-topmost", True)
|
||||
root.resizable(False, False)
|
||||
root.protocol("WM_DELETE_WINDOW", on_close)
|
||||
|
||||
lbl_img = tk.Label(root)
|
||||
@@ -91,23 +102,41 @@ def main():
|
||||
|
||||
show(0)
|
||||
root.update_idletasks()
|
||||
sw = root.winfo_screenwidth()
|
||||
w = root.winfo_width()
|
||||
h = root.winfo_height()
|
||||
x = (sw - w) // 2
|
||||
root.geometry(f"+{x}+0")
|
||||
try:
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, str(Path(__file__).parent))
|
||||
from window_layout import get_layout, apply_geometry
|
||||
_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>
|
||||
import json as _json
|
||||
for arg in sys.argv:
|
||||
if arg.startswith("--write-geometry="):
|
||||
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
|
||||
|
||||
root.lift()
|
||||
root.focus_force()
|
||||
root.after(100, lambda: root.focus_force())
|
||||
root.attributes("-topmost", True)
|
||||
root.after(1500, lambda: root.attributes("-topmost", False))
|
||||
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()
|
||||
+11
-6
@@ -13,11 +13,8 @@ from PIL import Image, ImageTk
|
||||
import fitz
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(1)
|
||||
|
||||
variants = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
def show(variants: list) -> str | None:
|
||||
"""Zobrazí picker přímo (bez subprocesů). Vrátí cestu k vybrané variantě nebo None."""
|
||||
chosen = {"path": None}
|
||||
docs = [fitz.open(v["path"]) for v in variants]
|
||||
current = [0]
|
||||
@@ -145,7 +142,15 @@ def main():
|
||||
except Exception:
|
||||
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__":
|
||||
@@ -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 `DDMMMYYYY–DDMMMYYYY` (měsíc třemi písmeny anglicky, velká, bez mezer), za pomlčkou pak popis.
|
||||
- Příklad: `[PZ interna] [12–15APR2026 srdeční selhání]`
|
||||
- Pokud je datum přijetí a propuštění ve stejném měsíci, stačí: `[12–15APR2026 ...]`
|
||||
- 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í CHRIG1–CHRIG5.
|
||||
- **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, 60–89 → CHRIG2, 45–59 → CHRIG3a, 30–44 → CHRIG3b, 15–29 → CHRIG4, < 15 → CHRIG5.
|
||||
- Prahové hodnoty pro orientaci při jednotce ml/s: ≥ 1.50 → G1, 1.00–1.49 → G2, 0.75–0.99 → G3a, 0.50–0.74 → G3b, 0.25–0.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]`
|
||||
@@ -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",
|
||||
"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řezen–kvě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řezen–kvě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í] [17–20APR2026 scabies dermatoskopicky verifikovaný, léčba sirnou kúrou].pdf",
|
||||
"corrected": "460614110 2026-04-20 Galus, Karel [PZ kožní] [17–20APR2026 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] [01–02JUN2026 EFV/RFA pro susp. FAT, AVNRT po RFA pomalé dráhy 0925].pdf",
|
||||
"corrected": "5751211807 2026-06-02 Hnízdová, Eva [PZ kardiologie] [01–02JUN2026 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] [18–24NOV2014 degenerativní stenóza L4/5, miniinvazivní over-the-top dekomprese L4/5 zleva].pdf",
|
||||
"corrected": "5404211967 2014-11-24 Zich, Jiří [PZ neurochirurgie] [18–24NOV2014 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á] [20–22JUN2022 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] [20–22JUN2022 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.2–5.6, hodnoty 118–132/54–67 mmHg].pdf",
|
||||
"corrected": "400828108 2026-06-09 Šebek, Josef [domácí měření TK] [záznamy 5.2–5.6, hodnoty 118–13254–67 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.5–9.6, hodnoty TK 100–116/65–79 mmHg, TF 67–92].pdf",
|
||||
"corrected": "7606050518 2026-06-09 Novotný, Pavel [domácí měření TK] [záznamy 28.5–9.6, hodnoty TK 100–11665–79 mmHg, TF 67–92].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á] [21APR2026–19MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf",
|
||||
"corrected": "5606051143 2026-05-19 Zána, Jan [PZ lázně] [21APR2026–19MAY2026 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á] [26APR2026–24MAY2026, st.p. fract. femoris+humeri l.dx., vertebrogenní sy, DM2, polyneuropatie DKK].pdf",
|
||||
"corrected": "425915482 2026-05-24 Lebedová, Zdenka [PZ lázně] [26APR2026–24MAY2026, 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] [27APR–04MAY2026, Prestance 5/5mg ráno, Agen 100mg večer].pdf",
|
||||
"corrected": "425915482 2026-05-04 Lebedová, Zdenka [domácí měření TK] [27APR–04MAY2026, 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] [18MAY–15JUN2026, průměr 13980, hypertenze 11d, zvýšený TK 8d].pdf",
|
||||
"corrected": "891209 2026-06-15 [Holter TK] [18MAY–15JUN2026, 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" }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
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í CHRIG1–CHRIG5.
|
||||
- **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, 60–89 → CHRIG2, 45–59 → CHRIG3a, 30–44 → CHRIG3b, 15–29 → CHRIG4, < 15 → CHRIG5.
|
||||
- Prahové hodnoty pro orientaci při jednotce ml/s: ≥ 1.50 → G1, 1.00–1.49 → G2, 0.75–0.99 → G3a, 0.50–0.74 → G3b, 0.25–0.49 → G4, < 0.25 → G5.
|
||||
- **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:
|
||||
- **ml/s** (resp. ml/sec, ml/s/1.73m²) — typicky malá čísla cca 0.2–2.3 (např. 0.8, **1.27**, 1.5). Tuto hodnotu **přenásob ×60**, abys dostal ml/min.
|
||||
- **ml/min** (resp. ml/min/1.73m²) — typicky velká čísla 5–140 (např. 55, 68, 90). Použij přímo.
|
||||
- **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.
|
||||
- **Klasifikace** (vždy až v ml/min/1.73m²): ≥ 90 → CHRIG1, 60–89 → CHRIG2, 45–59 → CHRIG3a, 30–44 → CHRIG3b, 15–29 → CHRIG4, < 15 → CHRIG5.
|
||||
- Prahové hodnoty pro orientaci přímo při jednotce ml/s: ≥ 1.50 → G1, 1.00–1.49 → **G2**, 0.75–0.99 → G3a, 0.50–0.74 → G3b, 0.25–0.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.
|
||||
- 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]`.
|
||||
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`.
|
||||
@@ -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]`
|
||||
|
||||
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]`
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -41,7 +41,8 @@ response = requests.post(GRAPHQL_URL, headers=headers, cookies=cookies, data=jso
|
||||
|--------|----|
|
||||
| Clinic | `25f24970-dae3-4f80-9337-d3616e53fb10` |
|
||||
| Clinic slug | `mudr-buzalkova` |
|
||||
| Calendar MUDr. Buzalkova | `144c4e12-347c-49ca-9ec0-8ca965a4470d` |
|
||||
| Calendar MUDr. Buzalkova (manzelka) | `144c4e12-347c-49ca-9ec0-8ca965a4470d` |
|
||||
| Calendar Vlado | `b6555c7e-4e95-4657-b441-87c2c9a7b2ca` |
|
||||
| AIS entity (Medicus) | `ef1549a5-d266-4f52-9a4d-7275e79ac82e` |
|
||||
|
||||
---
|
||||
@@ -217,6 +218,15 @@ Výsledek: 1963 pacientů synchronizováno (květen 2026).
|
||||
| Operation | Variables | Response |
|
||||
|-----------|-----------|----------|
|
||||
| `ClinicLegacyRequestList_ListPatientRequestsForClinic` | `clinicSlug`, `locale`, `pageInfo {first: 30, offset}`, `queueAssignment`, `queueId`, `state` (ACTIVE/DONE) | `requests`, `clinic` |
|
||||
| `ClinicRequestList2` | `clinicSlug`, `queueId`, `queueAssignment`, `state` (ACTIVE/DONE), `pageInfo {first, offset}`, `locale` | `requestsResponse { count, patientRequests [] }` |
|
||||
|
||||
`ClinicRequestList2` volá `listPatientRequestsForClinic2` — novější endpoint, vrací `count` a plně stránkovaný seznam. Struktura položky:
|
||||
```
|
||||
patientRequest { id, displayTitle, createdAt, updatedAt, doneAt, removedAt,
|
||||
extendedPatient { name, surname, identificationNumber },
|
||||
lastMessage { createdAt } }
|
||||
```
|
||||
Skript: `Medevio/10ReadPozadavky/PRAVIDELNE_0_READ_ALL_ACTIVE_POZADAVKY.py`
|
||||
|
||||
#### Request list item structure
|
||||
```
|
||||
@@ -231,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
|
||||
|
||||
| Operation | Variables | Response |
|
||||
@@ -238,7 +311,48 @@ request {
|
||||
| `ClinicRequestDetail_GetPatientRequest2` | `clinicSlug`, `isDoctor`, `requestId`, `locale` | `request` (full detail) |
|
||||
| `UseMessages_ListMessages` | `requestId`, `updatedSince` | `messages` |
|
||||
| `Communication_GetClinicFooter` | `clinicSlug` | `footer` |
|
||||
| `ClinicRequestNotes_Get` | `patientRequestId` | `notes` |
|
||||
| `ClinicRequestNotes_Get` | `patientRequestId` | `notes []` |
|
||||
| `ClinicRequestNotes_Update` | `noteInput { id, content }` | `{ id }` |
|
||||
| `ClinicRequestNotes_Create` | `noteInput { requestId, content }` | `{ id }` |
|
||||
| `ClinicRequestDetail_GetMessages` | `clinicSlug`, `requestId` | zprávy (alternativní endpoint) |
|
||||
|
||||
#### Interní poznámky k požadavku (klinické notes)
|
||||
|
||||
```graphql
|
||||
# Čtení
|
||||
query ClinicRequestNotes_Get($patientRequestId: String!) {
|
||||
notes: getClinicPatientRequestNotes(requestId: $patientRequestId) {
|
||||
id content createdAt updatedAt createdBy { id name surname }
|
||||
}
|
||||
}
|
||||
|
||||
# Aktualizace existující
|
||||
mutation ClinicRequestNotes_Update($noteInput: UpdateClinicPatientRequestNoteInput!) {
|
||||
updateClinicPatientRequestNote(noteInput: $noteInput) { id }
|
||||
}
|
||||
|
||||
# Vytvoření nové
|
||||
mutation ClinicRequestNotes_Create($noteInput: CreateClinicPatientRequestNoteInput!) {
|
||||
createClinicPatientRequestNote(noteInput: $noteInput) { id }
|
||||
}
|
||||
```
|
||||
|
||||
K jednomu požadavku existuje typicky jedna interní poznámka. Pokud neexistuje → Create, pokud existuje → Update.
|
||||
Skript: `Medevio/30 ManipulacePoznámek/101 JednoducheDoplneniInterniPoznamky.py`
|
||||
|
||||
#### Alternativní endpoint pro zprávy konverzace
|
||||
|
||||
```graphql
|
||||
query ClinicRequestDetail_GetMessages($clinicSlug: String!, $requestId: ID!) {
|
||||
clinicRequestDetail_GetPatientRequestMessages(clinicSlug: $clinicSlug, requestId: $requestId) {
|
||||
id text createdAt
|
||||
sender { id name }
|
||||
extendedPatient { name surname identificationNumber }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Skript: `Medevio/10ReadPozadavky/10 UpdateMessageswithJmeno.py`
|
||||
|
||||
#### Request detail structure
|
||||
```
|
||||
@@ -264,6 +378,48 @@ request {
|
||||
|-----------|-----------|----------|
|
||||
| `ClinicCalendar_ListClinicReservations` | `calendarIds []`, `clinicCountry`, `clinicSlug`, `locale`, `since` (ISO), `until` (ISO), `showTimeSlots`, `schedulePatientId`, `emptyCalendarIds` | `holidays`, `reservations`, `vacations` |
|
||||
| `ClinicCalendar_GetWindows` | `calendarIds []`, `clinicSlug`, `locale`, `since`, `until` | `calendarWindows` (ordinacni hodiny) |
|
||||
| `Agenda_ListAll` | `calendarIds []`, `clinicSlug`, `locale`, `since`, `until` | `reservations` (jednorazove) + `recurringReservations` (opakujici) |
|
||||
|
||||
`Agenda_ListAll` volá dva endpointy zároveň:
|
||||
- `listClinicReservations` → jednorazové rezervace (pacienti + poznámky lékaře)
|
||||
- `listClinicRecurringReservations` → opakující se rezervace; vrací `recurringReservation { id calendarId color note rrule { frequency interval dtstart tzid byweekday bymonthday byweekno } }` a `instances { start end note color }`
|
||||
|
||||
Fungující skript: `Medevio/agenda_dne.py` — funkce `list_agendu(start, end, calendar)`
|
||||
|
||||
#### Vytvoření poznámky lékaře v kalendáři
|
||||
|
||||
```graphql
|
||||
mutation CreateReservation_MakeReservationByDoctor(
|
||||
$clinicSlug: String!, $color: ECRFIconColor, $note: String!, $timeSlotInput: TimeSlotInput!
|
||||
) {
|
||||
reservation: makeReservationByDoctor(
|
||||
clinicSlug: $clinicSlug color: $color note: $note timeSlotInput: $timeSlotInput
|
||||
) { id __typename }
|
||||
}
|
||||
```
|
||||
|
||||
Variables: `clinicSlug`, `color` (např. `"CHARCOAL"`), `note` (text), `timeSlotInput { calendarId, start (UTC ISO), end (UTC ISO) }`
|
||||
Vrací: `reservation.id` — UUID nové rezervace.
|
||||
Skript: `Medevio/zapis_poznamky.py` — funkce `zapis_poznamku(calendar, den, cas, trvani_min, poznamka, color)`
|
||||
|
||||
#### Smazání / zrušení rezervace
|
||||
|
||||
```graphql
|
||||
# Jednorazová:
|
||||
mutation UpdateReservation_CancelReservationByDoctor(
|
||||
$clinicSlug: String!, $reservationId: UUID!
|
||||
) {
|
||||
reservation: cancelReservationByDoctor(clinicSlug: $clinicSlug, reservationId: $reservationId) { id __typename }
|
||||
}
|
||||
|
||||
# Opakující se:
|
||||
mutation UpdateReservation_CancelRecurringReservationByDoctor($input: RemoveRecurringReservationInput!) {
|
||||
success: removeDateFromRecurringReservation(input: $input)
|
||||
}
|
||||
```
|
||||
|
||||
Pro opakující se: `input { clinicSlug, recurringReservationId, date (UTC ISO), updateType: "Single"|"ThisAndFuture"|"All" }`
|
||||
Skript: `Medevio/smaz_poznamku.py` — funkce `smaz_jednorazovou(reservation_id)`, `smaz_opakujici(recurring_id, date, update_type)`
|
||||
|
||||
#### Reservation structure
|
||||
```
|
||||
|
||||
@@ -709,12 +709,19 @@ ORDER BY h.DATUM DESC
|
||||
| Nástroj | Popis |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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ů |
|
||||
| `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)
|
||||
LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}}"
|
||||
@@ -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
Reference in New Issue
Block a user