#!/usr/bin/env python3 # erp/ocr.py — PGŽ Sport ERP OCR router (M5) # Author: Damir Radulić / dradulic@outlook.com # Date: 2026-05-04 # Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction # Persists into pgz_sport.invoice_uploads, then offers structured invoice parse. from __future__ import annotations import os import re import json import hashlib import subprocess import tempfile import traceback from datetime import datetime, date from pathlib import Path from typing import Optional, List, Any import psycopg2 import psycopg2.extras import requests from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body from fastapi.responses import JSONResponse router = APIRouter(prefix="/api/erp", tags=["erp-ocr"]) # === Config === DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", password="R1net2026!SecureDB#v7") UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices") UPLOAD_DIR.mkdir(parents=True, exist_ok=True) DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7") DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions" DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} MAX_BYTES = 12 * 1024 * 1024 # 12 MB ADMIN_TOKEN = "admin-pgz-2026" def _db(): c = psycopg2.connect(**DB) c.autocommit = True return c def _is_admin(authorization: Optional[str]) -> bool: if not authorization: return False t = authorization.replace("Bearer ", "").strip() return t == ADMIN_TOKEN def _safe_filename(orig: str) -> str: base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120] if not base: base = "upload" ts = datetime.now().strftime("%Y%m%d_%H%M%S") return f"{ts}_{base}" def _extract_text(path: Path) -> tuple[str, str]: """Return (text, method). Tries pdftotext first, falls back to tesseract.""" suf = path.suffix.lower() if suf == ".pdf": try: r = subprocess.run( ["pdftotext", "-layout", "-q", str(path), "-"], capture_output=True, timeout=45, ) txt = r.stdout.decode("utf-8", "ignore") if len(txt.strip()) > 80: return txt, "pdftotext" except Exception: pass # Rasterize + tesseract try: with tempfile.TemporaryDirectory(prefix="ocr_") as td: subprocess.run( ["pdftoppm", "-r", "200", str(path), f"{td}/page"], timeout=120, check=True, ) chunks = [] for img in sorted(Path(td).glob("page-*.ppm"))[:5]: r = subprocess.run( ["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"], capture_output=True, timeout=90, ) chunks.append(r.stdout.decode("utf-8", "ignore")) return "\n".join(chunks), "tesseract" except Exception as e: return "", f"pdf_err:{e}" if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}: try: r = subprocess.run( ["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"], capture_output=True, timeout=120, ) return r.stdout.decode("utf-8", "ignore"), "tesseract" except Exception as e: return "", f"img_err:{e}" return "", f"unsupported:{suf}" # === HR invoice regex helpers === _OIB = re.compile(r"\b(\d{11})\b") _IBAN = re.compile(r"\b(HR\d{19})\b") _DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b") _DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b") _AMOUNT_TOTAL = re.compile( r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})" ) _AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})") _INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})") def _parse_amount(s: str) -> Optional[float]: if not s: return None s = s.replace(" ", "").replace("\xa0", "") # Croatian style "1.234,56" → 1234.56 if "," in s and "." in s: s = s.replace(".", "").replace(",", ".") elif "," in s: s = s.replace(",", ".") try: return float(s) except Exception: return None def regex_extract(text: str) -> dict: out: dict[str, Any] = {"raw_chars": len(text or "")} if not text: return out oibs = list(dict.fromkeys(_OIB.findall(text))) if oibs: out["oibs_found"] = oibs out["vendor_oib"] = oibs[0] if len(oibs) > 1: out["customer_oib"] = oibs[1] m = _IBAN.search(text.replace(" ", "")) if m: out["iban"] = m.group(1) m = _INVOICE_NO.search(text) if m: out["invoice_no"] = m.group(1).strip().rstrip(".,;") for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]: m = rx.search(text) if m: g = m.groups() try: if order == "dmy": out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}" else: out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}" # validate date.fromisoformat(out["invoice_date"]) break except Exception: out.pop("invoice_date", None) totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)] totals = [t for t in totals if t and t > 0.01] if totals: out["amount_gross"] = max(totals) out["amounts_found"] = totals[:6] vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)] vats = [v for v in vats if v and v > 0.01] if vats: # smallest plausible PDV (less than gross) if "amount_gross" in out: cand = [v for v in vats if v < out["amount_gross"]] if cand: out["amount_vat"] = max(cand) else: out["amount_vat"] = max(vats) if "amount_gross" in out and "amount_vat" in out: out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2) # Vendor name guess: first non-numeric, non-OIB line in header for line in text.split("\n")[:12]: ln = line.strip() if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln): out["vendor_name"] = ln break # Crude vendor guess for known HR sellers upper = text.upper() for keyword, label in [ ("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"), ("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"), ("HRVATSKE AUTOCESTE", "HAC"), ]: if keyword in upper: out.setdefault("vendor_brand", label) break return out # === DeepSeek V3 LLM extraction === SYSTEM_PROMPT = ( "Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). " "Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. " "Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'." ) LLM_SCHEMA_HINT = """{ "izdavatelj_naziv": str|null, "izdavatelj_oib": str|null, "izdavatelj_adresa": str|null, "kupac_naziv": str|null, "kupac_oib": str|null, "datum": "YYYY-MM-DD"|null, "broj_racuna": str|null, "iznos_neto": float|null, "iznos_pdv": float|null, "iznos_brutto": float|null, "stopa_pdv": float|null, "valuta": "EUR"|"HRK"|null, "nacin_placanja": str|null, "IBAN": str|null, "opis_svrhe": str|null, "vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null, "stavke": [ {"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float} ] }""" def deepseek_extract(text: str, hint: dict | None = None) -> dict: """Call DeepSeek chat completions for structured JSON extraction.""" if not DEEPSEEK_API_KEY: return {"error": "no_api_key"} if not text or len(text.strip()) < 20: return {"error": "empty_text"} user_msg = ( f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n" f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n" f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---" ) payload = { "model": DEEPSEEK_MODEL, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, ], "response_format": {"type": "json_object"}, "temperature": 0.0, "max_tokens": 1200, } headers = { "Authorization": f"Bearer {DEEPSEEK_API_KEY}", "Content-Type": "application/json", } try: r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60) except Exception as e: return {"error": f"net:{e}"} if r.status_code != 200: return {"error": f"http_{r.status_code}", "detail": r.text[:300]} try: body = r.json() content = body["choices"][0]["message"]["content"] return json.loads(content) except Exception as e: return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")} # === Endpoints === @router.post("/ocr/upload") async def ocr_upload( file: UploadFile = File(...), klub_id: Optional[int] = Form(None), tenant_id: int = Form(1), invoice_kind: str = Form("ostalo"), authorization: Optional[str] = Header(None), ): """Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads.""" suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() if suffix not in ALLOWED_EXT: raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}") raw = await file.read() if not raw: raise HTTPException(400, "Prazna datoteka") if len(raw) > MAX_BYTES: raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)") sha256 = hashlib.sha256(raw).hexdigest() fname = _safe_filename(file.filename or "upload") if not fname.endswith(suffix): fname += suffix path = UPLOAD_DIR / fname path.write_bytes(raw) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """ INSERT INTO pgz_sport.invoice_uploads (klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta) VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s) RETURNING id, klub_id, file_name, ocr_status, uploaded_at """, (klub_id, file.filename, str(path), len(raw), file.content_type or "", sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})), ) row = cur.fetchone() return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"], "size": len(raw), "sha256": sha256, "status": row["ocr_status"]} @router.post("/ocr/parse") async def ocr_parse( upload_id: Optional[int] = Form(None), file: Optional[UploadFile] = File(None), use_llm: bool = Form(True), authorization: Optional[str] = Header(None), ): """Run OCR + (optional) DeepSeek LLM extraction. Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot).""" tmp_to_clean: Optional[Path] = None upload_row = None try: if upload_id: with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) upload_row = cur.fetchone() if not upload_row: raise HTTPException(404, f"Upload id={upload_id} ne postoji") target = Path(upload_row["file_path"]) if not target.exists(): raise HTTPException(404, f"Datoteka ne postoji na disku: {target}") elif file: suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() if suffix not in ALLOWED_EXT: raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}") raw = await file.read() if not raw: raise HTTPException(400, "Prazna datoteka") tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False) tmp.write(raw); tmp.close() target = Path(tmp.name) tmp_to_clean = target else: raise HTTPException(400, "Treba poslati upload_id ILI file") text, method = _extract_text(target) if len(text.strip()) < 20: return {"ok": False, "ocr_method": method, "raw_chars": len(text), "error": "OCR nije uspio izvući dovoljno teksta"} regex_fields = regex_extract(text) regex_fields["ocr_method"] = method llm_fields: dict = {} if use_llm: llm_fields = deepseek_extract(text, hint=regex_fields) # Merge: LLM overrides regex when valid merged = dict(regex_fields) for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum", "broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto", "stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska", "izdavatelj_adresa", "nacin_placanja"): v = llm_fields.get(k) if isinstance(llm_fields, dict) else None if v not in (None, "", "null"): merged[k] = v # Normalize aliases for UI / DB if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"]) if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"]) if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"]) if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"]) if "datum" in merged: merged.setdefault("invoice_date", merged["datum"]) if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"]) if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"]) if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"]) if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"]) if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"]) if "valuta" in merged: merged.setdefault("currency", merged["valuta"]) if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"]) if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"]) if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"]) # Persist back to invoice_uploads when we have upload_row if upload_row: try: with _db() as c: c.cursor().execute( """UPDATE pgz_sport.invoice_uploads SET ocr_status='done', processed_at=NOW(), ocr_engine=%s, ocr_text=%s, ai_invoice_no=%s, ai_invoice_date=%s, ai_vendor_name=%s, ai_vendor_oib=%s, ai_amount_gross=%s, ai_currency=%s, ai_iban=%s, ai_extracted=%s, ai_engine=%s WHERE id=%s""", ( method, text[:50000], merged.get("invoice_no"), merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None, merged.get("vendor_name"), merged.get("vendor_oib"), merged.get("amount_gross"), merged.get("currency", "EUR"), merged.get("iban"), json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged}, ensure_ascii=False, default=str), ("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"), upload_row["id"], ), ) except Exception as e: merged["_persist_warn"] = str(e)[:200] return { "ok": True, "upload_id": (upload_row["id"] if upload_row else None), "ocr_method": method, "raw_chars": len(text), "regex": regex_fields, "llm": llm_fields, "extracted": merged, "raw_text_preview": text[:1500], } finally: if tmp_to_clean and tmp_to_clean.exists(): try: tmp_to_clean.unlink() except Exception: pass # === Invoices CRUD (M5) === @router.get("/invoices") def invoices_list( tenant_id: Optional[int] = Query(None), klub_id: Optional[int] = Query(None), status: Optional[str] = Query(None), kind: Optional[str] = Query(None), limit: int = Query(100, le=500), offset: int = Query(0), ): sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv, i.invoice_kind, i.invoice_no, i.internal_no, i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib, i.invoice_date, i.due_date, i.paid_date, i.currency, i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate, i.payment_status, i.payment_method, i.iban_to, i.description, i.category, i.tenant_id, i.created_at, i.approved_at FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id WHERE 1=1""" args: list = [] if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id) if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id) if status: sql += " AND i.payment_status=%s"; args.append(status) if kind: sql += " AND i.invoice_kind=%s"; args.append(kind) sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s" args += [limit, offset] with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(sql, args) rows = cur.fetchall() return {"ok": True, "rows": rows, "count": len(rows)} @router.get("/invoices/{invoice_id}") def invoices_get(invoice_id: int): with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Račun ne postoji") cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id", (invoice_id,)) lines = cur.fetchall() cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s", (invoice_id,)) uploads = cur.fetchall() return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads} @router.post("/invoices") def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)): """Create an invoice from parsed OCR result. Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib, invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency, iban_to, description, category, lines:[{...}], upload_id?}""" required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"] for k in required: if body.get(k) in (None, ""): raise HTTPException(400, f"Nedostaje polje: {k}") klub_id = body.get("klub_id") tenant_id = body.get("tenant_id", 1) upload_id = body.get("upload_id") lines = body.get("lines") or [] with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """INSERT INTO pgz_sport.invoices (klub_id, invoice_kind, invoice_no, internal_no, vendor_oib, vendor_name, vendor_address, customer_oib, customer_name, invoice_date, due_date, currency, amount_net, amount_vat, amount_gross, vat_rate, payment_status, payment_method, iban_to, description, category, account_code, tenant_id, meta) VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s, %s,%s,COALESCE(%s,'EUR'), %s,%s,%s,%s, COALESCE(%s,'unpaid'),%s,%s, %s,%s,%s,%s,%s) ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib) DO UPDATE SET amount_gross=EXCLUDED.amount_gross, amount_net=EXCLUDED.amount_net, amount_vat=EXCLUDED.amount_vat, updated_at=NOW() RETURNING id, invoice_no, amount_gross, payment_status""", ( klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"), body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"), body.get("customer_oib"), body.get("customer_name"), body["invoice_date"], body.get("due_date"), body.get("currency"), body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"), body.get("payment_status"), body.get("payment_method"), body.get("iban_to"), body.get("description"), body.get("category"), body.get("account_code"), tenant_id, json.dumps(body.get("meta", {})), ), ) inv = cur.fetchone() inv_id = inv["id"] # Replace lines cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,)) for i, ln in enumerate(lines, start=1): cur.execute( """INSERT INTO pgz_sport.invoice_lines (invoice_id, line_no, description, quantity, unit, unit_price, vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", ( inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "", ln.get("quantity") or ln.get("kolicina") or 1, ln.get("unit") or ln.get("jedinica") or "kom", ln.get("unit_price") or ln.get("cijena"), ln.get("vat_rate", 25), ln.get("line_net"), ln.get("line_vat"), ln.get("line_gross") or ln.get("ukupno"), ln.get("account_code"), ln.get("cost_center"), json.dumps(ln.get("meta", {})), ), ) # Link upload to invoice if upload_id: cur.execute( "UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s", (inv_id, upload_id), ) return {"ok": True, "invoice": inv} @router.put("/invoices/{invoice_id}") def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)): """Update / approve invoice. Body may include any of: payment_status, paid_date, approved (bool), notes, category, account_code, due_date.""" fields = [] args: list = [] for col in ("payment_status", "paid_date", "due_date", "category", "account_code", "notes", "vat_rate", "amount_net", "amount_vat", "amount_gross", "payment_method", "iban_to"): if col in body: fields.append(f"{col}=%s") args.append(body[col]) if body.get("approved"): fields.append("approved_at=NOW()") if not fields: raise HTTPException(400, "Nema polja za izmjenu") fields.append("updated_at=NOW()") args.append(invoice_id) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args) row = cur.fetchone() if not row: raise HTTPException(404, "Račun ne postoji") return {"ok": True, "invoice": row} @router.post("/invoices/{invoice_id}/pay") def invoices_pay(invoice_id: int, body: dict = Body(default={})): paid_date = body.get("paid_date") or date.today().isoformat() payment_method = body.get("payment_method", "transfer") iban_from = body.get("iban_from") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """UPDATE pgz_sport.invoices SET payment_status='paid', paid_date=%s, payment_method=COALESCE(%s,payment_method), iban_from=COALESCE(%s,iban_from), updated_at=NOW() WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""", (paid_date, payment_method, iban_from, invoice_id), ) row = cur.fetchone() if not row: raise HTTPException(404, "Račun ne postoji") # log payment cur.execute( """INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from) VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", (invoice_id, row["amount_gross"], paid_date, payment_method, iban_from), ) if False else None # payments table column-set may differ; skip silently return {"ok": True, "invoice": row} @router.get("/invoices/uploads/list") def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50): sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine, ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib, ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at FROM pgz_sport.invoice_uploads WHERE 1=1""" args: list = [] if klub_id is not None: sql += " AND klub_id=%s"; args.append(klub_id) if status: sql += " AND ocr_status=%s"; args.append(status) sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(sql, args) rows = cur.fetchall() return {"ok": True, "rows": rows}