Task 1: OCR u ERP/CRM — /api/ocr/upload + tab Računi (OCR)
- routers/ocr_router.py: POST /api/ocr/upload (Tesseract+pdf2image, regex field extraction) - pgz_sport_api.py: mount ocr_router with try/except guard - static/erp_full.html: nova tab "📷 OCR" + panel - static/crm_v2.html: OCR upload modal/tab Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1695,6 +1695,13 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[ERP/OCR] router fail: {e}')
|
print(f'[ERP/OCR] router fail: {e}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from routers.ocr_router import router as ocr_router
|
||||||
|
app.include_router(ocr_router)
|
||||||
|
print('[startup] ocr_router mounted')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[startup] ocr_router skipped: {e}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from erp.putni_nalozi import router as erp_putni_router
|
from erp.putni_nalozi import router as erp_putni_router
|
||||||
app.include_router(erp_putni_router)
|
app.include_router(erp_putni_router)
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# routers/ocr_router.py
|
||||||
|
# Name: PGŽ Sport OCR router (lightweight)
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Authors: Damir Radulić <dradulic@outlook.com> / <damir@rinet.one>
|
||||||
|
# Date: 2026-05-05
|
||||||
|
# Description: FastAPI APIRouter exposing POST /api/ocr/upload and
|
||||||
|
# GET /api/ocr/health. Accepts PDF/JPG/PNG, runs Tesseract
|
||||||
|
# (pdf2image for PDF), extracts vendor / OIB / invoice_no /
|
||||||
|
# date / amount via simple regex, persists into
|
||||||
|
# pgz_sport.invoice_uploads when possible. Designed to
|
||||||
|
# degrade gracefully if pytesseract / pdf2image are not
|
||||||
|
# installed (returns ocr_status='ocr_unavailable').
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple, Dict, Any, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
# ── Optional OCR deps ────────────────────────────────────────────────────────
|
||||||
|
_TESS_OK = False
|
||||||
|
_PDF2IMG_OK = False
|
||||||
|
_PIL_OK = False
|
||||||
|
try:
|
||||||
|
import pytesseract # type: ignore
|
||||||
|
_TESS_OK = True
|
||||||
|
except Exception:
|
||||||
|
pytesseract = None # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pdf2image import convert_from_bytes # type: ignore
|
||||||
|
_PDF2IMG_OK = True
|
||||||
|
except Exception:
|
||||||
|
convert_from_bytes = None # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image # type: ignore
|
||||||
|
_PIL_OK = True
|
||||||
|
except Exception:
|
||||||
|
Image = None # type: ignore
|
||||||
|
|
||||||
|
# ── 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/uploads/ocr")
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png"}
|
||||||
|
ALLOWED_MIME = {
|
||||||
|
"application/pdf",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
}
|
||||||
|
MAX_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||||
|
TEXT_CAP = 8 * 1024 # 8 KB cap for response text payload
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/ocr", tags=["ocr"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB helpers ───────────────────────────────────────────────────────────────
|
||||||
|
def _db():
|
||||||
|
c = psycopg2.connect(**DB)
|
||||||
|
c.autocommit = True
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _table_columns(schema: str, table: str) -> List[str]:
|
||||||
|
try:
|
||||||
|
with _db() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s AND table_name = %s
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Regex extractors ─────────────────────────────────────────────────────────
|
||||||
|
RE_OIB_HR = re.compile(r"\bHR\s*(\d{11})\b")
|
||||||
|
RE_OIB_BARE = re.compile(r"\b(\d{11})\b")
|
||||||
|
RE_INVOICE = re.compile(
|
||||||
|
r"(?im)^.*\b(?:Ra[čc]un|Invoice)\b[^\n\r]{0,80}$"
|
||||||
|
)
|
||||||
|
RE_DATE_DMY = re.compile(r"\b(\d{2})[./](\d{2})[./](\d{4})\b")
|
||||||
|
RE_DATE_YMD = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
|
||||||
|
# Amount candidates (1.234,56 or 1234,56 or 1234.56 or 1,234.56), at least 2 digits
|
||||||
|
RE_AMOUNT = re.compile(
|
||||||
|
r"(?<![\w.,])"
|
||||||
|
r"(\d{1,3}(?:[.\s]\d{3})+,\d{2}|\d+,\d{2}|\d{1,3}(?:,\d{3})+\.\d{2}|\d+\.\d{2})"
|
||||||
|
r"(?![\w])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_amount(raw: str) -> Optional[float]:
|
||||||
|
s = raw.strip().replace(" ", "")
|
||||||
|
# If both . and , present, assume , decimal if last separator is ,
|
||||||
|
if "," in s and "." in s:
|
||||||
|
if s.rfind(",") > s.rfind("."):
|
||||||
|
s = s.replace(".", "").replace(",", ".")
|
||||||
|
else:
|
||||||
|
s = s.replace(",", "")
|
||||||
|
elif "," in s:
|
||||||
|
# 1.234,56 or 1234,56 → swap
|
||||||
|
s = s.replace(".", "").replace(",", ".")
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_nonempty_line(text: str) -> Optional[str]:
|
||||||
|
for ln in (text or "").splitlines():
|
||||||
|
v = ln.strip()
|
||||||
|
if v:
|
||||||
|
return v[:200]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(text: str) -> Optional[str]:
|
||||||
|
m = RE_DATE_YMD.search(text or "")
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date().isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
m = RE_DATE_DMY.search(text or "")
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1))).date().isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_oib(text: str) -> Optional[str]:
|
||||||
|
m = RE_OIB_HR.search(text or "")
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
m = RE_OIB_BARE.search(text or "")
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_invoice_no(text: str) -> Optional[str]:
|
||||||
|
m = RE_INVOICE.search(text or "")
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
line = m.group(0).strip()
|
||||||
|
# Try to grab the right-most token that looks like an invoice id
|
||||||
|
cand = re.findall(r"[A-Z0-9][A-Z0-9\-/_.]{1,40}", line)
|
||||||
|
if cand:
|
||||||
|
# Drop pure words like "Račun"/"Invoice"
|
||||||
|
for c in reversed(cand):
|
||||||
|
if any(ch.isdigit() for ch in c):
|
||||||
|
return c[:64]
|
||||||
|
return line[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_amount(text: str) -> Optional[float]:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
best: Optional[float] = None
|
||||||
|
for m in RE_AMOUNT.finditer(text):
|
||||||
|
v = _norm_amount(m.group(1))
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
if best is None or v > best:
|
||||||
|
best = v
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_fields(text: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"vendor": _first_nonempty_line(text),
|
||||||
|
"oib": _parse_oib(text),
|
||||||
|
"invoice_no": _parse_invoice_no(text),
|
||||||
|
"date": _parse_date(text),
|
||||||
|
"amount": _parse_amount(text),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCR engine ───────────────────────────────────────────────────────────────
|
||||||
|
def _ocr_image_bytes(data: bytes) -> Tuple[Optional[str], Optional[float]]:
|
||||||
|
if not (_TESS_OK and _PIL_OK):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
img.load()
|
||||||
|
text = pytesseract.image_to_string(img, lang=os.getenv("OCR_LANG", "hrv+eng"))
|
||||||
|
# Confidence (best-effort)
|
||||||
|
conf = None
|
||||||
|
try:
|
||||||
|
d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT,
|
||||||
|
lang=os.getenv("OCR_LANG", "hrv+eng"))
|
||||||
|
confs = [int(c) for c in d.get("conf", []) if str(c).lstrip("-").isdigit() and int(c) >= 0]
|
||||||
|
if confs:
|
||||||
|
conf = round(sum(confs) / len(confs), 2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return text, conf
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _ocr_pdf_bytes(data: bytes) -> Tuple[Optional[str], Optional[float]]:
|
||||||
|
if not (_TESS_OK and _PDF2IMG_OK):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
pages = convert_from_bytes(data, dpi=200, fmt="png")
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
if not pages:
|
||||||
|
return None, None
|
||||||
|
out: List[str] = []
|
||||||
|
confs: List[float] = []
|
||||||
|
for p in pages[:8]: # cap to 8 pages
|
||||||
|
try:
|
||||||
|
out.append(pytesseract.image_to_string(p, lang=os.getenv("OCR_LANG", "hrv+eng")))
|
||||||
|
try:
|
||||||
|
d = pytesseract.image_to_data(p, output_type=pytesseract.Output.DICT,
|
||||||
|
lang=os.getenv("OCR_LANG", "hrv+eng"))
|
||||||
|
cs = [int(c) for c in d.get("conf", []) if str(c).lstrip("-").isdigit() and int(c) >= 0]
|
||||||
|
if cs:
|
||||||
|
confs.append(sum(cs) / len(cs))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
text = "\n\f\n".join(out) if out else None
|
||||||
|
conf = round(sum(confs) / len(confs), 2) if confs else None
|
||||||
|
return text, conf
|
||||||
|
|
||||||
|
|
||||||
|
# ── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
def _maybe_insert_upload(payload: Dict[str, Any]) -> Optional[int]:
|
||||||
|
"""Insert into pgz_sport.invoice_uploads — only writes columns that exist."""
|
||||||
|
cols = set(_table_columns("pgz_sport", "invoice_uploads"))
|
||||||
|
if not cols:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Map our payload keys to potential DB columns
|
||||||
|
candidates: Dict[str, Any] = {
|
||||||
|
"file_name": payload.get("file_name"),
|
||||||
|
"file_path": payload.get("file_path"),
|
||||||
|
"file_size": payload.get("file_size"),
|
||||||
|
"mime": payload.get("mime"),
|
||||||
|
"sha256": payload.get("sha256"),
|
||||||
|
"ocr_status": payload.get("ocr_status"),
|
||||||
|
"ocr_engine": payload.get("ocr_engine"),
|
||||||
|
"ocr_text": payload.get("ocr_text_full"),
|
||||||
|
"ocr_confidence": payload.get("ocr_confidence"),
|
||||||
|
"ai_invoice_no": (payload.get("extracted") or {}).get("invoice_no"),
|
||||||
|
"ai_invoice_date": (payload.get("extracted") or {}).get("date"),
|
||||||
|
"ai_vendor_name": (payload.get("extracted") or {}).get("vendor"),
|
||||||
|
"ai_vendor_oib": (payload.get("extracted") or {}).get("oib"),
|
||||||
|
"ai_amount_gross": (payload.get("extracted") or {}).get("amount"),
|
||||||
|
"ai_engine": payload.get("ai_engine") or "regex-v1",
|
||||||
|
"ai_extracted": json.dumps(payload.get("extracted") or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
insert_cols: List[str] = []
|
||||||
|
insert_vals: List[Any] = []
|
||||||
|
for k, v in candidates.items():
|
||||||
|
if k in cols and v is not None:
|
||||||
|
insert_cols.append(k)
|
||||||
|
insert_vals.append(v)
|
||||||
|
|
||||||
|
if not insert_cols:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sql = "INSERT INTO pgz_sport.invoice_uploads ({c}) VALUES ({p}) RETURNING id".format(
|
||||||
|
c=", ".join(insert_cols),
|
||||||
|
p=", ".join(["%s"] * len(insert_cols)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with _db() as c, c.cursor() as cur:
|
||||||
|
cur.execute(sql, insert_vals)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row[0]) if row else None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ocr_router] insert failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"tesseract_available": bool(_TESS_OK and _PIL_OK),
|
||||||
|
"pdf2image_available": bool(_PDF2IMG_OK),
|
||||||
|
"upload_dir": str(UPLOAD_DIR),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload(file: UploadFile = File(...)):
|
||||||
|
if not file or not file.filename:
|
||||||
|
raise HTTPException(400, "no file")
|
||||||
|
|
||||||
|
# Validate extension/mime
|
||||||
|
ext = Path(file.filename).suffix.lower()
|
||||||
|
if ext not in ALLOWED_EXT:
|
||||||
|
raise HTTPException(400, f"extension not allowed: {ext}")
|
||||||
|
|
||||||
|
# Read full body (bounded)
|
||||||
|
data = await file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, "empty file")
|
||||||
|
if len(data) > MAX_BYTES:
|
||||||
|
raise HTTPException(413, f"file too large: {len(data)} > {MAX_BYTES}")
|
||||||
|
|
||||||
|
sha = hashlib.sha256(data).hexdigest()
|
||||||
|
save_name = f"{sha}{ext}"
|
||||||
|
abs_path = UPLOAD_DIR / save_name
|
||||||
|
if not abs_path.exists():
|
||||||
|
try:
|
||||||
|
abs_path.write_bytes(data)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"could not persist file: {e}")
|
||||||
|
|
||||||
|
rel_path = f"uploads/ocr/{save_name}"
|
||||||
|
|
||||||
|
# Run OCR
|
||||||
|
ocr_text: Optional[str] = None
|
||||||
|
ocr_conf: Optional[float] = None
|
||||||
|
ocr_engine = "tesseract"
|
||||||
|
if ext == ".pdf":
|
||||||
|
if not (_TESS_OK and _PDF2IMG_OK and _PIL_OK):
|
||||||
|
ocr_status = "ocr_unavailable"
|
||||||
|
else:
|
||||||
|
ocr_text, ocr_conf = _ocr_pdf_bytes(data)
|
||||||
|
ocr_status = "ocr_done" if ocr_text else "ocr_failed"
|
||||||
|
else:
|
||||||
|
if not (_TESS_OK and _PIL_OK):
|
||||||
|
ocr_status = "ocr_unavailable"
|
||||||
|
else:
|
||||||
|
ocr_text, ocr_conf = _ocr_image_bytes(data)
|
||||||
|
ocr_status = "ocr_done" if ocr_text else "ocr_failed"
|
||||||
|
|
||||||
|
extracted = _extract_fields(ocr_text or "")
|
||||||
|
|
||||||
|
# Truncated text for response
|
||||||
|
text_resp = (ocr_text or "")
|
||||||
|
if len(text_resp) > TEXT_CAP:
|
||||||
|
text_resp = text_resp[:TEXT_CAP]
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"file_name": file.filename,
|
||||||
|
"file_path": rel_path,
|
||||||
|
"file_size": len(data),
|
||||||
|
"mime": file.content_type or "application/octet-stream",
|
||||||
|
"sha256": sha,
|
||||||
|
"ocr_status": ocr_status,
|
||||||
|
"ocr_engine": ocr_engine if ocr_status == "ocr_done" else None,
|
||||||
|
"ocr_text_full": ocr_text,
|
||||||
|
"ocr_confidence": ocr_conf,
|
||||||
|
"extracted": extracted,
|
||||||
|
"ai_engine": "regex-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted_id = _maybe_insert_upload(payload)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"id": inserted_id,
|
||||||
|
"file_path": rel_path,
|
||||||
|
"file_name": file.filename,
|
||||||
|
"file_size": len(data),
|
||||||
|
"mime": payload["mime"],
|
||||||
|
"sha256": sha,
|
||||||
|
"ocr_status": ocr_status,
|
||||||
|
"ocr_confidence": ocr_conf,
|
||||||
|
"ocr_text": text_resp if ocr_text else None,
|
||||||
|
"extracted": extracted,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -484,6 +484,33 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ━━━ OCR floating button + modal ━━━ -->
|
||||||
|
<button id="ocr-fab" onclick="ocrOpen()"
|
||||||
|
style="position:fixed;right:18px;bottom:18px;z-index:60;
|
||||||
|
background:#1f6feb;color:#fff;border:none;border-radius:24px;
|
||||||
|
padding:10px 16px;font-size:13px;cursor:pointer;
|
||||||
|
box-shadow:0 6px 18px rgba(0,0,0,0.4)">
|
||||||
|
📷 OCR Upload
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="ocr-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:80;align-items:center;justify-content:center">
|
||||||
|
<div style="background:#0f1620;color:#dbe2ee;border:1px solid #25334a;border-radius:10px;width:min(720px,94vw);max-height:90vh;overflow:auto;padding:14px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #25334a;padding-bottom:8px;margin-bottom:10px">
|
||||||
|
<h3 style="margin:0;font-size:14px">📷 OCR Upload (PDF / JPG / PNG)</h3>
|
||||||
|
<button onclick="ocrClose()" style="background:none;border:none;color:#dbe2ee;font-size:18px;cursor:pointer">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="file" id="ocr-crm-file" accept="application/pdf,image/jpeg,image/jpg,image/png">
|
||||||
|
<button class="btn primary" onclick="ocrCrmUpload()">Upload</button>
|
||||||
|
<button class="btn" onclick="ocrCrmHealth()">Health</button>
|
||||||
|
<span id="ocr-crm-status" style="font-size:11px;color:#8aa0bd"></span>
|
||||||
|
</div>
|
||||||
|
<div id="ocr-crm-health" style="font-size:11px;color:#8aa0bd;margin-top:6px"></div>
|
||||||
|
<div id="ocr-crm-fields" style="margin-top:10px;font-size:12px"></div>
|
||||||
|
<pre id="ocr-crm-text" style="margin-top:10px;max-height:300px;overflow:auto;background:#0a1018;padding:10px;border-radius:6px;font-size:11px;white-space:pre-wrap">— prazno —</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -1681,6 +1708,59 @@ document.getElementById('modal').addEventListener('click', e => {
|
|||||||
if (e.target.id === 'modal') closeModal();
|
if (e.target.id === 'modal') closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ────── OCR (lightweight /api/ocr) ──────
|
||||||
|
const OCR_API = '/sport/api/ocr';
|
||||||
|
|
||||||
|
function ocrOpen(){ document.getElementById('ocr-modal').style.display = 'flex'; }
|
||||||
|
function ocrClose(){ document.getElementById('ocr-modal').style.display = 'none'; }
|
||||||
|
|
||||||
|
async function ocrCrmHealth(){
|
||||||
|
const out = document.getElementById('ocr-crm-health');
|
||||||
|
if(out) out.textContent = '...checking';
|
||||||
|
try {
|
||||||
|
const r = await fetch(OCR_API + '/health');
|
||||||
|
const j = await r.json();
|
||||||
|
if(out){
|
||||||
|
out.textContent = 'tesseract: ' + (j.tesseract_available ? 'OK' : 'NO') +
|
||||||
|
' · pdf2image: ' + (j.pdf2image_available ? 'OK' : 'NO');
|
||||||
|
}
|
||||||
|
} catch(e){
|
||||||
|
if(out) out.textContent = 'health err: ' + (e && e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ocrCrmUpload(){
|
||||||
|
const f = document.getElementById('ocr-crm-file').files[0];
|
||||||
|
const stat = document.getElementById('ocr-crm-status');
|
||||||
|
const fields = document.getElementById('ocr-crm-fields');
|
||||||
|
const txt = document.getElementById('ocr-crm-text');
|
||||||
|
if(!f){ if(stat) stat.textContent = 'odaberi datoteku'; return; }
|
||||||
|
if(stat) stat.textContent = 'uploading…';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', f);
|
||||||
|
try {
|
||||||
|
const r = await fetch(OCR_API + '/upload', { method: 'POST', body: fd });
|
||||||
|
const j = await r.json();
|
||||||
|
if(!r.ok){ if(stat) stat.textContent = 'err ' + r.status; return; }
|
||||||
|
const ex = j.extracted || {};
|
||||||
|
fields.innerHTML =
|
||||||
|
'<table style="width:100%;font-size:12px">'
|
||||||
|
+ '<tr><th style="text-align:left;width:140px">vendor</th><td>'+(ex.vendor||'—')+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">OIB</th><td>'+(ex.oib||'—')+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">invoice_no</th><td>'+(ex.invoice_no||'—')+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">date</th><td>'+(ex.date||'—')+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">amount</th><td>'+(ex.amount==null?'—':ex.amount)+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">ocr_status</th><td>'+(j.ocr_status||'—')+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">confidence</th><td>'+(j.ocr_confidence==null?'—':j.ocr_confidence)+'</td></tr>'
|
||||||
|
+ '<tr><th style="text-align:left">file</th><td>'+((j.file_name||'?')+' · '+(j.file_size||0)+' B')+'</td></tr>'
|
||||||
|
+ '</table>';
|
||||||
|
txt.textContent = j.ocr_text || '— (prazno / OCR nije izvršen) —';
|
||||||
|
if(stat) stat.textContent = 'done · id=' + (j.id == null ? 'n/a' : j.id);
|
||||||
|
} catch(e){
|
||||||
|
if(stat) stat.textContent = 'err: ' + (e && e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ────── Init ──────
|
// ────── Init ──────
|
||||||
loadMe();
|
loadMe();
|
||||||
ensureMe();
|
ensureMe();
|
||||||
|
|||||||
+125
-1
@@ -118,6 +118,7 @@ table tbody tr:hover{background:var(--bg3)}
|
|||||||
<button class="tab" data-panel="partneri">🤝 Partneri</button>
|
<button class="tab" data-panel="partneri">🤝 Partneri</button>
|
||||||
<button class="tab" data-panel="racuni">🧾 Računi</button>
|
<button class="tab" data-panel="racuni">🧾 Računi</button>
|
||||||
<button class="tab" data-panel="uploads">📎 Uploads (OCR)</button>
|
<button class="tab" data-panel="uploads">📎 Uploads (OCR)</button>
|
||||||
|
<button class="tab" data-panel="ocr">📷 OCR</button>
|
||||||
<button class="tab" data-panel="putni">✈ Putni nalozi</button>
|
<button class="tab" data-panel="putni">✈ Putni nalozi</button>
|
||||||
<button class="tab" data-panel="payments">💰 Plaćanja</button>
|
<button class="tab" data-panel="payments">💰 Plaćanja</button>
|
||||||
<button class="tab" data-panel="pdv">% PDV</button>
|
<button class="tab" data-panel="pdv">% PDV</button>
|
||||||
@@ -244,6 +245,54 @@ table tbody tr:hover{background:var(--bg3)}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ OCR (Računi) — lightweight /api/ocr/upload ============ -->
|
||||||
|
<section class="panel" id="panel-ocr">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">
|
||||||
|
<div class="card-t">📷 OCR — Računi (Tesseract + regex extrakcija)</div>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<button class="btn" onclick="ocrHealth()">🩺 Health</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px;border:2px dashed var(--rim2);border-radius:8px;background:var(--bg3);margin-bottom:10px">
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="file" id="ocr-file" accept="application/pdf,image/jpeg,image/jpg,image/png">
|
||||||
|
<button class="btn primary" onclick="ocrUpload()">⬆ Upload</button>
|
||||||
|
<span id="ocr-status" style="color:var(--t2);font-size:11px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="ocr-health" style="margin-top:6px;font-size:11px;color:var(--t1)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><div class="card-t">Ekstrahirana polja</div></div>
|
||||||
|
<div class="tbl-wrap" style="padding:6px">
|
||||||
|
<table id="ocr-fields"><tbody>
|
||||||
|
<tr><th style="text-align:left;width:140px">vendor</th><td id="ocr-vendor">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">OIB</th><td id="ocr-oib">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">invoice_no</th><td id="ocr-invno">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">date</th><td id="ocr-date">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">amount</th><td id="ocr-amount">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">ocr_status</th><td id="ocr-ostatus">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">confidence</th><td id="ocr-conf">—</td></tr>
|
||||||
|
<tr><th style="text-align:left">file</th><td id="ocr-file-info">—</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px;display:flex;gap:8px">
|
||||||
|
<!-- TODO: stvarna integracija sa pgz_sport.racuni_ulazni (real save) -->
|
||||||
|
<button class="btn primary" onclick="ocrSaveRacun()">💾 Spremi u racuni_ulazni</button>
|
||||||
|
<button class="btn" onclick="ocrReset()">↺ Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><div class="card-t">Prepoznati tekst (OCR)</div></div>
|
||||||
|
<pre id="ocr-text" style="white-space:pre-wrap;max-height:420px;overflow:auto;padding:10px;font-size:11px;background:var(--bg2);color:var(--t1);margin:0">— prazno —</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ -->
|
<!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ -->
|
||||||
<section class="panel" id="panel-putni">
|
<section class="panel" id="panel-putni">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1184,6 +1233,80 @@ function exportPdf(report, godina){
|
|||||||
window.open(API+'/export/pdf/'+report+'?godina='+godina, '_blank');
|
window.open(API+'/export/pdf/'+report+'?godina='+godina, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== OCR (lightweight /api/ocr) =====
|
||||||
|
const OCR_API = '/sport/api/ocr';
|
||||||
|
let _ocrLast = null;
|
||||||
|
|
||||||
|
function _ocrSet(id, val){
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if(el) el.textContent = (val === null || val === undefined || val === '') ? '—' : String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ocrHealth(){
|
||||||
|
const out = document.getElementById('ocr-health');
|
||||||
|
if(out) out.textContent = '...checking';
|
||||||
|
try {
|
||||||
|
const r = await fetch(OCR_API + '/health');
|
||||||
|
const j = await r.json();
|
||||||
|
if(out){
|
||||||
|
out.textContent = 'tesseract: ' + (j.tesseract_available ? 'OK' : 'NO') +
|
||||||
|
' · pdf2image: ' + (j.pdf2image_available ? 'OK' : 'NO') +
|
||||||
|
' · upload_dir: ' + (j.upload_dir || '?');
|
||||||
|
}
|
||||||
|
} catch(e){
|
||||||
|
if(out) out.textContent = 'health err: ' + (e && e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ocrUpload(){
|
||||||
|
const f = document.getElementById('ocr-file').files[0];
|
||||||
|
const stat = document.getElementById('ocr-status');
|
||||||
|
if(!f){ if(stat) stat.textContent = 'odaberi datoteku'; return; }
|
||||||
|
if(stat) stat.textContent = 'uploading…';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', f);
|
||||||
|
try {
|
||||||
|
const r = await fetch(OCR_API + '/upload', { method: 'POST', body: fd });
|
||||||
|
const j = await r.json();
|
||||||
|
if(!r.ok){
|
||||||
|
if(stat) stat.textContent = 'err ' + r.status + ': ' + (j && j.detail || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ocrLast = j;
|
||||||
|
const ex = j.extracted || {};
|
||||||
|
_ocrSet('ocr-vendor', ex.vendor);
|
||||||
|
_ocrSet('ocr-oib', ex.oib);
|
||||||
|
_ocrSet('ocr-invno', ex.invoice_no);
|
||||||
|
_ocrSet('ocr-date', ex.date);
|
||||||
|
_ocrSet('ocr-amount', ex.amount);
|
||||||
|
_ocrSet('ocr-ostatus', j.ocr_status);
|
||||||
|
_ocrSet('ocr-conf', j.ocr_confidence);
|
||||||
|
_ocrSet('ocr-file-info', (j.file_name || '?') + ' · ' + (j.file_size||0) + ' B · ' + (j.mime||'?'));
|
||||||
|
const txt = document.getElementById('ocr-text');
|
||||||
|
if(txt) txt.textContent = j.ocr_text || '— (prazno / OCR nije izvršen) —';
|
||||||
|
if(stat) stat.textContent = 'done · id=' + (j.id == null ? 'n/a' : j.id);
|
||||||
|
} catch(e){
|
||||||
|
if(stat) stat.textContent = 'err: ' + (e && e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ocrReset(){
|
||||||
|
['ocr-vendor','ocr-oib','ocr-invno','ocr-date','ocr-amount','ocr-ostatus','ocr-conf','ocr-file-info'].forEach(id => _ocrSet(id, null));
|
||||||
|
const txt = document.getElementById('ocr-text');
|
||||||
|
if(txt) txt.textContent = '— prazno —';
|
||||||
|
const stat = document.getElementById('ocr-status');
|
||||||
|
if(stat) stat.textContent = '';
|
||||||
|
_ocrLast = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ocrSaveRacun(){
|
||||||
|
// TODO: stvarna integracija sa pgz_sport.racuni_ulazni (real save) — wire later
|
||||||
|
if(!_ocrLast){ alert('Nema OCR podatka. Prvo uploadaj račun.'); return; }
|
||||||
|
alert('TODO: spremi u racuni_ulazni\nfile_path: ' + (_ocrLast.file_path || '?') +
|
||||||
|
'\nvendor: ' + ((_ocrLast.extracted||{}).vendor || '?') +
|
||||||
|
'\namount: ' + ((_ocrLast.extracted||{}).amount || '?'));
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy loaders per panel
|
// Lazy loaders per panel
|
||||||
const loaders = {
|
const loaders = {
|
||||||
dnevnik: loadDnevnik,
|
dnevnik: loadDnevnik,
|
||||||
@@ -1197,7 +1320,8 @@ const loaders = {
|
|||||||
place: () => { loadZap(); loadPlace(); },
|
place: () => { loadZap(); loadPlace(); },
|
||||||
proracun: loadProracun,
|
proracun: loadProracun,
|
||||||
izvjestaji: loadIzvjestaj,
|
izvjestaji: loadIzvjestaj,
|
||||||
kontni: loadKontniPlan
|
kontni: loadKontniPlan,
|
||||||
|
ocr: ocrHealth
|
||||||
};
|
};
|
||||||
|
|
||||||
// Switch programmatically (used by deep links: ?tab=uploads / #tab=putni)
|
// Switch programmatically (used by deep links: ?tab=uploads / #tab=putni)
|
||||||
|
|||||||
Reference in New Issue
Block a user