263 lines
9.2 KiB
Markdown
263 lines
9.2 KiB
Markdown
# 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: `<table class="detailTabulka">`
|
||
- Řádky: `<th>label</th><td>hodnota</td>` (ne `<td><td>` jak by se čekalo)
|
||
- Sub-tabulky: `<table class="VnitrniTabulka">` uvnitř `<td>`
|
||
- 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
|
||
```
|