z230
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
# FakturyRename.py
|
||||
# Verze: 1.2
|
||||
# Datum: 05JUN2026
|
||||
# Autor: Vladimír Buzalka
|
||||
#
|
||||
# Popis:
|
||||
# Projde PDF faktury a doklady přímo ve vstupním adresáři, pošle je do
|
||||
# OpenAI Responses API k vytěžení údajů a navrhne jednotný název souboru.
|
||||
# Výsledný formát názvu:
|
||||
# YYYY-MM-DD Typ Dodavatel ČÍSLO [popis] [částka MĚNA].pdf
|
||||
#
|
||||
# Při DRY_RUN = False skript soubor přejmenuje a přesune do podadresáře
|
||||
# NamedInvoicesbyOpenAI, aby další běh zpracovával jen nové dokumenty.
|
||||
# Loguje původní název, návrh, tokeny, odhad ceny a stav zpracování.
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
# =========================
|
||||
# CENA API
|
||||
# =========================
|
||||
|
||||
USD_TO_CZK = 25.0
|
||||
|
||||
# # Ceny nastav ručně podle modelu, který používáš.
|
||||
# # Zde počítáme:
|
||||
# # input = 5 USD / 1M tokenů
|
||||
# # output = 30 USD / 1M tokenů
|
||||
# PRICE_INPUT_USD_PER_1M = 5.00
|
||||
# PRICE_OUTPUT_USD_PER_1M = 30.00
|
||||
# # Model s podporou PDF / vision vstupu.
|
||||
# MODEL = "gpt-5.5"
|
||||
|
||||
MODEL = "gpt-5.4-mini"
|
||||
|
||||
PRICE_INPUT_USD_PER_1M = 0.75
|
||||
PRICE_OUTPUT_USD_PER_1M = 4.50
|
||||
|
||||
# =========================
|
||||
# NASTAVENÍ
|
||||
# =========================
|
||||
|
||||
FOLDER = Path(
|
||||
r"u:\Dropbox\Ordinace\!!MUDr. Michaela Buzalková s.r.o\Prosek\#040 Faktury přijaté"
|
||||
)
|
||||
|
||||
PROCESSED_FOLDER = FOLDER / "NamedInvoicesbyOpenAI"
|
||||
|
||||
# Pro test na 3 fakturách nech DRY_RUN = True.
|
||||
# Skript jen vypíše a zaloguje návrhy názvů, ale soubory nepřejmenuje.
|
||||
DRY_RUN = False
|
||||
|
||||
# Nepůjde do podadresářů, protože používáme glob(), ne rglob().
|
||||
PDF_PATTERN = "*.pdf"
|
||||
|
||||
|
||||
|
||||
LOG_FILE = FOLDER / "_rename_log_invoices.txt"
|
||||
|
||||
ENV_FILE = Path(r"U:\ordinaceprojekt\.env")
|
||||
|
||||
|
||||
# =========================
|
||||
# PRAVIDLA PRO POJMENOVÁNÍ
|
||||
# =========================
|
||||
|
||||
NAMING_RULES = """
|
||||
Jsi pomocník pro pojmenování naskenovaných PDF dokladů MUDr. Michaely Buzalkové.
|
||||
|
||||
ÚKOL:
|
||||
Z PDF 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 napsáno "Dodací list není daňový doklad - nehraďte", typ musí být "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/firma "Distribuce CZ", v názvu souboru použij dodavatele "Ptáček".
|
||||
7. SPECIÁLNÍ PRAVIDLO: u faktur MEDIPOS použij jako číslo dokladu variabilní symbol nebo hlavní číslo faktury bez mezer, například 10195703. Nepoužívej interní evidenční číslo typu FV-5703/2026.
|
||||
8. Částku piš vždy s desetinnou tečkou a měnou, například [5578.97 CZK].
|
||||
9. Když je částka v Kč, měna je CZK.
|
||||
10. Popis drž krátký, praktický a česky.
|
||||
11. Popis dávej do hranatých závorek.
|
||||
12. Nepoužívej dvojtečky, lomítka, uvozovky ani znaky nevhodné pro Windows názvy souborů.
|
||||
13. Pokud jde jen o dodací list bez daňového dokladu, částku můžeš uvést, ale typ musí zůstat Dodací list.
|
||||
14. Pokud si nejsi jistý popisem, použij obecný popis typu [materiál do ordinace], [lékárna], [vakcíny], [testy].
|
||||
15. 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"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# =========================
|
||||
# POMOCNÉ FUNKCE
|
||||
# =========================
|
||||
|
||||
def pdf_to_base64_data_url(path: Path) -> str:
|
||||
data = path.read_bytes()
|
||||
b64 = base64.b64encode(data).decode("utf-8")
|
||||
return f"data:application/pdf;base64,{b64}"
|
||||
|
||||
|
||||
def sanitize_windows_filename(name: str) -> str:
|
||||
"""
|
||||
Očistí název souboru pro Windows.
|
||||
"""
|
||||
# Zakázané znaky ve Windows: < > : " / \ | ? *
|
||||
name = re.sub(r'[<>:"/\\|?*]', " ", name)
|
||||
|
||||
# Sjednocení mezer
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
|
||||
# Windows nemá rád tečku nebo mezeru na konci
|
||||
name = name.rstrip(" .")
|
||||
|
||||
if not name.lower().endswith(".pdf"):
|
||||
name += ".pdf"
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def unique_path(target: Path) -> Path:
|
||||
"""
|
||||
Pokud cílový soubor existuje, přidá (2), (3), ...
|
||||
"""
|
||||
if not target.exists():
|
||||
return target
|
||||
|
||||
stem = target.stem
|
||||
suffix = target.suffix
|
||||
parent = target.parent
|
||||
|
||||
i = 2
|
||||
while True:
|
||||
candidate = parent / f"{stem} ({i}){suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
i += 1
|
||||
|
||||
|
||||
def extract_json_object(text: str) -> dict:
|
||||
"""
|
||||
Kdyby model náhodou vrátil něco kolem JSONu, zkusí vytáhnout první JSON objekt.
|
||||
"""
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError(f"Model nevrátil JSON:\n{text}")
|
||||
|
||||
return json.loads(match.group(0))
|
||||
|
||||
|
||||
def get_usage_value(usage, key: str) -> int:
|
||||
"""
|
||||
Bezpečně přečte usage hodnotu.
|
||||
Funguje pro objekt i dict.
|
||||
"""
|
||||
if usage is None:
|
||||
return 0
|
||||
|
||||
if isinstance(usage, dict):
|
||||
return usage.get(key, 0) or 0
|
||||
|
||||
return getattr(usage, key, 0) or 0
|
||||
|
||||
|
||||
def calculate_cost_from_usage(usage) -> dict:
|
||||
"""
|
||||
Spočítá odhad ceny z response.usage.
|
||||
"""
|
||||
input_tokens = get_usage_value(usage, "input_tokens")
|
||||
output_tokens = get_usage_value(usage, "output_tokens")
|
||||
total_tokens = get_usage_value(usage, "total_tokens")
|
||||
|
||||
if not total_tokens:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
input_cost_usd = input_tokens / 1_000_000 * PRICE_INPUT_USD_PER_1M
|
||||
output_cost_usd = output_tokens / 1_000_000 * PRICE_OUTPUT_USD_PER_1M
|
||||
total_cost_usd = input_cost_usd + output_cost_usd
|
||||
total_cost_czk = total_cost_usd * USD_TO_CZK
|
||||
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"input_cost_usd": input_cost_usd,
|
||||
"output_cost_usd": output_cost_usd,
|
||||
"total_cost_usd": total_cost_usd,
|
||||
"total_cost_czk": total_cost_czk,
|
||||
}
|
||||
|
||||
|
||||
def ask_openai_for_filename(client: OpenAI, pdf_path: Path) -> tuple[str, dict]:
|
||||
file_data = pdf_to_base64_data_url(pdf_path)
|
||||
|
||||
response = client.responses.create(
|
||||
model=MODEL,
|
||||
input=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_file",
|
||||
"filename": pdf_path.name,
|
||||
"file_data": file_data,
|
||||
},
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": NAMING_RULES,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
text = response.output_text.strip()
|
||||
obj = extract_json_object(text)
|
||||
|
||||
filename = obj.get("filename", "").strip()
|
||||
if not filename:
|
||||
raise ValueError(f"JSON neobsahuje filename:\n{text}")
|
||||
|
||||
cost = calculate_cost_from_usage(response.usage)
|
||||
|
||||
return sanitize_windows_filename(filename), cost
|
||||
|
||||
|
||||
def log_line(text: str) -> None:
|
||||
print(text)
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(text + "\n")
|
||||
|
||||
|
||||
# =========================
|
||||
# HLAVNÍ BĚH
|
||||
# =========================
|
||||
|
||||
def main() -> None:
|
||||
if not FOLDER.exists():
|
||||
raise FileNotFoundError(f"Adresář neexistuje: {FOLDER}")
|
||||
|
||||
# Načtení API klíče z .env
|
||||
load_dotenv(ENV_FILE)
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
raise RuntimeError(
|
||||
f"Chybí OPENAI_API_KEY. Zkontroluj soubor {ENV_FILE}"
|
||||
)
|
||||
|
||||
client = OpenAI()
|
||||
|
||||
pdfs = sorted(FOLDER.glob(PDF_PATTERN))
|
||||
pdfs = [p for p in pdfs if p.is_file() and p.suffix.lower() == ".pdf"]
|
||||
|
||||
if not pdfs:
|
||||
print("Nenalezeno žádné PDF.")
|
||||
return
|
||||
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
total_tokens = 0
|
||||
total_cost_usd = 0.0
|
||||
total_cost_czk = 0.0
|
||||
|
||||
log_line("")
|
||||
log_line("=" * 80)
|
||||
log_line(f"START: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
log_line(f"Adresář: {FOLDER}")
|
||||
log_line(f"Hotové faktury: {PROCESSED_FOLDER}")
|
||||
log_line(f"Počet PDF: {len(pdfs)}")
|
||||
log_line(f"DRY_RUN: {DRY_RUN}")
|
||||
log_line(f"MODEL: {MODEL}")
|
||||
log_line(f"Kurz: 1 USD = {USD_TO_CZK:.2f} CZK")
|
||||
log_line("=" * 80)
|
||||
|
||||
for i, pdf in enumerate(pdfs, start=1):
|
||||
log_line(f"\n[{i}/{len(pdfs)}] Původní název: {pdf.name}")
|
||||
|
||||
try:
|
||||
new_name, cost = ask_openai_for_filename(client, pdf)
|
||||
|
||||
total_input_tokens += cost["input_tokens"]
|
||||
total_output_tokens += cost["output_tokens"]
|
||||
total_tokens += cost["total_tokens"]
|
||||
total_cost_usd += cost["total_cost_usd"]
|
||||
total_cost_czk += cost["total_cost_czk"]
|
||||
|
||||
log_line(f" Návrh: {new_name}")
|
||||
log_line(
|
||||
f" Tokeny: input={cost['input_tokens']}, "
|
||||
f"output={cost['output_tokens']}, "
|
||||
f"total={cost['total_tokens']}"
|
||||
)
|
||||
log_line(
|
||||
f" Cena volání: ${cost['total_cost_usd']:.6f} "
|
||||
f"≈ {cost['total_cost_czk']:.2f} Kč"
|
||||
)
|
||||
|
||||
target = unique_path(PROCESSED_FOLDER / new_name)
|
||||
if target.name != new_name:
|
||||
log_line(f" Cíl po vyřešení konfliktu: {target.name}")
|
||||
|
||||
if DRY_RUN:
|
||||
log_line(f" Cíl: {target}")
|
||||
log_line(" Stav: DRY-RUN, nepřejmenováno/nepřesunuto")
|
||||
else:
|
||||
PROCESSED_FOLDER.mkdir(exist_ok=True)
|
||||
pdf.rename(target)
|
||||
if pdf.name == new_name:
|
||||
log_line(" Stav: PŘESUNUTO")
|
||||
else:
|
||||
log_line(" Stav: PŘEJMENOVÁNO A PŘESUNUTO")
|
||||
|
||||
except Exception as e:
|
||||
log_line(f" CHYBA: {type(e).__name__}: {e}")
|
||||
|
||||
log_line("")
|
||||
log_line("=" * 80)
|
||||
log_line("SOUHRN CENY")
|
||||
log_line(f"Tokeny celkem: input={total_input_tokens}, output={total_output_tokens}, total={total_tokens}")
|
||||
log_line(f"Cena celkem: ${total_cost_usd:.6f} ≈ {total_cost_czk:.2f} Kč")
|
||||
log_line("=" * 80)
|
||||
|
||||
log_line("\nHOTOVO")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user