diff --git a/Medevio/60 ScansProcessing/corrections.json b/Medevio/60 ScansProcessing/corrections.json index b376b82..febc7e7 100644 --- a/Medevio/60 ScansProcessing/corrections.json +++ b/Medevio/60 ScansProcessing/corrections.json @@ -1658,5 +1658,21 @@ { "original": "7602044780 2026-05-22 Suchý, Vladimír [PZ interna] [15–22MAY2026 SLE, CKD G5 na ATN, sepse, AKI III.st., hypotenze, tachykardie].pdf", "corrected": "7602044780 2026-05-22 Suchý, Vladimír [PZ interna] [15–22MAY2026 SLE, CKD G5 na ATN, urosepse, AKI III.st., hypotenze, tachykardie].pdf" + }, + { + "original": "480416072 2026-01-26 Štrup, Petr [LZ balneologie] [Lázně Jáchymov, TEP L kyčle reop. 8/2025, osteopenie, hypertenze, chron. bronchitis].pdf", + "corrected": "480416072 2026-02-22 Štrup, Petr [LZ balneologie] [26JAN-22FEB2026, Lázně Jáchymov, TEP L kyčle reop. 82025, osteopenie, hypertenze, chron. bronchitis].pdf" + }, + { + "original": "485702422 2026-05-27 Štrupová, Eva [LZ kardiologie] [Incip. sick sinus sy, bradykardie, bez indikace KS, EF LK 66%, sy ND NYHA I-II, stop. mitr./trikusp./pulm. regurg.].pdf", + "corrected": "485702422 2026-05-27 Štrupová, Eva [LZ kardiologie] [kontrola po půl roce, Incip. sick sinus sy, bradykardie, bez indikace KS, EF LK 66%, sy ND NYHA I-II, stop. mitr.trikusp.pulm. regurg.].pdf" + }, + { + "original": "535510353 2026-01-23 Rejfířová, Marcela [Barthelův test ADL] [65b – lehká závislost; oblékání s pomocí, vozík, chůze po schodech s pomocí].pdf", + "corrected": "535510353 2026-01-23 Rejfířová, Marcela [Barthelův test ADL] [75b – lehká závislost; oblékání s pomocí, vozík, chůze po schodech s pomocí].pdf" + }, + { + "original": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [40b – nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf", + "corrected": "535510353 2026-05-28 Rejfířová, Marcela [IADL test] [35b – nesoběstačnost; jízda s pomocí, nákup 0, vaření ohřev, domácí práce 0, prádlo 0, tel. 10, léky 10, finance s pomocí].pdf" } ] \ No newline at end of file diff --git a/Vykony/NOTES.md b/Vykony/NOTES.md new file mode 100644 index 0000000..8459d7b --- /dev/null +++ b/Vykony/NOTES.md @@ -0,0 +1,262 @@ +# Vykony — stahování a zpracování zdravotních výkonů + +## Přehled + +Kompletní pipeline pro stahování zdravotních výkonů ze **szv.mzd.gov.cz** do MongoDB, +včetně detailů každého výkonu a exportu do XLSX reportu. + +``` +szv.mzd.gov.cz + │ + ├── /Vykon/ → stahni_vykony.py → MongoDB: vykony + vykony_historie + └── /Vykon/Detail/{id}/ → stahni_detaily.py → MongoDB: detaily + detaily_historie + ↓ + report_vykony.py → vykony_report.xlsx +``` + +--- + +## Skripty + +| Skript | Co dělá | +|--------|---------| +| `stahni_vykony.py` | Stahuje seznam všech výkonů (přehledová data), ukládá do `vykony` s historií | +| `stahni_detaily.py` | Stahuje detailní stránku každého výkonu, ukládá do `detaily` s historií | +| `report_vykony.py` | Exportuje výkony + detaily do XLSX, jeden list na odbornost | +| `debug_detail.py` | Ladící skript — stáhne HTML jednoho výkonu a vypíše co parser vidí | + +### Doporučené pořadí spuštění + +```bash +python stahni_vykony.py # 1. seznam výkonů (~4246 kusů, ~1 min) +python stahni_detaily.py # 2. detaily (~4246 HTTP requestů, ~10–20 min) +python report_vykony.py # 3. export do XLSX +``` + +--- + +## Nastavení skriptů + +Každý skript má na začátku blok konfiguračních proměnných — **není potřeba sahat do kódu**: + +### `stahni_detaily.py` +```python +KOLIK = 0 # 0 = vše; jinak max. počet ke stažení (pro testování: 10) +FORCE = False # True = přestáhni i už stažené záznamy +WORKERS = 5 # počet paralelních vláken (doporučeno 5–20) +``` + +### `report_vykony.py` +```python +ODBORNOSTI = ["001", "002"] # [] = všechny odbornosti +VYSTUP = Path(__file__).parent / "vykony_report.xlsx" +``` + +--- + +## MongoDB + +**Host:** `192.168.1.76:27017` +**DB:** `zdravotni_vykony` +**GUI:** MongoDB Compass (zdarma, od MongoDB Inc.) + +### Kolekce `vykony` — aktuální přehledový stav + +Vždy obsahuje poslední verzi každého výkonu. Hodnoty jsou zkrácené (kódy bez popisů). + +| Pole | Typ | Popis | +|------|-----|-------| +| `cislo_vykonu` | str | Unikátní klíč (např. `01021`) | +| `odbornost` | str | Kód odbornosti (např. `001`) | +| `nazev_vykonu` | str | Název výkonu | +| `kategorie` | str | Kód kategorie (např. `P`) | +| `typ_vykonu` | str | Kód typu (např. `A`) | +| `doba_trvani` | float\|null | Minuty | +| `omezeni_mistem` | str\|null | Kód omezení (např. `A`) | +| `omezeni_frekvenci` | str\|null | Např. `1/1 den` | +| `prime_naklady` | float\|null | Body | +| `osobni` | float\|null | Body | +| `body_rezijni` | float\|null | Body | +| `body_celkem` | float\|null | Body | +| `revize` | datetime\|null | Datum poslední revize výkonu | +| `detail_url` | str\|null | URL detailu na szv.mzd.gov.cz | +| `_aktivni` | bool | `false` pokud výkon zmizel ze scrape | +| `_platny_od` | datetime | Kdy tato verze začala platit | +| `_scraped_at` | datetime | Čas posledního úspěšného scrape | +| `_deaktivovano` | datetime | Kdy byl výkon deaktivován (jen pokud `_aktivni=false`) | + +**Indexy:** `cislo_vykonu` (unique), `odbornost`, `_aktivni`, `_platny_od` + +--- + +### Kolekce `vykony_historie` — archiv změn přehledu + +Každý záznam = jedna historická verze výkonu (stav před změnou). +Obsahuje stejná pole jako `vykony`, navíc: + +| Pole | Typ | Popis | +|------|-----|-------| +| `_platny_do` | datetime | Kdy tato verze přestala platit | +| `_zmenena_pole` | list[str] | Která pole se změnila (nebo `["_aktivni"]` při deaktivaci) | + +**Indexy:** `cislo_vykonu`, `_platny_do`, `_zmenena_pole` + +--- + +### Kolekce `detaily` — aktuální detailní stav + +Obsahuje kompletní data z detailní stránky každého výkonu. Hodnoty jsou dlouhé (s popisy). +Skalární pole + vnořené sub-tabulky jako pole objektů. + +**Skalární pole:** + +| Pole | Typ | Popis | +|------|-----|-------| +| `cislo_vykonu` | str | Unikátní klíč, propojení s `vykony` | +| `nazev` | str | Plný název výkonu | +| `kategorie` | str | Dlouhý popis (např. `P - hrazen plně`) | +| `typ_formulare` | str | Např. `ambulantní` | +| `omezeni_mistem` | str | Dlouhý popis (např. `A - pouze ambulantně`) | +| `omezeni_frekvenci` | str | Např. `1/1 den` | +| `doba_trvani` | float\|null | Minuty | +| `nepocitat_rezii` | bool | | +| `popis` | str | Obecný popis (často prázdný) | +| `poznamka` | str | Poznámka | +| `podminky` | str | Podmínky pro vykázání | +| `cim_zacina` | str | Popis začátku výkonu | +| `obsah_rozsah` | str | Obsah a rozsah výkonu | +| `cim_konci` | str | Popis konce výkonu | +| `body_prime` | float\|null | Bodová hodnota — přímé náklady | +| `body_osobni` | float\|null | Bodová hodnota — osobní náklady | +| `body_rezijni` | float\|null | Bodová hodnota — režijní náklady | +| `body_celkem` | float\|null | Bodová hodnota — celkem | +| `detail_url` | str | URL zdroje | +| `_platny_od` | datetime | Kdy byla tato verze detailu uložena | +| `_scraped_at` | datetime | Čas posledního scrape | + +**Sub-tabulky (pole objektů):** + +| Pole | Klíče objektů | +|------|---------------| +| `autorska_odbornost` | Kód, Název, Pořadí, Sazba režie | +| `dalsi_odbornost` | Kód, Název, Sazba režie | +| `nositele` | Kategorie, Funkce, Praxe, Cas, Bodyaktualni, Poznamka | +| `materialy` | Kód, Název, Doplněk, Množství, Jednotka, Cena, DPH %, Body | +| `pripravky` | Kód, Název, Doplněk, ATC, Omezení, Množství, Jednotka, Cena, Body | +| `pristroje` | Kód, Název, D.Ž., N.Ú., D.P., DPH %, Body | +| `zum` | Kód, Název | +| `zulp` | Kód, Název | +| `bodova_hodnota` | Přímé, Osobní, Režijní, Celkem (1 řádek, flatten na skalární body_*) | + +**Indexy:** `cislo_vykonu` (unique), `_scraped_at`, `_platny_od` + +--- + +### Kolekce `detaily_historie` — archiv změn detailů + +Každý záznam = stav detailu před změnou. +Obsahuje stejná pole jako `detaily`, navíc: + +| Pole | Typ | Popis | +|------|-----|-------| +| `_platny_do` | datetime | Kdy tato verze přestala platit | +| `_zmenena_pole` | list[str] | Která pole se změnila | + +**Indexy:** `cislo_vykonu`, `_platny_do`, `_zmenena_pole` + +--- + +## Logika detekce změn + +Stejná logika platí pro oba páry kolekcí (`vykony`/`vykony_historie` i `detaily`/`detaily_historie`): + +1. Načte celou aktivní kolekci do paměti (`cislo_vykonu → dokument`) +2. Pro každý nově stažený záznam: + - **Nový** → vloží s `_platny_od = run_at` + - **Změněn** (liší se v některém z `COMPARE_FIELDS`) → stará verze jde do historie s `_platny_do` + `_zmenena_pole`, nová verze se zapíše + - **Nezměněn** → jen aktualizuje `_scraped_at` +3. Výkony co v novém scrape chybí → `_aktivni = False`, stará verze do historie (jen `vykony`) + +--- + +## Technické poznámky + +### Proč ne MVCGridHandler.axd? +Ajax endpoint (`/MVCGridHandler.axd?Name=VykonGrid&pageSize=9999`) ignoruje `pageSize` +a vrací max 50 záznamů. Navíc po vyčerpání dat cyklí od začátku. +Správný endpoint je přímo stránka `/Vykon/`. + +### HTML struktura detailní stránky +- Hlavní tabulka: `` +- Řádky: `` (ne `
labelhodnota` jak by se čekalo) +- Sub-tabulky: `` uvnitř `
` +- Parser iteruje `find_all("tr", recursive=False)` — bez rekurze nevleze do vnořených tabulek + +### Encoding +Server vrací latin znaky i při UTF-8 hlavičce → `resp.apparent_encoding` místo `resp.encoding`. + +### Rate limiting +`stahni_detaily.py` dělá `time.sleep(0.1)` po každém requestu per worker. +Při 20 workerech ≈ 200 requestů/s — server to bez problémů zvládá. + +--- + +## Typické MongoDB dotazy + +```js +// Všechny aktivní výkony odbornosti 001 +db.vykony.find({ odbornost: "001", _aktivni: true }) + +// Detail konkrétního výkonu +db.detaily.findOne({ cislo_vykonu: "01021" }) + +// Výkony s podmínkami (neprázdné pole) +db.detaily.find({ podminky: { $ne: "" } }) + +// Výkony které se někdy změnily +db.vykony_historie.distinct("cislo_vykonu") + +// Historie konkrétního výkonu (detail) +db.detaily_historie.find({ cislo_vykonu: "01021" }).sort({ _platny_do: 1 }) + +// Co se změnilo při posledním runu +db.detaily_historie.find({ _platny_do: { $gte: ISODate("2026-06-01") } }) + +// Výkony s nositelem kategorie L3 +db.detaily.find({ "nositele.Kategorie": "L3" }) + +// Výkony s materiálem (neprázdné materialy) +db.detaily.find({ "materialy.0": { $exists: true } }) + +// Deaktivované výkony +db.vykony.find({ _aktivni: false }) +``` + +--- + +## XLSX Report + +Skript `report_vykony.py` generuje `vykony_report.xlsx`: + +- Jeden list na odbornost (název listu: `Odbornost 001` atd.) +- Výkony řazené podle čísla výkonu +- Více nositelů → více řádků pro jeden výkon (střídavé zbarvení po výkonech) +- Sloupec **Postup výkonu** = čím začíná + obsah a rozsah + čím končí (odděleno prázdným řádkem) +- Záhlaví zmrazeno (freeze panes) + +**Sloupce reportu:** +Číslo výkonu · Název · Kategorie · Typ formuláře · Doba trvání · Omezení místem · +Omezení frekvencí · Nepočítat režii · Body přímé · Body osobní · Body režijní · Body celkem · +Postup výkonu · Nositel kategorie · Nositel funkce · Nositel čas · Nositel body + +--- + +## Závislosti + +``` +requests +beautifulsoup4 +lxml +pymongo +openpyxl +``` diff --git a/Vykony/debug_01021.html b/Vykony/debug_01021.html new file mode 100644 index 0000000..01fd4c7 --- /dev/null +++ b/Vykony/debug_01021.html @@ -0,0 +1,866 @@ + + + + + + Detail - Zdravotní výkony + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +

Seznam zdravotních výkonů

+
+
+ + + + + + + + + + + + + + + +

Registrační list - 01021

+

KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM

+ +
+

Obsah registračního listu

+
+
+
+ + Tisk + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Číslo výkonu01021
Název + KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM + + +
+ Nepočítat režii + + + + + +
Poznámka + + + +
+ Autorská odbornost + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + Pořadí + + Sazba režie +
+ 001 + + všeobecné praktické lékařství + + 1020 + + 4,58 +
+ + +
+ Další odbornost + + + + + + + + + + + + + +
+ Kód + + Název + + Sazba režie +
+ + + +
Kategorie + P - hrazen plně + + +
+ Typ formuláře + + ambulantní +
Omezení místem + A - pouze ambulantně + + + +
Omezení frekvencí + 1/1 den + + + + +
Doba trvání + 60 + + +
Popis + + + + +
Čím výkon začíná + Prostudováním dokumentace pacienta a odběrem životní osobní a rodinné anamnézy. + + +
Obsah a rozsah výkonu + Celkové interní fyzikální vyšetření. Orientační vyšetření neurologické. Orientační vyšetření funkcí pohybového aparátu. Vyšetření moče. Indikace dalších potřebných vyšetření podle výsledku prohlídky. Zhodnocení zdravotního stavu. Vyšetření zraku, sluchu. + + +
Čím výkon končí + Poučením pacienta, informace o možné intervenci rizikových faktorů. Administrativní činností související s výkonem (vystavení zprávy pro ošetřujícího lékaře, potřebných receptů, poukazů, žádanek, vystavení formulářů na pracovní neschopnost či ošetřování člena rodiny, vypsání povinných hlášení, určení ev. data další návštěvy, rozhodnutí o případné nezbytné zdravotnické dopravě pacienta a vystavení poukazu na ni). + + +
Podmínky + + + +
+ Nositelé + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kategorie + + Funkce + + Praxe + + Cas + + Bodyaktualni + + Poznamka +
+ L3 + + všeobecný praktický lékař + + + + 60 + + 838,87 + + + + + +
Celkem:838,87
+ +
+ Materiály + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + Doplněk + + Množství + + Jednotka + + Cena + + DPH % + + Body +
+ P000057 + + PMAT ke komplexnímu vyšetření + + + + 1 + + + + 8,00 + + 0,00 + + 8,00 + + + +
+ + Celkem: 8,00 8,00
+
+ Přípravky + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + Doplněk + + ATC + + Omezení + + Množství + + Jednotka + + DPH % + + Cena + + Body +
+ + + Celkem: + + 0,00 + + 0,00 +
+
+ Přístroje + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Kód + + Název + + D.Ž. + + N.Ú. + + D.P. + + DPH % + + Procento z času výkonu + + Cena + + Body +
Celkem: + 0,00 + + + 0,00 + +
+
+ ZUM + + + + + + + + + + + +
+ Kód + + Název +
+ +
+ ZULP + + + + + + + + + + + +
+ Kód + + Název +
+ +
Bodová hodnota + + + + + + + + + + + + + + + +
+ Přímé + OsobníRežijníCelkem
8,00838,87274,801 122
+
+ +
+

+ + + +Zpět na výpis +

+ + + + +
+
+ + V případě podávání návrhů nových registračních listů zdravotních výkonů, popř. úpravu stávajících registračních listů zdravotních výkonů, přístupů do aplikace a jiných podnětů, se obracejte na Ing. Martinu Cetelovou. Technickým správcem aplikace je ÚZIS ČR.

+ Financováno z projektu ESF - Rozvoj technologické platformy NZIS (CZ.03.4.74/0.0/0.0/15_019/0002748) +
+ + + + + + +
+ + + + + + + + + +
+ ÚZIS ČR + Ústav zdravotnických informací
a statistiky České republiky
+
+ + + +
+
+ ©2016 - 2026 Seznam zdravotních výkonů 2.0.200.3, ÚZIS ČR +
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/Vykony/debug_detail.py b/Vykony/debug_detail.py new file mode 100644 index 0000000..c30335b --- /dev/null +++ b/Vykony/debug_detail.py @@ -0,0 +1,80 @@ +""" +Ladění parseru detailu. Stáhne HTML jednoho výkonu, uloží na disk +a vypíše co parser vidí (tabulky, řádky, labely). + +Spuštění: + python debug_detail.py + python debug_detail.py 09581 +""" + +import sys +import requests +from bs4 import BeautifulSoup +from pathlib import Path + +CISLO = sys.argv[1] if len(sys.argv) > 1 else "01021" +BASE_URL = "https://szv.mzd.gov.cz" +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "cs,en;q=0.9", +} + +url = f"{BASE_URL}/Vykon/Detail/{CISLO}/" +print(f"Stahuji: {url}") +resp = requests.get(url, headers=HEADERS, timeout=30) +resp.encoding = resp.apparent_encoding or "utf-8" + +# Ulož HTML na disk +html_path = Path(__file__).parent / f"debug_{CISLO}.html" +html_path.write_text(resp.text, encoding="utf-8") +print(f"HTML uloženo: {html_path}") + +soup = BeautifulSoup(resp.text, "lxml") + +# --- Kolik tabulek je na stránce? --- +all_tables = soup.find_all("table") +print(f"\nPočet na stránce: {len(all_tables)}") + +# --- Co najde aktuální selektor? --- +main_table = soup.select_one("div.container table") or soup.find("table") +print(f"Selektor 'div.container table': {bool(soup.select_one('div.container table'))}") +print(f"Fallback soup.find('table'): {bool(soup.find('table'))}") + +if not main_table: + print("ŽÁDNÁ TABULKA NENALEZENA!") + sys.exit(1) + +# --- Přímé potomky hlavní tabulky --- +direct_trs = main_table.find_all("tr", recursive=False) +print(f"\nPřímých v hlavní tabulce: {len(direct_trs)}") + +print("\n--- Labely v přímých řádcích ---") +for i, tr in enumerate(direct_trs): + tds = tr.find_all("td", recursive=False) + if len(tds) >= 2: + label = tds[0].get_text(strip=True) + preview = tds[1].get_text(strip=True)[:60] + print(f" [{i:2d}] label='{label}' | hodnota='{preview}'") + elif len(tds) == 1: + print(f" [{i:2d}] 1 buňka: '{tds[0].get_text(strip=True)[:80]}'") + else: + ths = tr.find_all("th", recursive=False) + if ths: + print(f" [{i:2d}] s {len(tbody_trs)} přímými ") + print("--- Labely v tbody řádcích ---") + for i, tr in enumerate(tbody_trs[:5]): + tds = tr.find_all("td", recursive=False) + if len(tds) >= 2: + label = tds[0].get_text(strip=True) + preview = tds[1].get_text(strip=True)[:60] + print(f" [{i:2d}] label='{label}' | hodnota='{preview}'") diff --git a/Vykony/report_vykony.py b/Vykony/report_vykony.py new file mode 100644 index 0000000..ccf3fd6 --- /dev/null +++ b/Vykony/report_vykony.py @@ -0,0 +1,201 @@ +""" +Export zdravotních výkonů do XLSX — jeden list na odbornost. + +Každý výkon se rozvine do tolika řádků, kolik má nositelů. +Pokud nositelé chybí, výkon dostane jeden prázdný řádek. + +Požadavky: + pip install pymongo openpyxl +""" + +from pathlib import Path +from datetime import datetime, timezone + +from pymongo import MongoClient +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +# ── Nastavení ───────────────────────────────────────────────────────────────── +ODBORNOSTI = ["001", "002"] # [] = všechny odbornosti +VYSTUP = Path(__file__).parent / "vykony_report.xlsx" +# ────────────────────────────────────────────────────────────────────────────── + +MONGO_URI = "mongodb://192.168.1.76:27017/" +MONGO_DB = "zdravotni_vykony" + +SLOUPCE = [ + ("cislo_vykonu", "Číslo výkonu", 10), + ("nazev", "Název", 45), + ("kategorie", "Kategorie", 22), + ("typ_formulare", "Typ formuláře", 14), + ("doba_trvani", "Doba trvání", 10), + ("omezeni_mistem", "Omezení místem", 22), + ("omezeni_frekvenci", "Omezení frekvencí", 16), + ("nepocitat_rezii", "Nepočítat režii", 12), + ("body_prime", "Body přímé", 10), + ("body_osobni", "Body osobní", 10), + ("body_rezijni", "Body režijní", 10), + ("body_celkem", "Body celkem", 10), + ("postup", "Postup výkonu", 50), + ("nositel_kategorie", "Nositel – kategorie", 14), + ("nositel_funkce", "Nositel – funkce", 30), + ("nositel_cas", "Nositel – čas", 10), + ("nositel_body", "Nositel – body", 12), +] + +BARVA_HLAVICKA = "1F4E79" +BARVA_VYKON_A = "DEEAF1" # sudý výkon +BARVA_VYKON_B = "FFFFFF" # lichý výkon + + +def _bool_text(v) -> str: + if v is True: return "ano" + if v is False: return "ne" + return "" + + +def _postup(d: dict) -> str: + casti = [] + if d.get("cim_zacina"): casti.append(d["cim_zacina"].strip()) + if d.get("obsah_rozsah"): casti.append(d["obsah_rozsah"].strip()) + if d.get("cim_konci"): casti.append(d["cim_konci"].strip()) + return "\n\n".join(casti) + + +def _nositele(d: dict) -> list[dict]: + """Vrátí nositelé bez řádků Celkem a bez prázdných řádků.""" + result = [] + for n in d.get("nositele", []): + # přeskoč řádky kde kdekoliv je "Celkem:" + hodnoty = [str(v) for v in n.values()] + if any("celkem" in v.lower() for v in hodnoty): + continue + # přeskoč zcela prázdné řádky + if not any(v.strip() for v in hodnoty if v): + continue + result.append(n) + return result + + +def _vykon_radky(v: dict, d: dict) -> list[dict]: + """Rozlož výkon do řádků podle nositelů. Min. 1 řádek.""" + base = { + "cislo_vykonu": v.get("cislo_vykonu", ""), + "nazev": d.get("nazev") or v.get("nazev_vykonu", ""), + "kategorie": d.get("kategorie") or v.get("kategorie", ""), + "typ_formulare": d.get("typ_formulare", ""), + "doba_trvani": d.get("doba_trvani") if d.get("doba_trvani") is not None else v.get("doba_trvani", ""), + "omezeni_mistem": d.get("omezeni_mistem") or v.get("omezeni_mistem", ""), + "omezeni_frekvenci": d.get("omezeni_frekvenci") or v.get("omezeni_frekvenci", ""), + "nepocitat_rezii": _bool_text(d.get("nepocitat_rezii")), + "body_prime": d.get("body_prime") if d.get("body_prime") is not None else v.get("prime_naklady", ""), + "body_osobni": d.get("body_osobni") if d.get("body_osobni") is not None else v.get("osobni", ""), + "body_rezijni": d.get("body_rezijni") if d.get("body_rezijni") is not None else v.get("body_rezijni", ""), + "body_celkem": d.get("body_celkem") if d.get("body_celkem") is not None else v.get("body_celkem", ""), + "postup": _postup(d), + } + + nositele = _nositele(d) + if not nositele: + return [{**base, "nositel_kategorie": "", "nositel_funkce": "", "nositel_cas": "", "nositel_body": ""}] + + radky = [] + for n in nositele: + radky.append({ + **base, + "nositel_kategorie": n.get("Kategorie", ""), + "nositel_funkce": n.get("Funkce", ""), + "nositel_cas": n.get("Cas", ""), + "nositel_body": n.get("Bodyaktualni", ""), + }) + return radky + + +def nastav_list(ws): + """Záhlaví + šířky sloupců.""" + hl_font = Font(bold=True, color="FFFFFF", size=10) + hl_fill = PatternFill("solid", fgColor=BARVA_HLAVICKA) + hl_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin = Side(style="thin", color="AAAAAA") + border = Border(left=thin, right=thin, bottom=thin) + + for col_idx, (_, label, sirka) in enumerate(SLOUPCE, 1): + cell = ws.cell(row=1, column=col_idx, value=label) + cell.font = hl_font + cell.fill = hl_fill + cell.alignment = hl_align + cell.border = border + ws.column_dimensions[get_column_letter(col_idx)].width = sirka + + ws.row_dimensions[1].height = 30 + ws.freeze_panes = "A2" + + +def zapis_radek(ws, row_idx: int, radek: dict, barva: str): + fill = PatternFill("solid", fgColor=barva) + thin = Side(style="thin", color="DDDDDD") + border = Border(left=thin, right=thin, bottom=thin) + align_def = Alignment(vertical="top", wrap_text=False) + align_wrap= Alignment(vertical="top", wrap_text=True) + + for col_idx, (klic, _, _) in enumerate(SLOUPCE, 1): + hodnota = radek.get(klic, "") + cell = ws.cell(row=row_idx, column=col_idx, value=hodnota) + cell.fill = fill + cell.border = border + cell.alignment = align_wrap if klic in ("nazev", "postup", "nositel_funkce") else align_def + + +def main(): + print("Připojuji k MongoDB...") + client = MongoClient(MONGO_URI) + col_vykony = client[MONGO_DB]["vykony"] + col_detaily = client[MONGO_DB]["detaily"] + + # Zjisti odbornosti + if ODBORNOSTI: + odbornosti = ODBORNOSTI + else: + odbornosti = sorted(col_vykony.distinct("odbornost", {"_aktivni": True})) + print(f"Odbornosti: {odbornosti}") + + wb = openpyxl.Workbook() + wb.remove(wb.active) # odstraň defaultní prázdný list + + for odbornost in odbornosti: + print(f" Zpracovávám odbornost {odbornost}...") + + vykony = list(col_vykony.find( + {"odbornost": odbornost, "_aktivni": True}, + {"_id": 0}, + ).sort("cislo_vykonu", 1)) + + cisla = [v["cislo_vykonu"] for v in vykony] + detaily_map = { + d["cislo_vykonu"]: d + for d in col_detaily.find({"cislo_vykonu": {"$in": cisla}}, {"_id": 0}) + } + + ws = wb.create_sheet(title=f"Odbornost {odbornost}") + nastav_list(ws) + + row_idx = 2 + for vykon_idx, v in enumerate(vykony): + d = detaily_map.get(v["cislo_vykonu"], {}) + radky = _vykon_radky(v, d) + barva = BARVA_VYKON_A if vykon_idx % 2 == 0 else BARVA_VYKON_B + for radek in radky: + zapis_radek(ws, row_idx, radek, barva) + row_idx += 1 + + print(f" → {row_idx - 2} řádků, {len(vykony)} výkonů") + + client.close() + + wb.save(VYSTUP) + print(f"\nUloženo: {VYSTUP}") + + +if __name__ == "__main__": + main() diff --git a/Vykony/stahni_detaily.py b/Vykony/stahni_detaily.py new file mode 100644 index 0000000..ece94b4 --- /dev/null +++ b/Vykony/stahni_detaily.py @@ -0,0 +1,334 @@ +""" +Stahování detailů zdravotních výkonů ze szv.mzd.gov.cz. + +Čte cislo_vykonu z kolekce `vykony`, stahuje detailní stránky +a ukládá do kolekce `detaily` (upsert podle cislo_vykonu). + +Požadavky: + pip install requests beautifulsoup4 pymongo lxml +""" + +# ── Nastavení skriptu ────────────────────────────────────────────────────────── +KOLIK = 0 # 0 = vše; jinak maximální počet výkonů ke stažení (např. 50) +FORCE = True # True = přestáhni i už stažené záznamy +WORKERS = 5 # počet paralelních vláken +# ────────────────────────────────────────────────────────────────────────────── + +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone + +import requests +from bs4 import BeautifulSoup +from pymongo import MongoClient, UpdateOne, InsertOne + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +MONGO_URI = "mongodb://192.168.1.76:27017/" +MONGO_DB = "zdravotni_vykony" +COL_VYKONY = "vykony" +COL_DETAILY = "detaily" +COL_DETAILY_HISTORIE = "detaily_historie" + +# Pole porovnávaná pro detekci změn (metadata vynecháme) +COMPARE_FIELDS = [ + "nazev", "poznamka", "kategorie", "typ_formulare", + "omezeni_mistem", "omezeni_frekvenci", "doba_trvani", + "nepocitat_rezii", "popis", "cim_zacina", "obsah_rozsah", + "cim_konci", "podminky", + "autorska_odbornost", "dalsi_odbornost", "nositele", + "materialy", "pripravky", "pristroje", "zum", "zulp", + "body_prime", "body_osobni", "body_rezijni", "body_celkem", +] + +BASE_URL = "https://szv.mzd.gov.cz" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "cs,en;q=0.9", +} + +# Mapování label → klíč pro skalární pole +SCALAR_MAP = { + "Číslo výkonu": "cislo_vykonu", + "Název": "nazev", + "Poznámka": "poznamka", + "Kategorie": "kategorie", + "Typ formuláře": "typ_formulare", + "Omezení místem": "omezeni_mistem", + "Omezení frekvencí": "omezeni_frekvenci", + "Doba trvání": "doba_trvani", + "Popis": "popis", + "Čím výkon začíná": "cim_zacina", + "Obsah a rozsah výkonu": "obsah_rozsah", + "Čím výkon končí": "cim_konci", + "Podmínky": "podminky", +} + +# Mapování label → klíč pro vnořené sub-tabulky +SUB_MAP = { + "Autorská odbornost": "autorska_odbornost", + "Další odbornost": "dalsi_odbornost", + "Nositelé": "nositele", + "Materiály": "materialy", + "Přípravky": "pripravky", + "Přístroje": "pristroje", + "ZUM": "zum", + "ZULP": "zulp", + "Bodová hodnota": "bodova_hodnota", +} + + +def _to_float(s: str) -> float | None: + s = s.strip().replace("\xa0", "").replace(" ", "").replace(" ", "").replace(",", ".") + try: + return float(s) + except (ValueError, AttributeError): + return None + + +def _parse_subtable(table_el) -> list[dict]: + """Parsuje vnořenou tabulku → seznam diktů. Přeskočí řádek Celkem.""" + rows = table_el.find_all("tr") + if not rows: + return [] + headers = [th.get_text(strip=True) for th in rows[0].find_all(["th", "td"])] + if not headers: + return [] + records = [] + for row in rows[1:]: + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if not cells: + continue + if cells[0].lower().startswith("celkem"): + continue + records.append(dict(zip(headers, cells))) + return records + + +def parse_detail(html: str, cislo: str) -> dict: + soup = BeautifulSoup(html, "lxml") + doc: dict = { + "cislo_vykonu": cislo, + "detail_url": f"{BASE_URL}/Vykon/Detail/{cislo}/", + } + + main_table = soup.select_one("table.detailTabulka") + if not main_table: + return doc + + # Řádky: + for tr in main_table.find_all("tr", recursive=False): + th = tr.find("th", recursive=False) + value_td = tr.find("td", recursive=False) + if not th or not value_td: + continue + label = th.get_text(strip=True) + + if label == "Nepočítat režii": + chk = value_td.find("input", {"type": "checkbox"}) + doc["nepocitat_rezii"] = bool(chk and chk.has_attr("checked")) + + elif label in SCALAR_MAP: + key = SCALAR_MAP[label] + doc[key] = value_td.get_text(strip=True) + + elif label in SUB_MAP: + key = SUB_MAP[label] + nested = value_td.find("table") + doc[key] = _parse_subtable(nested) if nested else [] + + # Záloha: checkbox mimo párový řádek + if "nepocitat_rezii" not in doc: + chk = soup.find("input", {"type": "checkbox"}) + doc["nepocitat_rezii"] = bool(chk and chk.has_attr("checked")) + + # Číselné přetypování skalárů + if "doba_trvani" in doc: + doc["doba_trvani"] = _to_float(doc["doba_trvani"]) + + # Bodová hodnota: tabulka s 1 datovým řádkem → flatten na skalární pole + bh = doc.get("bodova_hodnota", []) + if bh and isinstance(bh, list) and len(bh) == 1: + row = bh[0] + doc["body_prime"] = _to_float(row.get("Přímé", "")) + doc["body_osobni"] = _to_float(row.get("Osobní", "")) + doc["body_rezijni"] = _to_float(row.get("Režijní", "")) + doc["body_celkem"] = _to_float(row.get("Celkem", "")) + + return doc + + +def fetch_detail(cislo: str, session: requests.Session) -> dict | None: + url = f"{BASE_URL}/Vykon/Detail/{cislo}/" + try: + resp = session.get(url, headers=HEADERS, timeout=30) + resp.raise_for_status() + resp.encoding = resp.apparent_encoding or "utf-8" + return parse_detail(resp.text, cislo) + except Exception as exc: + logger.warning(f"Chyba při stahování {cislo}: {exc}") + return None + + +def worker(cislo: str) -> dict | None: + """Každý worker má vlastní session (thread-safe).""" + s = requests.Session() + result = fetch_detail(cislo, s) + time.sleep(0.1) + return result + + +def process_changes( + new_detaily: list[dict], + col_detaily, + col_historie, + run_at: datetime, +) -> dict: + existing = {d["cislo_vykonu"]: d for d in col_detaily.find({}, {"_id": 0})} + + ops_detaily = [] + ops_historie = [] + stats = {"novy": 0, "zmenen": 0, "nezmenen": 0} + + for detail in new_detaily: + cid = detail["cislo_vykonu"] + detail["_scraped_at"] = run_at + + if cid not in existing: + detail["_platny_od"] = run_at + ops_detaily.append(UpdateOne({"cislo_vykonu": cid}, {"$set": detail}, upsert=True)) + stats["novy"] += 1 + else: + old = existing[cid] + changed = [f for f in COMPARE_FIELDS if old.get(f) != detail.get(f)] + if changed: + archive = dict(old) + archive["_platny_do"] = run_at + archive["_zmenena_pole"] = changed + ops_historie.append(InsertOne(archive)) + + detail["_platny_od"] = run_at + ops_detaily.append(UpdateOne({"cislo_vykonu": cid}, {"$set": detail})) + stats["zmenen"] += 1 + else: + ops_detaily.append(UpdateOne( + {"cislo_vykonu": cid}, + {"$set": {"_scraped_at": run_at}}, + )) + stats["nezmenen"] += 1 + + if ops_detaily: + col_detaily.bulk_write(ops_detaily, ordered=False) + if ops_historie: + col_historie.bulk_write(ops_historie, ordered=False) + + return stats + + +def create_indexes(col_detaily, col_historie): + col_detaily.create_index("cislo_vykonu", unique=True) + col_detaily.create_index("_scraped_at") + col_detaily.create_index("_platny_od") + + col_historie.create_index("cislo_vykonu") + col_historie.create_index("_platny_do") + col_historie.create_index("_zmenena_pole") + logger.info("Indexy vytvořeny") + + +def main(): + logger.info("=== Spouštím stahování detailů výkonů ===") + logger.info(f"Nastavení: KOLIK={KOLIK or 'vše'}, FORCE={FORCE}, WORKERS={WORKERS}") + run_at = datetime.now(timezone.utc) + + client = MongoClient(MONGO_URI) + col_vykony = client[MONGO_DB][COL_VYKONY] + col_detaily = client[MONGO_DB][COL_DETAILY] + col_historie = client[MONGO_DB][COL_DETAILY_HISTORIE] + create_indexes(col_detaily, col_historie) + + vsechna_cisla = [ + d["cislo_vykonu"] + for d in col_vykony.find({"_aktivni": True}, {"cislo_vykonu": 1, "_id": 0}) + ] + logger.info(f"Aktivních výkonů v DB: {len(vsechna_cisla)}") + + if not FORCE: + uz_stazeno = { + d["cislo_vykonu"] + for d in col_detaily.find({}, {"cislo_vykonu": 1, "_id": 0}) + } + cisla = [c for c in vsechna_cisla if c not in uz_stazeno] + logger.info(f"Již staženo: {len(uz_stazeno)}, zbývá: {len(cisla)}") + else: + cisla = vsechna_cisla + logger.info("FORCE=True: přestahuju vše") + + if KOLIK: + cisla = cisla[:KOLIK] + logger.info(f"KOLIK={KOLIK}: omezuji na {len(cisla)} výkonů") + + if not cisla: + logger.info("Nic ke stahování.") + client.close() + return + + # Stahování + fetch_chyby = 0 + batch: list[dict] = [] + BATCH_SIZE = 100 + + def flush_batch(): + if batch: + stats = process_changes(batch, col_detaily, col_historie, run_at) + batch.clear() + return stats + return {"novy": 0, "zmenen": 0, "nezmenen": 0} + + total_stats = {"novy": 0, "zmenen": 0, "nezmenen": 0} + + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + futures = {pool.submit(worker, c): c for c in cisla} + done = 0 + for future in as_completed(futures): + done += 1 + result = future.result() + + if result: + batch.append(result) + else: + fetch_chyby += 1 + + if len(batch) >= BATCH_SIZE: + s = flush_batch() + for k in total_stats: + total_stats[k] += s[k] + + if done % 200 == 0 or done == len(cisla): + logger.info( + f"Průběh: {done}/{len(cisla)} " + f"(chyby stahování: {fetch_chyby})" + ) + + s = flush_batch() + for k in total_stats: + total_stats[k] += s[k] + + client.close() + + logger.info( + f"Výsledek: nové={total_stats['novy']}, změněné={total_stats['zmenen']}, " + f"nezměněné={total_stats['nezmenen']}, chyby={fetch_chyby}" + ) + logger.info("=== Hotovo ===") + + +if __name__ == "__main__": + main() diff --git a/Vykony/stahni_vykony.py b/Vykony/stahni_vykony.py new file mode 100644 index 0000000..43f53f8 --- /dev/null +++ b/Vykony/stahni_vykony.py @@ -0,0 +1,259 @@ +""" +Stahování zdravotních výkonů ze szv.mzd.gov.cz a uložení do MongoDB. + +Kolekce: + vykony -- aktuální stav každého výkonu (_aktivni, _platny_od) + vykony_historie -- archiv každé změny (_platny_do, _zmenena_pole) + +Požadavky: + pip install requests beautifulsoup4 pymongo lxml +""" + +import re +import requests +from bs4 import BeautifulSoup +from pymongo import MongoClient, UpdateOne, InsertOne +import logging +from datetime import datetime, timezone + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +MONGO_URI = "mongodb://192.168.1.76:27017/" +MONGO_DB = "zdravotni_vykony" +COL_VYKONY = "vykony" +COL_HISTORIE = "vykony_historie" + +BASE_URL = "https://szv.mzd.gov.cz" +COLS = ( + "Odbornost,CisloVykonu,NazevVykonu,Kategorie,TypVykonu," + "DobaTrvani,OmezeniMistem,OmezeniFrekvenci,PrimeNaklady,Osobni," + "BodyRezijni,BodyCelkem,Revize,Detail" +) +PAGE_URL = f"{BASE_URL}/Vykon/?cols={COLS}&page={{page}}" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "cs,en;q=0.9", +} + +# Pole která se porovnávají pro detekci změn (metadata vynecháme) +COMPARE_FIELDS = [ + "odbornost", "nazev_vykonu", "kategorie", "typ_vykonu", + "doba_trvani", "omezeni_mistem", "omezeni_frekvenci", + "prime_naklady", "osobni", "body_rezijni", "body_celkem", + "revize", "detail_url", +] + + +def parse_decimal(value: str) -> float | None: + if not value or value.strip() in ("", "-", "–"): + return None + cleaned = value.strip().replace("\xa0", "").replace(" ", "").replace(",", ".") + try: + return float(cleaned) + except ValueError: + return None + + +def parse_date(value: str) -> datetime | None: + if not value or value.strip() in ("", "-"): + return None + try: + return datetime.strptime(value.strip(), "%d.%m.%Y") + except ValueError: + return None + + +def parse_page(html: str) -> tuple[list[dict], int, int]: + """Vrátí (výkony, zobrazenoDo, celkem).""" + soup = BeautifulSoup(html, "lxml") + + displayed_to = total = 0 + m = re.search(r"Zobrazeno\s+\d+\s+\S+\s+(\d+)\s+z\S+\s+z\s+(\d+)", soup.get_text(" ")) + if m: + displayed_to = int(m.group(1)) + total = int(m.group(2)) + + table = soup.find("table") + if not table: + return [], displayed_to, total + + vykony = [] + for row in table.find_all("tr")[1:]: + cells = row.find_all(["td", "th"]) + if len(cells) < 13: + continue + + def cell_text(idx): + cell = cells[idx] + for elem in cell.find_all(attrs={"title": True}): + t = elem.get("title", "").strip() + if t: + return t + return cell.get_text(separator=" ", strip=True) + + detail_link = None + if len(cells) >= 14: + link = cells[13].find("a") or cells[13].find("button") + if link: + href = link.get("href", "") or link.get("data-url", "") + if href: + detail_link = BASE_URL + href if href.startswith("/") else href + + vykony.append({ + "cislo_vykonu": cell_text(1).strip(), + "odbornost": cell_text(0).strip(), + "nazev_vykonu": cell_text(2).strip(), + "kategorie": cell_text(3).strip(), + "typ_vykonu": cell_text(4).strip(), + "doba_trvani": parse_decimal(cell_text(5)), + "omezeni_mistem": cell_text(6).strip() or None, + "omezeni_frekvenci": cell_text(7).strip() or None, + "prime_naklady": parse_decimal(cell_text(8)), + "osobni": parse_decimal(cell_text(9)), + "body_rezijni": parse_decimal(cell_text(10)), + "body_celkem": parse_decimal(cell_text(11)), + "revize": parse_date(cell_text(12)), + "detail_url": detail_link, + }) + + return vykony, displayed_to, total + + +def fetch_all(session: requests.Session) -> list[dict]: + all_vykony = [] + page = 1 + while True: + url = PAGE_URL.format(page=page) + logger.info(f"Stahuji stránku {page} ...") + resp = session.get(url, headers=HEADERS, timeout=60) + resp.raise_for_status() + resp.encoding = resp.apparent_encoding or "utf-8" + + batch, displayed_to, total = parse_page(resp.text) + all_vykony.extend(batch) + logger.info(f" {len(batch)} výkonů (zobrazeno {displayed_to} z {total})") + + if displayed_to >= total > 0: + logger.info("Poslední stránka — konec.") + break + if not batch: + logger.warning("Prázdná stránka — konec.") + break + if len(batch) < 50: + logger.info("Poslední stránka (méně než 50 záznamů) — konec.") + break + page += 1 + + return all_vykony + + +def process_changes(new_vykony: list[dict], col_vykony, col_historie, run_at: datetime) -> dict: + """ + Porovná nová data s aktuálním stavem v DB. + Změněné verze archivuje do vykony_historie. + Výkony co zmizely označí _aktivni=False. + """ + existing = {d["cislo_vykonu"]: d for d in col_vykony.find({}, {"_id": 0})} + new_ids = {v["cislo_vykonu"] for v in new_vykony} + + ops_vykony = [] + ops_historie = [] + stats = {"novy": 0, "zmenen": 0, "nezmenen": 0, "deaktivovan": 0} + + for vykon in new_vykony: + cid = vykon["cislo_vykonu"] + vykon["_aktivni"] = True + vykon["_scraped_at"] = run_at + + if cid not in existing: + vykon["_platny_od"] = run_at + ops_vykony.append(UpdateOne({"cislo_vykonu": cid}, {"$set": vykon}, upsert=True)) + stats["novy"] += 1 + else: + old = existing[cid] + changed = [f for f in COMPARE_FIELDS if old.get(f) != vykon.get(f)] + if changed: + archive = {k: v for k, v in old.items()} + archive["_platny_do"] = run_at + archive["_zmenena_pole"] = changed + ops_historie.append(InsertOne(archive)) + + vykon["_platny_od"] = run_at + ops_vykony.append(UpdateOne({"cislo_vykonu": cid}, {"$set": vykon})) + stats["zmenen"] += 1 + else: + ops_vykony.append(UpdateOne( + {"cislo_vykonu": cid}, + {"$set": {"_scraped_at": run_at, "_aktivni": True}}, + )) + stats["nezmenen"] += 1 + + # Výkony co v novém scrape chybí → deaktivovat + for cid, old in existing.items(): + if cid not in new_ids and old.get("_aktivni", True): + archive = {k: v for k, v in old.items()} + archive["_platny_do"] = run_at + archive["_zmenena_pole"] = ["_aktivni"] + ops_historie.append(InsertOne(archive)) + ops_vykony.append(UpdateOne( + {"cislo_vykonu": cid}, + {"$set": {"_aktivni": False, "_deaktivovano": run_at}}, + )) + stats["deaktivovan"] += 1 + + if ops_vykony: + col_vykony.bulk_write(ops_vykony, ordered=False) + if ops_historie: + col_historie.bulk_write(ops_historie, ordered=False) + + return stats + + +def create_indexes(col_vykony, col_historie): + col_vykony.create_index("cislo_vykonu", unique=True) + col_vykony.create_index("odbornost") + col_vykony.create_index("_aktivni") + col_vykony.create_index("_platny_od") + + col_historie.create_index("cislo_vykonu") + col_historie.create_index("_platny_do") + col_historie.create_index("_zmenena_pole") + logger.info("Indexy vytvořeny") + + +def main(): + logger.info("=== Spouštím stahování zdravotních výkonů ===") + run_at = datetime.now(timezone.utc) + + session = requests.Session() + vykony = fetch_all(session) + + if not vykony: + logger.error("Žádná data nebyla naparsována!") + return + + logger.info(f"Celkem naparsováno: {len(vykony)} výkonů") + + client = MongoClient(MONGO_URI) + col_vykony = client[MONGO_DB][COL_VYKONY] + col_historie = client[MONGO_DB][COL_HISTORIE] + create_indexes(col_vykony, col_historie) + + stats = process_changes(vykony, col_vykony, col_historie, run_at) + logger.info( + f"Výsledek: nové={stats['novy']}, změněné={stats['zmenen']}, " + f"nezměněné={stats['nezmenen']}, deaktivované={stats['deaktivovan']}" + ) + + client.close() + logger.info("=== Hotovo ===") + + +if __name__ == "__main__": + main() diff --git a/Vykony/vykony_report.xlsx b/Vykony/vykony_report.xlsx new file mode 100644 index 0000000..afc45f5 Binary files /dev/null and b/Vykony/vykony_report.xlsx differ
: {[th.get_text(strip=True) for th in ths]}") + +# --- Zkus také tbody --- +tbody = main_table.find("tbody") +if tbody: + tbody_trs = tbody.find_all("tr", recursive=False) + print(f"\nTabulka má
labelhodnota