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: `label hodnota ` (ne `` 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
+
+
+
+
+
+ Číslo výkonu
+ 01021
+
+
+ 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,00
+
+ 838,87
+ 274,80
+ 1 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)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ©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}] : {[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á 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: label hodnota
+ 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