Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
+262
@@ -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: `<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
|
||||
```
|
||||
@@ -0,0 +1,866 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Detail - Zdravotní výkony</title>
|
||||
|
||||
|
||||
<link href="/Content/css/select2.min.css" rel="stylesheet"/>
|
||||
<script src="/Scripts/jquery-1.10.2.js"></script>
|
||||
<link href="/Content/PagedList.css" rel="stylesheet"/>
|
||||
<link href="/Content/smoke/smoke.min.css" rel="stylesheet"/>
|
||||
<link href="/Content/select2.min.css" rel="stylesheet"/>
|
||||
<link href="/Content/Site.css" rel="stylesheet"/>
|
||||
|
||||
<script src="/Scripts/Scripts.js"></script>
|
||||
|
||||
|
||||
|
||||
<link href="/Content/bootstrap.min.css" rel="stylesheet"/>
|
||||
<script src="/Scripts/bootstrap.min.js"></script>
|
||||
<script src="/Scripts/respond.js"></script>
|
||||
<script src="/Scripts/Smoke/js/smoke.min.js"></script>
|
||||
<script src="/Scripts/Smoke/lang/cs.min.js"></script>
|
||||
|
||||
<script src="/Scripts/jquery.unobtrusive-ajax.js" type="text/javascript"></script>
|
||||
<script src="/Scripts/jquery.validate.js" type="text/javascript"></script>
|
||||
<script src="/Scripts/jquery.validate.unobtrusive.js" type="text/javascript"></script>
|
||||
<script src="/Scripts/select2.js"></script>
|
||||
<link href="/Content/UpravenyGrid.css" rel="stylesheet"/>
|
||||
<link href="/Content/Style.css" rel="stylesheet"/>
|
||||
<script src="/Scripts/modernizr-2.6.2.js"></script>
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
|
||||
|
||||
<script>
|
||||
// override validačních jquery validačních funkcí tak aby braly desetinnou čárku
|
||||
$.validator.methods.number = function (value, element) {
|
||||
return this.optional(element) || /-?(?:\d+|\d{1,3}(?:[\s\.,]\d{3})+)(?:[\.,]\d+)?$/.test(value);
|
||||
}
|
||||
|
||||
$.validator.methods.range = function (value, element, param) {
|
||||
var globalizedValue = value.replace(",", ".");
|
||||
return this.optional(element) || (globalizedValue >= param[0] && globalizedValue <= param[1]);
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-125495940-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-125495940-1');
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dropdown:hover .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<div class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/">Úvod</a>
|
||||
</div>
|
||||
<ul class="nav navbar-nav">
|
||||
|
||||
|
||||
|
||||
<li class=" ">
|
||||
|
||||
<a href="/Vyhlaska" class="dropdown-toggle">Vyhláška</a>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li class=" ">
|
||||
|
||||
<a href="/Vykon" class="dropdown-toggle">Platné výkony</a>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li class="dropdown ">
|
||||
|
||||
<a href="/Ciselnik" class="dropdown-toggle">Číselníky</a>
|
||||
|
||||
|
||||
|
||||
<ul class="dropdown-menu" role="menu" aria-labelledby="dropdownCommonMenu">
|
||||
|
||||
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/Kategorie" class="dropdown-toggle" role="menuitem">Kategorie</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/Material" class="dropdown-toggle" role="menuitem">Materiál</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/Nositel" class="dropdown-toggle" role="menuitem">Nositel</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/Odbornost" class="dropdown-toggle" role="menuitem">Odbornost</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/Pripravek" class="dropdown-toggle" role="menuitem">Přípravek</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/Pristroj" class="dropdown-toggle" role="menuitem">Přístroj</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
<li>
|
||||
|
||||
<a href="/Ciselnik/OmezeniMistem" class="dropdown-toggle" role="menuitem">Omezení místem</a>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
|
||||
<li><a href="/Account/Register/" id="registerLink">Registrovat</a></li>
|
||||
|
||||
<li><a href="/Account/Login/" id="loginLink">Přihlásit</a></li>
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div style="height: 4em; margin: 10px; margin-left: 20px;" id="zahlavi">
|
||||
<a href="http://ec.europa.eu/ceskarepublika/index_cs.htm" style="border: none;">
|
||||
<img src="/Content/Images/Opz.png" alt="" style="float: left; margin-right: 1em; height: 4em;" />
|
||||
</a>
|
||||
<img src="/Content/Images/mzcr_logo.png" alt="" style="float: left; margin-right: 1em; height: 4em;" />
|
||||
<h1>Seznam zdravotních výkonů</h1>
|
||||
</div>
|
||||
<div class="container body-content">
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- display errors -->
|
||||
|
||||
|
||||
|
||||
<!-- display warnings -->
|
||||
|
||||
|
||||
|
||||
<!-- display success -->
|
||||
|
||||
|
||||
|
||||
<!-- display success -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
.has-feedback .smk-error-msg { position: relative; }
|
||||
|
||||
|
||||
|
||||
.clickable.otevrit { float: right; }
|
||||
|
||||
</style>
|
||||
|
||||
<h2>Registrační list - 01021</h2>
|
||||
|
||||
<h4>KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM</h4>
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
|
||||
<h4> Obsah registračního listu</h4>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div class="well">
|
||||
|
||||
<div class="form-inline">
|
||||
|
||||
|
||||
|
||||
<a class="btn btn-default" href="/Vykon/Tisk/01021/" target="_blank">Tisk</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<table class="detailTabulka">
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Číslo výkonu</th>
|
||||
|
||||
<td>01021</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Název</th>
|
||||
|
||||
<td class="zobrazeni NAZEV">
|
||||
|
||||
KOMPLEXNÍ VYŠETŘENÍ PRAKTICKÝM LÉKAŘEM
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Nepočítat režii
|
||||
|
||||
</th>
|
||||
|
||||
|
||||
|
||||
<td class="zobrazeni rezieNula">
|
||||
|
||||
<input class="check-box" disabled="disabled" type="checkbox" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Poznámka</th>
|
||||
|
||||
<td class="zobrazeni POZNAMKA">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Autorská odbornost
|
||||
|
||||
|
||||
|
||||
</th>
|
||||
|
||||
<td class="zobrazeni ODBORNOST">
|
||||
|
||||
|
||||
|
||||
<table class="VnitrniTabulka">
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Kód
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Název
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Pořadí
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Sazba režie
|
||||
|
||||
</th>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
|
||||
001
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
všeobecné praktické lékařství
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
1020
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
4,58
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Další odbornost
|
||||
|
||||
|
||||
|
||||
</th>
|
||||
|
||||
<td class="zobrazeni dalsiOdbornost">
|
||||
|
||||
|
||||
|
||||
<table class="VnitrniTabulka">
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Kód
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Název
|
||||
|
||||
</th>
|
||||
|
||||
|
||||
|
||||
<th>
|
||||
|
||||
Sazba režie
|
||||
|
||||
</th>
|
||||
|
||||
<th></th>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Kategorie</th>
|
||||
|
||||
<td class="zobrazeni KATEGORIE">
|
||||
|
||||
P - hrazen plně
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Typ formuláře
|
||||
|
||||
</th>
|
||||
|
||||
<td class="zobrazeni typ-vykonu">
|
||||
|
||||
ambulantní
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Omezení místem</th>
|
||||
|
||||
<td class="zobrazeni OMEZENIMISTEM">
|
||||
|
||||
A - pouze ambulantně
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Omezení frekvencí</th>
|
||||
|
||||
<td class="zobrazeni OMEZENIFREKVENCI">
|
||||
|
||||
1/1 den
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Doba trvání</th>
|
||||
|
||||
<td class="zobrazeni DOBATRVANI">
|
||||
|
||||
60
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Popis</th>
|
||||
|
||||
<td class="zobrazeni POPIS">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Čím výkon začíná</th>
|
||||
|
||||
<td class="zobrazeni definice1">
|
||||
|
||||
Prostudováním dokumentace pacienta a odběrem životní osobní a rodinné anamnézy.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Obsah a rozsah výkonu</th>
|
||||
|
||||
<td class="zobrazeni definice2">
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Čím výkon končí</th>
|
||||
|
||||
<td class="zobrazeni definice3">
|
||||
|
||||
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).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Podmínky</th>
|
||||
|
||||
<td class="zobrazeni podminky">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Nositelé
|
||||
|
||||
</th>
|
||||
|
||||
<td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<table class="VnitrniTabulka">
|
||||
|
||||
<tr>
|
||||
|
||||
|
||||
|
||||
<th>
|
||||
|
||||
Kategorie
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Funkce
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Praxe
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Cas
|
||||
|
||||
</th>
|
||||
|
||||
|
||||
|
||||
<th>
|
||||
|
||||
Bodyaktualni
|
||||
|
||||
</th>
|
||||
|
||||
<th>
|
||||
|
||||
Poznamka
|
||||
|
||||
</th>
|
||||
|
||||
<th></th>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
|
||||
|
||||
<td>
|
||||
|
||||
L3
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
<td>
|
||||
|
||||
všeobecný praktický lékař
|
||||
|
||||
</td>
|
||||
|
||||
<td class="doprava">
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
<td class="doprava">
|
||||
|
||||
60
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
<td class="doprava">
|
||||
|
||||
838,87
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tfoot>
|
||||
|
||||
<tr>
|
||||
|
||||
<td></td>
|
||||
|
||||
<td></td>
|
||||
|
||||
<td></td>
|
||||
|
||||
<td>Celkem:</td>
|
||||
|
||||
<td class="doprava">838,87 </td>
|
||||
|
||||
<td></td>
|
||||
|
||||
<td></td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</tfoot>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>
|
||||
|
||||
Materiály
|
||||
|
||||
</th>
|
||||
|
||||
<td>
|
||||
|
||||
|
||||
|
||||
<table class="VnitrniTabulka">
|
||||
@@ -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 <table> 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é <tr> potomky hlavní tabulky ---
|
||||
direct_trs = main_table.find_all("tr", recursive=False)
|
||||
print(f"\nPřímých <tr> 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>: {[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á <tbody> s {len(tbody_trs)} přímými <tr>")
|
||||
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}'")
|
||||
@@ -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()
|
||||
@@ -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: <th>label</th><td>hodnota</td>
|
||||
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()
|
||||
@@ -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()
|
||||
Binary file not shown.
Reference in New Issue
Block a user