Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -12,6 +12,10 @@ __pycache__/
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Secrets (.env s API klíči - nikdy do gitu!)
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Certifikáty (soukromé klíče - nikdy do gitu!)
|
||||
**/*.pfx
|
||||
**/*.p12
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# EmailAgent — agent na přijaté faktury
|
||||
|
||||
Hlídá schránku **ordinace@buzalkova.cz** a ukládá PDF přílohy přijatých faktur.
|
||||
|
||||
## Ochrana proti duplicitám (2 vrstvy)
|
||||
|
||||
1. **`state.json`** — `message_id` zpracovaných mailů. Stejný mail se podruhé
|
||||
vůbec nestahuje (hlavní pojistka).
|
||||
2. **sha256 obsahu** — při startu se načtou hashe všech PDF v cílové složce;
|
||||
stažená příloha se porovná hned po stažení. Když je **stejný obsah** už ve
|
||||
složce (i pod jiným názvem), soubor se přeskočí (`[DUPLIKÁT]`) a ušetří se
|
||||
i AI volání za pojmenování. Řeší případ, kdy se `state.json` ztratí nebo
|
||||
dodavatel pošle fakturu dvakrát. Porovnává se obsah, ne název — AI název se
|
||||
mezi běhy může v části `[popis]` lehce lišit.
|
||||
|
||||
## Nasazení na unraid (Tower, 24/7)
|
||||
|
||||
Cílový adresář: `/mnt/user/Scripts/StahovaniFaktur/` (= `/scripts/StahovaniFaktur/`
|
||||
v kontejneru `python-runner`). Běh: `docker exec python-runner python3
|
||||
/scripts/StahovaniFaktur/faktury_agent.py`.
|
||||
|
||||
- `.env` na serveru má `STORAGE=dropbox` + `ANTHROPIC_API_KEY` + `DROPBOX_*`
|
||||
(na serveru není `Medevio/.env` ani Dropbox mount).
|
||||
- Závislosti v kontejneru: `dropbox msal pymupdf requests` (`_ensure_deps()` je
|
||||
doinstaluje i samo).
|
||||
- **Plánování** přes unraid **User Scripts plugin** (ne cron v dockeru):
|
||||
- wrapper `/boot/config/plugins/user.scripts/scripts/StahovaniFaktur/script`
|
||||
(`flock` + `docker exec`, loguje do `/mnt/user/Scripts/logs/stahovani_faktur.log`),
|
||||
- rozvrh `0 6,18 * * *` v `schedule.json` → `customSchedule.cron` → `update_cron`
|
||||
→ `/etc/cron.d/root`.
|
||||
- Pozn.: `/boot` je FAT32, skript NELZE spustit přímým execem — plugin ho pouští
|
||||
přes `startCustom.php` (`bash`), proto to funguje.
|
||||
- **Pozor na dvojí běh:** spouštět jen ze serveru, ne zároveň lokálně (oddělené
|
||||
`state.json`).
|
||||
|
||||
## Spuštění
|
||||
|
||||
```powershell
|
||||
cd U:\OrdinaceProjekt\EmailAgent
|
||||
python faktury_agent.py
|
||||
```
|
||||
|
||||
Skript je **idempotentní** — opakované spuštění nestáhne nic dvakrát
|
||||
(viz `state.json`). Lze přidat do Windows Task Scheduleru.
|
||||
|
||||
## Co dělá (tok)
|
||||
|
||||
1. **Graph API** (`graph_mail.py`) — načte nové maily s přílohou ze složek
|
||||
`Inbox` + přímé podsložky (vynechává Junk/Deleted/Sent/Drafts),
|
||||
od posledního běhu (`state.json` → `last_run`, s překryvem −1 den).
|
||||
2. **Levný předfiltr (Python, zdarma)** — propustí jen maily, kde se slovo
|
||||
`faktur*` vyskytuje v předmětu, těle, nebo v názvu přílohy.
|
||||
*Maily, které neprojdou, se rovnou označí jako zpracované.*
|
||||
3. **AI klasifikace (Claude, placené)** — jen na propuštěné maily. Model
|
||||
`claude-haiku-4-5` rozhodne `je_faktura: true/false` a vybere správnou
|
||||
**.pdf** přílohu (ne ISDOC/XML/obrázek/VOP/dodací list/objednávku).
|
||||
4. **Stažení** vybraného PDF přes Graph.
|
||||
5. **Ověření obsahu PDF (Python, zdarma)** — přečte text PDF (`fitz`/PyMuPDF)
|
||||
a hledá slovo `faktur*`:
|
||||
- `ano` → potvrzeno, uloží se.
|
||||
- `ne` → text fakturu neobsahuje (AI nejspíš vybrala špatnou přílohu) →
|
||||
**neukládá se**, jen zaloguje `[PDF NEPOTVRZENO]`.
|
||||
- `bez_textu` → PDF nemá textovou vrstvu (skenovaná faktura) → uloží se,
|
||||
ale zaloguje `[PDF BEZ TEXTU]` k ruční kontrole.
|
||||
6. **Návrh názvu (Claude nad textem PDF)** — `propose_filename()` z textu faktury
|
||||
vytěží datum/typ/dodavatele/číslo/popis/částku a vrátí jednotný název
|
||||
`YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf`. Pravidla převzata
|
||||
z `Faktury/FakturyRenameOpenAI.py` (např. Distribuce CZ → Ptáček). Při chybě
|
||||
nebo u skenu bez textu se použije původní název přílohy.
|
||||
7. **Uložení** pod navrženým názvem. Konflikty řeší přípona ` (2)`, ` (3)` …
|
||||
8. **Kategorie + přesun (Graph, destruktivní)** — mail se označí kategorií
|
||||
`ClaudeProcessed` (zelená) a přesune do `Inbox/ProcessedByAgent/Invoices`.
|
||||
Děje se i u duplikátu (úklid Inboxu). Vyžaduje **Mail.ReadWrite**.
|
||||
9. Zápis `state.json` + log `_log_faktury.txt`.
|
||||
10. **Summary e-mail** — po každém běhu se z `SUMMARY_FROM` (reports@buzalka.cz)
|
||||
pošle souhrn na `SUMMARY_TO` (vladimir.buzalka@buzalka.cz) přes Graph
|
||||
(`graph_mail.send_mail`, vyžaduje **Mail.Send**). Tělo = log běhu.
|
||||
|
||||
Na konci běhu skript vypíše **cenu AI** za běh — počet volání, tokeny a částku
|
||||
v USD i Kč (kurz `USD_TO_CZK`, ceník modelů v `PRICING`). Orientačně ~0,1 Kč
|
||||
na fakturu (klasifikace + pojmenování).
|
||||
|
||||
## Konfigurace (`faktury_agent.py`, sekce NASTAVENÍ)
|
||||
|
||||
| Konstanta | Význam |
|
||||
|-----------|--------|
|
||||
| `MAILBOX` | sledovaná schránka |
|
||||
| `TARGET_SUBPATH` | podsložka v Dropboxu — root přes `Knihovny/najdi_dropbox.py` |
|
||||
| `FIRST_RUN_DAYS` | kolik dní dozadu při prvním běhu (prázdný state) |
|
||||
| `ANTHROPIC_MODEL` | model pro klasifikaci faktura ano/ne |
|
||||
| `ANTHROPIC_NAMING_MODEL` | model pro návrh názvu souboru |
|
||||
| `FAKTUR_RE` | regex předfiltru (`faktur`) |
|
||||
| `CATEGORY` / `CATEGORY_COLOR` | kategorie přidaná zpracovanému mailu + barva |
|
||||
| `PROCESSED_FOLDER_PARTS` | cílová podsložka přesunu (pod Inbox) |
|
||||
| `SKIP_FOLDERS` | složky vynechané při skenování |
|
||||
|
||||
## Úložiště — lokální vs. Dropbox API (`storage.py`)
|
||||
|
||||
Cíl je stejná složka, ale dvěma cestami podle prostředí (`STORAGE`):
|
||||
|
||||
| `STORAGE` | Backend | Kdy |
|
||||
|-----------|---------|-----|
|
||||
| `local` (default) | `LocalStorage` — filesystem přes `najdi_dropbox.get_dropbox_root()` + `TARGET_SUBPATH` | Windows (je tu Dropbox mount) |
|
||||
| `dropbox` | `DropboxStorage` — Dropbox HTTP API, cesta `DROPBOX_TARGET_PATH` | unraid/server (bez Dropbox mountu) |
|
||||
|
||||
Společné rozhraní: `load_hashes()`, `hash_bytes(data)`, `save(name, data)`, `describe()`.
|
||||
Dedup: lokálně **sha256** obsahu, přes API **Dropbox content_hash** (blokový sha256
|
||||
z metadat — nestahuje soubory). Listing v Dropboxu **stránkuje**.
|
||||
|
||||
**Cílová složka:**
|
||||
`<Dropbox>\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté\`
|
||||
(Dropbox API: `/Ordinace/.../#040 Faktury přijaté`, Full Dropbox app).
|
||||
|
||||
**Po zpracování (destruktivní):** `CATEGORY` (= `ClaudeProcessed`, barva
|
||||
`CATEGORY_COLOR`) + přesun do `Inbox/` + `PROCESSED_FOLDER_PARTS`
|
||||
(= `ProcessedByAgent/Invoices`). Tato složka se při skenování přeskakuje
|
||||
(`SKIP_FOLDERS`).
|
||||
|
||||
## Autentizace
|
||||
|
||||
- **Microsoft Graph** — app registrace (application permissions) sdílená s
|
||||
`Knihovny/EmailMessagingGraph.py`. Vyžaduje granty **Mail.Read** (čtení/přílohy)
|
||||
a **Mail.ReadWrite** (kategorie + přesun mailů) a **Mail.Send** (summary e-mail).
|
||||
Credentials jsou natvrdo v `graph_mail.py` (tenant/client/secret).
|
||||
- **Anthropic** — klíč `ANTHROPIC_API_KEY` z `Medevio/.env`.
|
||||
- **Dropbox API** (jen `STORAGE=dropbox`) — `DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`
|
||||
/ `DROPBOX_APP_REFRESH_TOKEN` z `EmailAgent/.env` (gitignored). Full Dropbox app,
|
||||
účet vladimir.buzalka@buzalka.cz. Refresh token = trvalý, access token se obnovuje
|
||||
sám.
|
||||
|
||||
## Závislosti
|
||||
|
||||
`msal`, `requests`, `fitz` (PyMuPDF), a pro `STORAGE=dropbox` navíc **`dropbox`**
|
||||
(`pip install dropbox`). Na unraid/python-runner je nutné `dropbox` doinstalovat.
|
||||
|
||||
## Soubory
|
||||
|
||||
| Soubor | Popis |
|
||||
|--------|-------|
|
||||
| `faktury_agent.py` | hlavní skript |
|
||||
| `graph_mail.py` | vrstva nad Graphem (čtení/zápis zpráv, stahování příloh) |
|
||||
| `storage.py` | úložiště faktur — `LocalStorage` / `DropboxStorage` |
|
||||
| `.env` | Dropbox credentials + volitelně `STORAGE` (gitignored) |
|
||||
| `state.json` | ID zpracovaných mailů + `last_run` |
|
||||
| `_log_faktury.txt` | log běhů |
|
||||
|
||||
## Poznámky / TODO
|
||||
|
||||
- **Destruktivní** — zpracovaný mail se kategorizuje a přesouvá z Inboxu.
|
||||
Maily se nemažou. Přesun mění `message_id`; idempotenci hlídá `state.json`
|
||||
(původní id) i to, že přesunuté maily jsou v nesnímané podsložce.
|
||||
- Maily, které předfiltr **nepropustí**, se uloží do `processed_ids` jako
|
||||
vyřízené — když se prompt/pravidla později změní, znovu se nepřehodnotí.
|
||||
Pro re-test smaž příslušné ID ze `state.json`.
|
||||
- Graph nezvládne `$orderby` spolu s filtrem `hasAttachments` (`InefficientFilter`)
|
||||
— proto se na serveru neřadí.
|
||||
@@ -0,0 +1,23 @@
|
||||
# EmailAgent — TODO
|
||||
|
||||
## Plánované
|
||||
|
||||
- [ ] **OCR nad skeny** — faktury bez textové vrstvy (skenovaná PDF) dnes
|
||||
`pdf_faktur_check()` vrací `bez_textu` a soubor se uloží jen s varováním
|
||||
`[PDF BEZ TEXTU]`, bez ověření obsahu. Doplnit OCR (např. Tesseract /
|
||||
`ocrmypdf`, nebo render stránky přes `fitz` → OCR), aby se i u skenů ověřilo
|
||||
slovo `faktur*` a případně vytěžil text pro klasifikaci.
|
||||
|
||||
## Hotovo
|
||||
|
||||
- [x] Cílová složka přepnuta na ostrou `#040 Faktury přijaté`.
|
||||
- [x] Po zpracování: kategorie `ClaudeProcessed` + přesun do
|
||||
`Inbox/ProcessedByAgent/Invoices` (vyžaduje Mail.ReadWrite).
|
||||
|
||||
## Možná rozšíření (až se výsledky odladí)
|
||||
|
||||
- [ ] Plánované spouštění přes Windows Task Scheduler.
|
||||
- [ ] Přísnější režim: ukládat jen když text PDF potvrdí `faktur` (tvrdá brána)
|
||||
— možné až po doplnění OCR, jinak hrozí ztráta skenovaných faktur.
|
||||
- [ ] Zvážit dedup i proti podsložce `NamedInvoicesbyOpenAI` v cílové složce
|
||||
(dnes se hashuje jen top-level `*.pdf`).
|
||||
@@ -0,0 +1,135 @@
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:42:23 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:42:23Z
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:42:57 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:42:57Z
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:43:13 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:43:13Z
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:43:51 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:43:51Z
|
||||
[ULOŽENO] Faktura - Daňový doklad číslo_ 202604570 .pdf <- 'Faktura'
|
||||
[ULOŽENO] Faktura_261103225.pdf <- 'Faktura 261103225'
|
||||
[ULOŽENO] Faktura č.110606255.pdf <- 'Faktura č. :110606255'
|
||||
[NE] 'Faktura vydaná č. 96260214' — Jediná dostupná PDF příloha je FV96260214-isdoc.pdf, což je formát *.isdoc (strukturovaný dokument), nikoliv čitelné PDF. Pravidla preferují lidsky čitelné PDF. Zbývající příloha image001.png je obrázek.
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 3, uloženo 3 souborů.
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:44:54 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-06-09T04:44:28Z
|
||||
HOTOVO: prošlo 0 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:49:23 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:49:23Z
|
||||
[ULOŽENO] Faktura - Daňový doklad číslo_ 202604570 .pdf <- 'Faktura'
|
||||
[ULOŽENO] Faktura_261103225.pdf <- 'Faktura 261103225'
|
||||
[ULOŽENO] Faktura č.110606255.pdf <- 'Faktura č. :110606255'
|
||||
[ULOŽENO] FV96260214-isdoc.pdf <- 'Faktura vydaná č. 96260214'
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů.
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:54:36 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:54:36Z
|
||||
[ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura'
|
||||
[ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225'
|
||||
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
|
||||
[ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214'
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů.
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:57:14 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:57:14Z
|
||||
[ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura'
|
||||
[ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225'
|
||||
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
|
||||
[ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214'
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 4 souborů.
|
||||
CENA AI: 8 volání, tokeny input=10087 output=738, $0.0138 ≈ 0.34 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 06:59:54 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T04:59:54Z
|
||||
[DUPLIKÁT] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf už existuje (shodný obsah), přeskakuji
|
||||
[DUPLIKÁT] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf už existuje (shodný obsah), přeskakuji
|
||||
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
|
||||
[DUPLIKÁT] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf už existuje (shodný obsah), přeskakuji
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 1 souborů.
|
||||
CENA AI: 8 volání, tokeny input=10087 output=766, $0.0139 ≈ 0.35 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 07:02:33 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T05:02:33Z
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf)
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf)
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf)
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji (2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf)
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 0 souborů.
|
||||
CENA AI: 8 volání, tokeny input=10087 output=663, $0.0134 ≈ 0.34 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 07:04:11 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\!!!Days\Downloads Z230\Faktury
|
||||
Hledám maily od: 2026-05-27T05:04:11Z
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura - Daňový doklad číslo_ 202604570 .pdf')
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura_261103225.pdf')
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('Faktura č.110606255.pdf')
|
||||
[DUPLIKÁT] obsah už ve složce je, přeskakuji ('FV96260214-isdoc.pdf')
|
||||
HOTOVO: prošlo 15 mailů, předfiltrem 4, faktur 4, uloženo 0 souborů.
|
||||
CENA AI: 4 volání, tokeny input=2662 output=513, $0.0052 ≈ 0.13 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 07:18:23 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
|
||||
Hledám maily od: 2026-05-27T05:18:20Z
|
||||
[ULOŽENO] 2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf <- 'Faktura'
|
||||
[ULOŽENO] 2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf <- 'Faktura 261103225'
|
||||
[ULOŽENO] 2026-06-01 Faktura QuickSeal 110606255 [VivaDiag Hydroxyvitamin D3 testy, poštovné] [2620.00 CZK].pdf <- 'Faktura č. :110606255'
|
||||
[ULOŽENO] 2026-06-10 Faktura Ptáček 202604906 [vakcína ADACEL] [1214.08 CZK].pdf <- 'Faktura'
|
||||
[ULOŽENO] 2026-06-01 Faktura Poliklinika Prosek 96260214 [nájemné a služby] [28363.00 CZK].pdf <- 'Faktura vydaná č. 96260214'
|
||||
HOTOVO: prošlo 16 mailů, předfiltrem 5, faktur 5, uloženo 5 souborů.
|
||||
CENA AI: 10 volání, tokeny input=12362 output=883, $0.0168 ≈ 0.42 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 08:14:11 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
|
||||
Hledám maily od: 2026-05-27T06:14:08Z
|
||||
HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
|
||||
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 08:14:37 schránka=ordinace@buzalkova.cz
|
||||
Cíl: Dropbox:/Ordinace/!!MUDr. Michaela Buzalková s.r.o/Prosek/#040 Faktury přijaté
|
||||
Hledám maily od: 2026-05-27T06:14:35Z
|
||||
HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
|
||||
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 08:18:18 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
|
||||
Hledám maily od: 2026-05-27T06:18:15Z
|
||||
HOTOVO: prošlo 11 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
|
||||
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
|
||||
|
||||
======================================================================
|
||||
START 2026-06-10 08:30:02 schránka=ordinace@buzalkova.cz
|
||||
Cíl: U:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté
|
||||
Hledám maily od: 2026-06-09T06:18:26Z
|
||||
HOTOVO: prošlo 0 mailů, předfiltrem 0, faktur 0, uloženo 0 souborů.
|
||||
CENA AI: 0 volání, tokeny input=0 output=0, $0.0000 ≈ 0.00 Kč (kurz 1 USD = 25 Kč)
|
||||
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
faktury_agent.py
|
||||
----------------
|
||||
Agent, který ve schránce ordinace@buzalkova.cz hledá PŘIJATÉ FAKTURY
|
||||
a ukládá jejich PDF přílohy do Dropbox složky ke kontrole.
|
||||
|
||||
Tok:
|
||||
1. Microsoft Graph: načti nové maily s přílohou (od posledního běhu).
|
||||
2. LEVNÝ PŘEDFILTR (Python, zdarma): nech jen maily, kde se slovo "faktur*"
|
||||
vyskytuje kdekoliv v textu (předmět/tělo) NEBO v názvu přílohy.
|
||||
3. AI KLASIFIKACE (Claude, placené): jen na propuštěné maily — model rozhodne,
|
||||
zda jde o přijatou fakturu, a vybere správnou PDF přílohu (ne ISDOC,
|
||||
ne dodací list, ne VOP, ne objednávku).
|
||||
4. Stáhni vybranou přílohu přes Graph a ulož do cílové složky.
|
||||
5. Zapiš stav (idempotence) a log.
|
||||
|
||||
Spouštěj opakovaně — už zpracované maily se přeskakují (state.json) a existující
|
||||
soubory se nepřepisují.
|
||||
"""
|
||||
|
||||
import html
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_deps():
|
||||
"""Doinstaluje chybějící balíčky třetích stran (běh na čistém serveru)."""
|
||||
needed = {"requests": "requests", "msal": "msal",
|
||||
"fitz": "PyMuPDF", "dropbox": "dropbox"}
|
||||
missing = []
|
||||
for mod, pkg in needed.items():
|
||||
try:
|
||||
importlib.import_module(mod)
|
||||
except ImportError:
|
||||
missing.append(pkg)
|
||||
if missing:
|
||||
print(f"Instaluji chybějící balíčky: {', '.join(missing)}")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet", *missing]
|
||||
)
|
||||
|
||||
|
||||
_ensure_deps()
|
||||
|
||||
import fitz # PyMuPDF # noqa: E402
|
||||
import requests # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import graph_mail # noqa: E402
|
||||
import storage as storage_mod # noqa: E402
|
||||
|
||||
# =========================
|
||||
# NASTAVENÍ
|
||||
# =========================
|
||||
MAILBOX = "ordinace@buzalkova.cz"
|
||||
|
||||
# Cílová složka pro PDF faktur.
|
||||
# - LOCAL backend: podsložka pod Dropbox rootem (najdi_dropbox).
|
||||
# - DROPBOX backend: cesta od kořene Dropboxu (Full Dropbox app).
|
||||
TARGET_SUBPATH = [
|
||||
"Ordinace", "!!MUDr. Michaela Buzalková s.r.o", "Prosek", "#040 Faktury přijaté"
|
||||
]
|
||||
DROPBOX_TARGET_PATH = "/" + "/".join(TARGET_SUBPATH)
|
||||
|
||||
# Po zpracování: označit mail kategorií a přesunout do podsložky Inboxu.
|
||||
CATEGORY = "ClaudeProcessed"
|
||||
CATEGORY_COLOR = "preset4" # zelená (Outlook preset paleta)
|
||||
PROCESSED_FOLDER_PARTS = ["ProcessedByAgent", "Invoices"] # pod Inbox
|
||||
# Tuto složku při skenování přeskoč (jsou v ní už zpracované maily).
|
||||
SKIP_FOLDERS = {"ProcessedByAgent"}
|
||||
|
||||
# Summary e-mail po každém běhu (přes Graph, app má Mail.Send).
|
||||
SUMMARY_FROM = "reports@buzalka.cz"
|
||||
SUMMARY_TO = "vladimir.buzalka@buzalka.cz"
|
||||
|
||||
# Při prvním běhu (prázdný state) se prohledá posledních N dní.
|
||||
FIRST_RUN_DAYS = 14
|
||||
|
||||
# Claude model pro klasifikaci (levný, na text stačí).
|
||||
ANTHROPIC_MODEL = "claude-haiku-4-5"
|
||||
|
||||
# Claude model pro návrh názvu souboru (vytěžení datumu/dodavatele/částky
|
||||
# z textu faktury). Lze zvednout na silnější model, pokud názvy nesedí.
|
||||
ANTHROPIC_NAMING_MODEL = "claude-haiku-4-5"
|
||||
|
||||
# Předfiltr: slovo "faktur" kdekoliv (faktura, faktury, fakturace, faktuře...).
|
||||
FAKTUR_RE = re.compile(r"faktur", re.IGNORECASE)
|
||||
|
||||
# Cena Claude API — USD za 1M tokenů (input, output). Kurz pro přepočet.
|
||||
USD_TO_CZK = 25.0
|
||||
PRICING = {
|
||||
"claude-haiku-4-5": (1.00, 5.00),
|
||||
"claude-sonnet-4-6": (3.00, 15.00),
|
||||
"claude-opus-4-8": (5.00, 25.00),
|
||||
}
|
||||
|
||||
# Akumulátor nákladů aktuálního běhu (plní _claude_json).
|
||||
_cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0}
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE_FILE = HERE / "state.json"
|
||||
LOG_FILE = HERE / "_log_faktury.txt"
|
||||
|
||||
|
||||
# =========================
|
||||
# ENV (Anthropic klíč)
|
||||
# =========================
|
||||
def _load_env_file(env_path: Path):
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
os.environ[k.strip()] = v.strip().strip('"').strip("'")
|
||||
|
||||
|
||||
def _load_env():
|
||||
# Medevio/.env (ANTHROPIC_API_KEY) + EmailAgent/.env (DROPBOX_*, STORAGE).
|
||||
_load_env_file(Path(__file__).resolve().parent.parent / "Medevio" / ".env")
|
||||
_load_env_file(Path(__file__).resolve().parent / ".env")
|
||||
|
||||
|
||||
_load_env()
|
||||
|
||||
|
||||
# =========================
|
||||
# POMOCNÉ
|
||||
# =========================
|
||||
_email_lines = [] # řádky aktuálního běhu pro summary e-mail
|
||||
|
||||
|
||||
def _now_str(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""Aktuální čas v pražském pásmu (i když server běží v UTC)."""
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
dt = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
except Exception:
|
||||
dt = datetime.now() # fallback: lokální čas stroje
|
||||
return dt.strftime(fmt)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg)
|
||||
_email_lines.append(msg)
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
return {"processed_ids": [], "last_run": None}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_FILE.write_text(
|
||||
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def since_iso(state: dict) -> str:
|
||||
if state.get("last_run"):
|
||||
# malý překryv -1 den pro jistotu (idempotence to pohlídá)
|
||||
dt = datetime.fromisoformat(state["last_run"]) - timedelta(days=1)
|
||||
else:
|
||||
dt = datetime.now(timezone.utc) - timedelta(days=FIRST_RUN_DAYS)
|
||||
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def sanitize(name: str) -> str:
|
||||
name = re.sub(r'[<>:"/\\|?*]', " ", name)
|
||||
name = re.sub(r"\s+", " ", name).strip().rstrip(" .")
|
||||
return name
|
||||
|
||||
|
||||
def sanitize_pdf_name(name: str) -> str:
|
||||
"""Očistí navržený název pro Windows a zajistí příponu .pdf."""
|
||||
name = sanitize(name)
|
||||
if not name.lower().endswith(".pdf"):
|
||||
name += ".pdf"
|
||||
return name
|
||||
|
||||
|
||||
# =========================
|
||||
# PŘEDFILTR (zdarma)
|
||||
# =========================
|
||||
def passes_prefilter(msg: dict, attachments: list[dict]) -> bool:
|
||||
subject = msg.get("subject") or ""
|
||||
body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or ""
|
||||
if FAKTUR_RE.search(subject) or FAKTUR_RE.search(body):
|
||||
return True
|
||||
for a in attachments:
|
||||
if FAKTUR_RE.search(a.get("name") or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# =========================
|
||||
# OVĚŘENÍ OBSAHU PDF (Python, zdarma)
|
||||
# =========================
|
||||
def extract_pdf_text(data: bytes) -> str:
|
||||
"""Vrátí text z PDF (prázdný řetězec, pokud nelze — sken/chyba)."""
|
||||
try:
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
text = "".join(page.get_text() for page in doc)
|
||||
doc.close()
|
||||
return text
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def faktur_status(pdf_text: str) -> str:
|
||||
"""
|
||||
"ano" (text obsahuje faktur), "ne" (text bez faktur),
|
||||
"bez_textu" (PDF nemá extrahovatelný text — nejspíš sken).
|
||||
"""
|
||||
if not pdf_text.strip():
|
||||
return "bez_textu"
|
||||
return "ano" if FAKTUR_RE.search(pdf_text) else "ne"
|
||||
|
||||
|
||||
# =========================
|
||||
# AI KLASIFIKACE (Claude)
|
||||
# =========================
|
||||
PROMPT = """Jsi asistent ordinace praktického lékaře. Rozhoduješ, zda e-mail obsahuje \
|
||||
PŘIJATOU FAKTURU (daňový doklad k zaplacení), a pokud ano, vybíráš PDF přílohu, \
|
||||
která tu fakturu obsahuje.
|
||||
|
||||
Pravidla:
|
||||
- "je_faktura": true POUZE pokud jde o skutečnou přijatou fakturu / daňový doklad.
|
||||
- NENÍ faktura: objednávka, dodací list, předávací protokol, zálohová faktura bez \
|
||||
plnění, obchodní podmínky (VOP), upomínka, newsletter, zdravotní zpráva, žádanka.
|
||||
- "soubor_faktury": přesný název přílohy s fakturou, která má příponu .pdf. \
|
||||
Rozhoduje POUZE skutečná přípona souboru: soubor končící na ".pdf" je platný, i když \
|
||||
má v názvu slovo "isdoc" (např. "FV123-isdoc.pdf" je běžné PDF faktury — vyber ho). \
|
||||
NEVybírej soubory s příponou .isdoc / .xml / .png / .jpg / .zip ani VOP/obchodní podmínky.
|
||||
- Pokud faktura není, vrať "je_faktura": false a "soubor_faktury": null.
|
||||
|
||||
Vrať POUZE JSON:
|
||||
{"je_faktura": true/false, "soubor_faktury": "nazev.pdf"|null, "duvod": "krátké zdůvodnění"}
|
||||
|
||||
E-MAIL:
|
||||
Odesílatel: %(sender)s
|
||||
Předmět: %(subject)s
|
||||
Přílohy: %(attachments)s
|
||||
|
||||
Tělo (zkráceno):
|
||||
%(body)s
|
||||
"""
|
||||
|
||||
|
||||
def _claude_json(prompt: str, model: str, max_tokens: int) -> dict:
|
||||
"""Zavolá Claude a vrátí JSON objekt z odpovědi."""
|
||||
r = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# Náklady: posbírej tokeny a přičti cenu podle modelu.
|
||||
usage = data.get("usage", {})
|
||||
in_tok = usage.get("input_tokens", 0)
|
||||
out_tok = usage.get("output_tokens", 0)
|
||||
price_in, price_out = PRICING.get(model, PRICING["claude-haiku-4-5"])
|
||||
_cost["input_tokens"] += in_tok
|
||||
_cost["output_tokens"] += out_tok
|
||||
_cost["usd"] += in_tok / 1_000_000 * price_in + out_tok / 1_000_000 * price_out
|
||||
_cost["calls"] += 1
|
||||
|
||||
text = data["content"][0]["text"].strip()
|
||||
m = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not m:
|
||||
raise ValueError(f"Claude nevrátil JSON: {text}")
|
||||
return json.loads(m.group(0))
|
||||
|
||||
|
||||
def classify(msg: dict, attachments: list[dict]) -> dict:
|
||||
sender = (msg.get("from") or {}).get("emailAddress", {})
|
||||
body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or ""
|
||||
prompt = PROMPT % {
|
||||
"sender": f"{sender.get('name','')} <{sender.get('address','')}>",
|
||||
"subject": msg.get("subject") or "",
|
||||
"attachments": ", ".join(a.get("name", "") for a in attachments) or "(žádné)",
|
||||
"body": body[:4000],
|
||||
}
|
||||
return _claude_json(prompt, ANTHROPIC_MODEL, 300)
|
||||
|
||||
|
||||
# =========================
|
||||
# NÁVRH NÁZVU SOUBORU (Claude nad textem faktury)
|
||||
# =========================
|
||||
# Pravidla převzatá z Faktury/FakturyRenameOpenAI.py, upravená na vstup = text.
|
||||
NAMING_RULES = """Jsi pomocník pro pojmenování PDF faktur a dokladů MUDr. Michaely Buzalkové.
|
||||
|
||||
ÚKOL:
|
||||
Z TEXTU faktury/dokladu vytěž datum, typ dokladu, dodavatele, číslo dokladu, stručný popis, částku a měnu.
|
||||
Vrať POUZE JSON s polem "filename".
|
||||
|
||||
CÍLOVÝ FORMÁT:
|
||||
YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
|
||||
|
||||
PŘÍKLADY:
|
||||
2026-06-01 Faktura ASKER 261103225 [kontejner Yannick 1.5 l] [339.00 CZK].pdf
|
||||
2026-06-01 Faktura MEDIPOS 10195703 [CRP, kapiláry, písty, rukavice, nádoba] [5578.97 CZK].pdf
|
||||
2026-05-29 Faktura Ptáček 202604570 [vakcíny Adacel, Vaqta, Havrix] [9235.20 CZK].pdf
|
||||
2026-05-29 Faktura Poliklinika Prosek 91260763 [lékárna] [16165.40 CZK].pdf
|
||||
2026-06-01 Dodací list QuickSeal 200609058 [VivaDiag Hydroxyvitamin D3] [2620.00 CZK].pdf
|
||||
|
||||
DŮLEŽITÁ PRAVIDLA:
|
||||
1. Prefix [POHODA] nikdy nepřidávej.
|
||||
2. Používej datum vystavení dokladu, ne datum splatnosti.
|
||||
3. Typ dokladu vyber podle dokumentu: Faktura, Dobropis, Paragon, Dodací list, Zálohová faktura, Smlouva, Platba, Poplatek, Výdajový pokladní doklad.
|
||||
4. Pokud je v dokumentu "Dodací list není daňový doklad - nehraďte", typ je "Dodací list", ne "Faktura".
|
||||
5. Dodavatel zapisuj krátce a konzistentně: MEDIPOS, MEDEVIO, MEDATRON, ASKER, QuickSeal, Poliklinika Prosek, Alza, Microsoft, OpenAI, Ptáček.
|
||||
6. SPECIÁLNÍ PRAVIDLO: pokud je dodavatel "Distribuce CZ", použij dodavatele "Ptáček".
|
||||
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo variabilní symbol nebo hlavní číslo faktury bez mezer (např. 10195703), ne interní evidenční číslo typu FV-5703/2026.
|
||||
8. Částku piš vždy s desetinnou tečkou a měnou (např. [5578.97 CZK]).
|
||||
9. Když je částka v Kč, měna je CZK.
|
||||
10. Popis drž krátký, praktický a česky, v hranatých závorkách.
|
||||
11. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy.
|
||||
12. Pokud si nejsi jistý popisem, použij obecný popis typu [materiál do ordinace], [lékárna], [vakcíny], [testy].
|
||||
13. Výstup musí být POUZE validní JSON, nic jiného.
|
||||
|
||||
JSON FORMÁT:
|
||||
{"filename": "YYYY-MM-DD Faktura Dodavatel 123456 [popis] [123.45 CZK].pdf"}
|
||||
|
||||
TEXT FAKTURY:
|
||||
%(text)s
|
||||
"""
|
||||
|
||||
|
||||
def propose_filename(pdf_text: str) -> str | None:
|
||||
"""Navrhne název souboru podle textu faktury. None při selhání/prázdném."""
|
||||
prompt = NAMING_RULES % {"text": pdf_text[:15000]}
|
||||
obj = _claude_json(prompt, ANTHROPIC_NAMING_MODEL, 300)
|
||||
filename = (obj.get("filename") or "").strip()
|
||||
return sanitize_pdf_name(filename) if filename else None
|
||||
|
||||
|
||||
# =========================
|
||||
# HLAVNÍ BĚH
|
||||
# =========================
|
||||
def main() -> None:
|
||||
# Úložiště: STORAGE=dropbox -> Dropbox API, jinak lokální Dropbox mount.
|
||||
use_dropbox = os.getenv("STORAGE", "local").lower() == "dropbox"
|
||||
if use_dropbox:
|
||||
local_dir = None
|
||||
else:
|
||||
# najdi_dropbox potřebujeme jen lokálně (na serveru Knihovny nejsou).
|
||||
from Knihovny.najdi_dropbox import get_dropbox_root
|
||||
local_dir = Path(get_dropbox_root(), *TARGET_SUBPATH)
|
||||
storage = storage_mod.get_storage(local_dir, DROPBOX_TARGET_PATH)
|
||||
|
||||
# Otisky souborů už v cíli -> dedup podle obsahu, ne názvu. Faktur je málo.
|
||||
existing_hashes = storage.load_hashes()
|
||||
|
||||
state = load_state()
|
||||
processed = set(state.get("processed_ids", []))
|
||||
since = since_iso(state)
|
||||
|
||||
# Příprava cílové kategorie a složky pro přesun (idempotentní).
|
||||
graph_mail.ensure_category(MAILBOX, CATEGORY, CATEGORY_COLOR)
|
||||
processed_folder_id = graph_mail.ensure_folder_path(MAILBOX, PROCESSED_FOLDER_PARTS)
|
||||
|
||||
def finalize(message_id: str, subject: str) -> None:
|
||||
"""Označ mail kategorií a přesuň do složky zpracovaných."""
|
||||
try:
|
||||
graph_mail.add_category(MAILBOX, message_id, CATEGORY)
|
||||
graph_mail.move_message(MAILBOX, message_id, processed_folder_id)
|
||||
except Exception as e:
|
||||
log(f" [KATEGORIE/PŘESUN CHYBA] {subject!r}: {e}")
|
||||
|
||||
log("\n" + "=" * 70)
|
||||
log(f"START {_now_str()} schránka={MAILBOX}")
|
||||
log(f"Cíl: {storage.describe()}")
|
||||
log(f"Hledám maily od: {since}")
|
||||
|
||||
saved = scanned = prefiltered = invoices = 0
|
||||
|
||||
for folder_id, folder_name in graph_mail.inbox_folder_ids(MAILBOX):
|
||||
if folder_name in SKIP_FOLDERS:
|
||||
continue
|
||||
for msg in graph_mail.list_messages(MAILBOX, folder_id, since):
|
||||
mid = msg["id"]
|
||||
if mid in processed:
|
||||
continue
|
||||
scanned += 1
|
||||
atts = graph_mail.list_attachments(MAILBOX, mid)
|
||||
|
||||
if not passes_prefilter(msg, atts):
|
||||
processed.add(mid) # nezajímavé, už neřeš
|
||||
continue
|
||||
prefiltered += 1
|
||||
|
||||
subj = (msg.get("subject") or "")[:60]
|
||||
try:
|
||||
verdict = classify(msg, atts)
|
||||
except Exception as e:
|
||||
log(f" [AI CHYBA] {subj!r}: {e}")
|
||||
continue # zkusíme příště
|
||||
|
||||
if not verdict.get("je_faktura"):
|
||||
log(f" [NE] {subj!r} — {verdict.get('duvod','')}")
|
||||
processed.add(mid)
|
||||
continue
|
||||
|
||||
invoices += 1
|
||||
want = verdict.get("soubor_faktury")
|
||||
chosen = next((a for a in atts if a.get("name") == want), None)
|
||||
if chosen is None:
|
||||
# fallback: první PDF příloha
|
||||
chosen = next(
|
||||
(a for a in atts if (a.get("name") or "").lower().endswith(".pdf")),
|
||||
None,
|
||||
)
|
||||
if chosen is None:
|
||||
log(f" [FAKTURA bez PDF] {subj!r} — přílohy: {[a.get('name') for a in atts]}")
|
||||
continue
|
||||
|
||||
try:
|
||||
data = graph_mail.download_attachment(MAILBOX, mid, chosen["id"])
|
||||
except Exception as e:
|
||||
log(f" [DOWNLOAD CHYBA] {subj!r}: {e}")
|
||||
continue
|
||||
|
||||
# Dedup podle OBSAHU napříč složkou (ne podle názvu — AI název se
|
||||
# může lehce lišit). Kontrola hned po stažení -> u duplikátu ušetří
|
||||
# AI volání za pojmenování i extrakci textu.
|
||||
digest = storage.hash_bytes(data)
|
||||
if digest in existing_hashes:
|
||||
log(f" [DUPLIKÁT] obsah už ve složce je, přeskakuji ('{chosen['name']}')")
|
||||
processed.add(mid)
|
||||
finalize(mid, subj) # i duplikát je zpracovaná faktura -> ukliď z Inboxu
|
||||
continue
|
||||
|
||||
# Ověření obsahu PDF: musí v textu obsahovat slovo "faktur".
|
||||
pdf_text = extract_pdf_text(data)
|
||||
check = faktur_status(pdf_text)
|
||||
if check == "ne":
|
||||
# AI mail označila za fakturu, ale text PDF slovo "faktur"
|
||||
# neobsahuje -> nejspíš špatně vybraná příloha. Neukládám.
|
||||
log(f" [PDF NEPOTVRZENO] {subj!r} — '{chosen['name']}' "
|
||||
f"text neobsahuje 'faktur', přeskakuji")
|
||||
continue
|
||||
if check == "bez_textu":
|
||||
log(f" [PDF BEZ TEXTU] {subj!r} — '{chosen['name']}' "
|
||||
f"(sken?) ukládám i tak, ověř ručně")
|
||||
|
||||
# Návrh názvu podle obsahu faktury (jen pokud máme text).
|
||||
out_name = sanitize(chosen["name"])
|
||||
if pdf_text.strip():
|
||||
try:
|
||||
proposed = propose_filename(pdf_text)
|
||||
if proposed:
|
||||
out_name = proposed
|
||||
except Exception as e:
|
||||
log(f" [POJMENOVÁNÍ CHYBA] {subj!r}: {e} — původní název")
|
||||
|
||||
# Nový obsah. Backend vyřeší kolizi názvu (lokálně "(2)", Dropbox autorename).
|
||||
saved_name = storage.save(out_name, data)
|
||||
existing_hashes.add(digest)
|
||||
saved += 1
|
||||
processed.add(mid)
|
||||
log(f" [ULOŽENO] {saved_name} <- {subj!r}")
|
||||
finalize(mid, subj) # označ kategorií + přesuň z Inboxu
|
||||
|
||||
state["processed_ids"] = sorted(processed)
|
||||
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
||||
save_state(state)
|
||||
|
||||
log(
|
||||
f"HOTOVO: prošlo {scanned} mailů, předfiltrem {prefiltered}, "
|
||||
f"faktur {invoices}, uloženo {saved} souborů."
|
||||
)
|
||||
log(
|
||||
f"CENA AI: {_cost['calls']} volání, "
|
||||
f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, "
|
||||
f"${_cost['usd']:.4f} ≈ {_cost['usd'] * USD_TO_CZK:.2f} Kč "
|
||||
f"(kurz 1 USD = {USD_TO_CZK:.0f} Kč)"
|
||||
)
|
||||
|
||||
send_summary(saved, invoices)
|
||||
|
||||
|
||||
def send_summary(saved: int, invoices: int) -> None:
|
||||
"""Po každém běhu pošle summary e-mail z reports@buzalka.cz."""
|
||||
subject = (
|
||||
f"Faktury agent — uloženo {saved}, faktur {invoices} "
|
||||
f"({_now_str('%Y-%m-%d %H:%M')})"
|
||||
)
|
||||
body = (
|
||||
"<pre style=\"font-family:Consolas,monospace;font-size:13px;"
|
||||
"white-space:pre-wrap\">"
|
||||
+ html.escape("\n".join(_email_lines))
|
||||
+ "</pre>"
|
||||
)
|
||||
try:
|
||||
graph_mail.send_mail(SUMMARY_FROM, SUMMARY_TO, subject, body)
|
||||
print(f"Summary odeslán na {SUMMARY_TO}")
|
||||
except Exception as e:
|
||||
print(f"[SUMMARY EMAIL CHYBA] {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
graph_mail.py
|
||||
-------------
|
||||
Tenká vrstva nad Microsoft Graph API pro ČTENÍ schránky a STAHOVÁNÍ příloh.
|
||||
|
||||
Používá stejnou app registraci (application permissions) jako
|
||||
Knihovny/EmailMessagingGraph.py. Pro čtení cizí schránky a příloh musí mít
|
||||
ta app registrace grant **Mail.Read** (Application). Pokud chybí, Graph vrátí
|
||||
403 a je potřeba oprávnění doplnit v Azure portálu.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import msal
|
||||
import requests
|
||||
from functools import lru_cache
|
||||
from typing import Iterator
|
||||
|
||||
# =========================
|
||||
# CONFIG (sdíleno s EmailMessagingGraph.py)
|
||||
# =========================
|
||||
TENANT_ID = "7d269944-37a4-43a1-8140-c7517dc426e9"
|
||||
CLIENT_ID = "4b222bfd-78c9-4239-a53f-43006b3ed07f"
|
||||
CLIENT_SECRET = "Txg8Q~MjhocuopxsJyJBhPmDfMxZ2r5WpTFj1dfk"
|
||||
|
||||
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
|
||||
SCOPE = ["https://graph.microsoft.com/.default"]
|
||||
GRAPH = "https://graph.microsoft.com/v1.0"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _token() -> str:
|
||||
app = msal.ConfidentialClientApplication(
|
||||
CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET
|
||||
)
|
||||
tok = app.acquire_token_for_client(scopes=SCOPE)
|
||||
if "access_token" not in tok:
|
||||
raise RuntimeError(f"Graph auth failed: {tok}")
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"Authorization": f"Bearer {_token()}"}
|
||||
|
||||
|
||||
def _get(url: str, params: dict | None = None) -> dict:
|
||||
r = requests.get(url, headers=_headers(), params=params, timeout=60)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(url: str, body: dict) -> dict:
|
||||
r = requests.post(
|
||||
url, headers={**_headers(), "Content-Type": "application/json"},
|
||||
json=body, timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.content else {}
|
||||
|
||||
|
||||
def _patch(url: str, body: dict) -> dict:
|
||||
r = requests.patch(
|
||||
url, headers={**_headers(), "Content-Type": "application/json"},
|
||||
json=body, timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.content else {}
|
||||
|
||||
|
||||
def inbox_folder_ids(mailbox: str) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Vrátí [(folder_id, display_name), ...] pro Inbox a jeho přímé podsložky.
|
||||
Záměrně vynechává Junk, Deleted, Sent, Drafts.
|
||||
"""
|
||||
inbox = _get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox")
|
||||
folders = [(inbox["id"], inbox.get("displayName", "Inbox"))]
|
||||
data = _get(
|
||||
f"{GRAPH}/users/{mailbox}/mailFolders/{inbox['id']}/childFolders",
|
||||
{"$top": 100, "$select": "id,displayName"},
|
||||
)
|
||||
for f in data.get("value", []):
|
||||
folders.append((f["id"], f.get("displayName", "")))
|
||||
return folders
|
||||
|
||||
|
||||
def list_messages(mailbox: str, folder_id: str, since_iso: str) -> Iterator[dict]:
|
||||
"""
|
||||
Vrací zprávy s přílohou ve složce přijaté od `since_iso` (ISO 8601 UTC, Z).
|
||||
Stránkuje přes @odata.nextLink. Tělo se vrací jako text (Prefer header).
|
||||
"""
|
||||
url = f"{GRAPH}/users/{mailbox}/mailFolders/{folder_id}/messages"
|
||||
params = {
|
||||
"$filter": f"hasAttachments eq true and receivedDateTime ge {since_iso}",
|
||||
"$select": "id,subject,from,receivedDateTime,bodyPreview,body,hasAttachments",
|
||||
"$top": 50,
|
||||
}
|
||||
headers = {**_headers(), "Prefer": 'outlook.body-content-type="text"'}
|
||||
while url:
|
||||
r = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
yield from data.get("value", [])
|
||||
url = data.get("@odata.nextLink")
|
||||
params = None # nextLink už obsahuje všechny parametry
|
||||
|
||||
|
||||
def list_attachments(mailbox: str, message_id: str) -> list[dict]:
|
||||
"""Metadata příloh (id, name, contentType, size, @odata.type)."""
|
||||
data = _get(
|
||||
f"{GRAPH}/users/{mailbox}/messages/{message_id}/attachments",
|
||||
{"$top": 50},
|
||||
)
|
||||
return data.get("value", [])
|
||||
|
||||
|
||||
def download_attachment(mailbox: str, message_id: str, attachment_id: str) -> bytes:
|
||||
"""Stáhne bajty jedné fileAttachment."""
|
||||
data = _get(
|
||||
f"{GRAPH}/users/{mailbox}/messages/{message_id}/attachments/{attachment_id}"
|
||||
)
|
||||
content = data.get("contentBytes")
|
||||
if not content:
|
||||
raise RuntimeError(f"Příloha nemá contentBytes (typ {data.get('@odata.type')})")
|
||||
return base64.b64decode(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zápisové operace (vyžadují Mail.ReadWrite Application)
|
||||
# ---------------------------------------------------------------------------
|
||||
def ensure_category(mailbox: str, name: str, color: str = "preset4") -> None:
|
||||
"""Zajistí kategorii v master-listu schránky (s barvou). Idempotentní."""
|
||||
data = _get(f"{GRAPH}/users/{mailbox}/outlook/masterCategories", {"$top": 200})
|
||||
if any(c.get("displayName") == name for c in data.get("value", [])):
|
||||
return
|
||||
_post(
|
||||
f"{GRAPH}/users/{mailbox}/outlook/masterCategories",
|
||||
{"displayName": name, "color": color},
|
||||
)
|
||||
|
||||
|
||||
def add_category(mailbox: str, message_id: str, name: str) -> None:
|
||||
"""Přidá kategorii ke zprávě (zachová stávající)."""
|
||||
msg = _get(
|
||||
f"{GRAPH}/users/{mailbox}/messages/{message_id}", {"$select": "categories"}
|
||||
)
|
||||
cats = msg.get("categories") or []
|
||||
if name not in cats:
|
||||
_patch(
|
||||
f"{GRAPH}/users/{mailbox}/messages/{message_id}",
|
||||
{"categories": cats + [name]},
|
||||
)
|
||||
|
||||
|
||||
def ensure_folder_path(mailbox: str, parts: list[str]) -> str:
|
||||
"""
|
||||
Zajistí cestu složek pod Inboxem (vytvoří chybějící). `parts` jsou názvy
|
||||
podsložek, např. ["ProcessedByAgent", "Invoices"]. Vrátí id poslední složky.
|
||||
"""
|
||||
parent_id = _get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox")["id"]
|
||||
for name in parts:
|
||||
children = _get(
|
||||
f"{GRAPH}/users/{mailbox}/mailFolders/{parent_id}/childFolders",
|
||||
{"$top": 200, "$select": "id,displayName"},
|
||||
)
|
||||
match = next(
|
||||
(f for f in children.get("value", []) if f.get("displayName") == name), None
|
||||
)
|
||||
if match is None:
|
||||
match = _post(
|
||||
f"{GRAPH}/users/{mailbox}/mailFolders/{parent_id}/childFolders",
|
||||
{"displayName": name},
|
||||
)
|
||||
parent_id = match["id"]
|
||||
return parent_id
|
||||
|
||||
|
||||
def move_message(mailbox: str, message_id: str, dest_folder_id: str) -> str:
|
||||
"""Přesune zprávu do složky. Vrací NOVÉ id (move id mění)."""
|
||||
res = _post(
|
||||
f"{GRAPH}/users/{mailbox}/messages/{message_id}/move",
|
||||
{"destinationId": dest_folder_id},
|
||||
)
|
||||
return res.get("id", message_id)
|
||||
|
||||
|
||||
def send_mail(sender: str, to, subject: str, html_body: str) -> None:
|
||||
"""Odešle HTML e-mail přes Graph (vyžaduje Mail.Send Application)."""
|
||||
to_list = [to] if isinstance(to, str) else list(to)
|
||||
_post(
|
||||
f"{GRAPH}/users/{sender}/sendMail",
|
||||
{
|
||||
"message": {
|
||||
"subject": subject,
|
||||
"body": {"contentType": "HTML", "content": html_body},
|
||||
"toRecipients": [{"emailAddress": {"address": a}} for a in to_list],
|
||||
},
|
||||
"saveToSentItems": True,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"processed_ids": [
|
||||
"AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAAACZtAAA=",
|
||||
"AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAAACZvAAA=",
|
||||
"AAMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAAAAACUxJgR93AZR7oAJqupmHbKBwCzO8FCllpZQqWxx9sLz6PHAAACh39_AACzO8FCllpZQqWxx9sLz6PHAAAReEmkAAA=",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4tgAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4uwAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4vwAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABW2C4wAAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINL5QAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINL5gAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXINMDQAAAA==",
|
||||
"AQMkADYxOTE5ZTYwLTg2MWItNDNmOC04MWE5LTczZjM0NmJmMTRlYwBGAAADlMSYEfdwGUe6ACarqZh2ygcAszvBQpZaWUKlscfbC8_jxwAAAgEMAAAAszvBQpZaWUKlscfbC8_jxwABXxY_4AAAAA=="
|
||||
],
|
||||
"last_run": "2026-06-10T06:30:04.195527+00:00"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
storage.py
|
||||
----------
|
||||
Abstrakce úložiště pro faktury. Dva backendy se stejným rozhraním:
|
||||
|
||||
- LocalStorage — lokální filesystem (Windows, Dropbox mount přes najdi_dropbox).
|
||||
- DropboxStorage — Dropbox HTTP API (unraid/server, kde není Dropbox mount).
|
||||
|
||||
Výběr přes proměnnou STORAGE: "local" (default) | "dropbox".
|
||||
|
||||
Rozhraní (oba backendy):
|
||||
load_hashes() -> set[str] # otisky souborů už v cíli (dedup baseline)
|
||||
hash_bytes(data) -> str # otisk vstupních bajtů (stejný algoritmus)
|
||||
save(name, data) -> str # ulož, vrať finální název (řeší kolizi názvu)
|
||||
describe() -> str # popis cíle do logu
|
||||
|
||||
Pozn.: každý backend je vnitřně konzistentní (load_hashes i hash_bytes používají
|
||||
týž algoritmus). Local = sha256 obsahu. Dropbox = Dropbox "content_hash"
|
||||
(blokový sha256, viz dokumentace Dropboxu) — bere se přímo z metadat souboru,
|
||||
takže není potřeba nic stahovat.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_DROPBOX_BLOCK = 4 * 1024 * 1024 # 4 MiB
|
||||
|
||||
|
||||
def _dropbox_content_hash(data: bytes) -> str:
|
||||
"""Dropbox content_hash: sha256 z konkatenace sha256 jednotlivých 4MiB bloků."""
|
||||
h = hashlib.sha256()
|
||||
for i in range(0, len(data), _DROPBOX_BLOCK):
|
||||
h.update(hashlib.sha256(data[i:i + _DROPBOX_BLOCK]).digest())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# =========================
|
||||
# LOKÁLNÍ FILESYSTEM
|
||||
# =========================
|
||||
class LocalStorage:
|
||||
def __init__(self, target_dir: Path):
|
||||
self.dir = Path(target_dir)
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def hash_bytes(self, data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
def load_hashes(self) -> set:
|
||||
return {self.hash_bytes(p.read_bytes()) for p in self.dir.glob("*.pdf")}
|
||||
|
||||
def save(self, name: str, data: bytes) -> str:
|
||||
out = self.dir / name
|
||||
stem, suffix = Path(name).stem, Path(name).suffix
|
||||
i = 2
|
||||
while out.exists():
|
||||
out = self.dir / f"{stem} ({i}){suffix}"
|
||||
i += 1
|
||||
out.write_bytes(data)
|
||||
return out.name
|
||||
|
||||
def describe(self) -> str:
|
||||
return str(self.dir)
|
||||
|
||||
|
||||
# =========================
|
||||
# DROPBOX HTTP API
|
||||
# =========================
|
||||
class DropboxStorage:
|
||||
def __init__(self, folder: str, app_key: str, app_secret: str, refresh_token: str):
|
||||
import dropbox # lazy import — potřeba jen pro tento backend
|
||||
self._dbx_mod = dropbox
|
||||
self.dbx = dropbox.Dropbox(
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
oauth2_refresh_token=refresh_token,
|
||||
)
|
||||
self.folder = "/" + folder.strip("/")
|
||||
|
||||
def hash_bytes(self, data: bytes) -> str:
|
||||
return _dropbox_content_hash(data)
|
||||
|
||||
def load_hashes(self) -> set:
|
||||
hashes = set()
|
||||
try:
|
||||
res = self.dbx.files_list_folder(self.folder)
|
||||
except Exception:
|
||||
return hashes # složka ještě nemusí existovat
|
||||
while True:
|
||||
for e in res.entries:
|
||||
ch = getattr(e, "content_hash", None)
|
||||
if ch:
|
||||
hashes.add(ch)
|
||||
if not res.has_more:
|
||||
break
|
||||
res = self.dbx.files_list_folder_continue(res.cursor)
|
||||
return hashes
|
||||
|
||||
def save(self, name: str, data: bytes) -> str:
|
||||
# autorename=True -> při kolizi názvu (jiný obsah) Dropbox přidá " (1)".
|
||||
md = self.dbx.files_upload(
|
||||
data,
|
||||
f"{self.folder}/{name}",
|
||||
mode=self._dbx_mod.files.WriteMode.add,
|
||||
autorename=True,
|
||||
)
|
||||
return md.name
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"Dropbox:{self.folder}"
|
||||
|
||||
|
||||
# =========================
|
||||
# VÝBĚR BACKENDU
|
||||
# =========================
|
||||
def get_storage(local_dir: Path, dropbox_path: str):
|
||||
"""
|
||||
STORAGE=dropbox -> DropboxStorage (klíče DROPBOX_APP_KEY/SECRET/REFRESH_TOKEN
|
||||
z prostředí), jinak LocalStorage(local_dir).
|
||||
"""
|
||||
if os.getenv("STORAGE", "local").lower() == "dropbox":
|
||||
return DropboxStorage(
|
||||
dropbox_path,
|
||||
os.environ["DROPBOX_APP_KEY"],
|
||||
os.environ["DROPBOX_APP_SECRET"],
|
||||
os.environ["DROPBOX_APP_REFRESH_TOKEN"],
|
||||
)
|
||||
return LocalStorage(local_dir)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
MailStore Server API Explorer
|
||||
Připojí se k API, zjistí konfiguraci a vypíše klíčové info.
|
||||
Spusť: python explore_api.py
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import base64
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# ── Konfigurace ───────────────────────────────────────────────
|
||||
HOST = "https://192.168.1.53:8463" # nebo https://mailstore.buzalka.cz pokud funguje
|
||||
USER = "admin"
|
||||
PASS = "*$N(B)vMUym!%"
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
session = requests.Session()
|
||||
session.auth = (USER, PASS)
|
||||
session.verify = False
|
||||
session.headers.update({"Content-Type": "application/x-www-form-urlencoded"})
|
||||
|
||||
|
||||
def call(fn, **params):
|
||||
r = session.post(f"{HOST}/api/invoke/{fn}", data=params or {})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("error"):
|
||||
raise Exception(data["error"]["message"])
|
||||
return data.get("result")
|
||||
|
||||
|
||||
def pp(label, data):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label}")
|
||||
print('='*60)
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Připojuji se k MailStore API...")
|
||||
|
||||
# 1. Server info
|
||||
info = call("GetServerInfo")
|
||||
pp("Server Info", info)
|
||||
|
||||
# 2. Všechny dostupné API funkce
|
||||
r = session.post(f"{HOST}/api/get-metadata")
|
||||
metadata = r.json()
|
||||
fn_names = [f["name"] for f in metadata.get("functions", [])]
|
||||
pp("Dostupné API funkce", fn_names)
|
||||
|
||||
# 3. Uživatelé
|
||||
users = call("GetUsers")
|
||||
pp("Uživatelé", users)
|
||||
|
||||
# 4. Archive stores (úložiště)
|
||||
stores = call("GetStoreInfos")
|
||||
pp("Archive Stores (úložiště)", stores)
|
||||
|
||||
# 5. Archivační profily
|
||||
try:
|
||||
profiles = call("GetProfiles")
|
||||
pp("Archivační profily", profiles)
|
||||
except Exception as e:
|
||||
print(f"\nProfily: {e}")
|
||||
|
||||
# 6. Složky (mailboxes) pro admin uživatele
|
||||
try:
|
||||
folders = call("GetFolderStatistics")
|
||||
pp("Folder statistiky", folders)
|
||||
except Exception as e:
|
||||
print(f"\nFolder stats: {e}")
|
||||
|
||||
print("\n\nHotovo. Zkopíruj výstup výše a pošli mi ho.")
|
||||
@@ -0,0 +1,106 @@
|
||||
"""export_prilohy.py – export příloh z Medicus FILES tabulky do souborů
|
||||
|
||||
Extrahuje všechny záznamy z FILES, uloží BODY blob jako soubor.
|
||||
Detekuje dva formáty BODY:
|
||||
- inline PDF (začíná %PDF)
|
||||
- reference (začíná magic \xee\xbb\xaa\x0b) → čte z externí MEDICUS_FILES_YYYYMM.fdb
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, r'U:\OrdinaceProjekt')
|
||||
from Knihovny.najdi_dropbox import get_dropbox_root
|
||||
from Knihovny.medicus_db import get_medicus_connection
|
||||
|
||||
# ─── Konfigurace ─────────────────────────────────────────────────────────────
|
||||
|
||||
OUTPUT_DIR = os.path.join(get_dropbox_root(), r'!!!Days\Downloads Z230\Files')
|
||||
RECORDS_COUNT = 0 # 0 = všechny záznamy
|
||||
|
||||
# Magic bytes identifikující referenci na externí FDB (na reporterovi nenastane)
|
||||
MAGIC = b'\xee\xbb\xaa\x0b'
|
||||
|
||||
# ─── Pomocné funkce ───────────────────────────────────────────────────────────
|
||||
|
||||
def parse_body_ref(body_bytes):
|
||||
"""Vrátí (uid, dbname) pokud BODY je reference na ext FDB, jinak None."""
|
||||
if not body_bytes or len(body_bytes) < 8:
|
||||
return None
|
||||
if body_bytes[:4] != MAGIC:
|
||||
return None
|
||||
uid = body_bytes[4:36].decode('ascii')
|
||||
dblen = struct.unpack_from('<I', body_bytes, 36)[0]
|
||||
dbname = body_bytes[40:40 + dblen].decode('ascii')
|
||||
return uid, dbname
|
||||
|
||||
|
||||
def safe_filename(name):
|
||||
"""Odstraní znaky nevhodné pro název souboru."""
|
||||
for ch in r'\/:*?"<>|':
|
||||
name = name.replace(ch, '_')
|
||||
return name.strip()
|
||||
|
||||
|
||||
# ─── Hlavní logika ────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
conn = get_medicus_connection()
|
||||
cur = conn.cursor()
|
||||
limit = f"FIRST {RECORDS_COUNT} " if RECORDS_COUNT > 0 else ""
|
||||
cur.execute(f"""
|
||||
SELECT {limit}f.ID, f.IDPAC, f.DATUM, f.FILENAME, f.BODY, f.POZNAMKA
|
||||
FROM FILES f
|
||||
ORDER BY f.ID
|
||||
""")
|
||||
|
||||
ok = 0
|
||||
chyb = 0
|
||||
|
||||
for row in cur:
|
||||
file_id, idpac, datum, filename, body_blob, poznamka = row
|
||||
|
||||
# Přečti blob
|
||||
try:
|
||||
if hasattr(body_blob, 'read'):
|
||||
body_bytes = body_blob.read()
|
||||
body_blob.close()
|
||||
else:
|
||||
body_bytes = body_blob or b''
|
||||
except Exception as e:
|
||||
print(f" [CHYBA] ID={file_id}: čtení BODY selhalo: {e}")
|
||||
chyb += 1
|
||||
continue
|
||||
|
||||
# Zjisti formát
|
||||
ref = parse_body_ref(body_bytes)
|
||||
if ref:
|
||||
_, dbname = ref
|
||||
print(f" [SKIP] ID={file_id}: reference na ext DB {dbname} — nepodporováno")
|
||||
chyb += 1
|
||||
continue
|
||||
pdf_data = body_bytes
|
||||
|
||||
# Sestavení cílového názvu: ID_IDPAC_DATUM_filename
|
||||
datum_str = datum.strftime('%Y-%m-%d') if datum else 'bez-data'
|
||||
raw_fn = filename or f'soubor_{file_id}.bin'
|
||||
out_name = f"{file_id:06d}_{idpac:06d}_{datum_str}_{safe_filename(raw_fn)}"
|
||||
out_path = os.path.join(OUTPUT_DIR, out_name)
|
||||
|
||||
# Ulož
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(pdf_data)
|
||||
|
||||
print(f" ID={file_id} idpac={idpac} {datum_str} {raw_fn} → {len(pdf_data):,} B")
|
||||
ok += 1
|
||||
|
||||
conn.close()
|
||||
print(f"\nHotovo: {ok} uloženo, {chyb} chyb")
|
||||
print(f"Výstup: {OUTPUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user