M9 CRM Obrasci + ZZJZ booking detect + e-mail fallback

Obrasci (M9):
- /api/crm/forms — katalog form_templates (15 templata već seedan)
- /api/crm/forms/templates — alias (kompatibilnost)
- /api/crm/forms/{code|id} — detalji + schema_json
- /api/crm/forms/{code|id}/prefill — autopopulacija polja iz baze
  (klub_id/clan_id/user_id → polja na obrascu mapirana po imenima)
- /api/crm/forms/submissions [GET/POST] — lista + create draft
- /api/crm/forms/submissions/{id} — detalji s schema + klub/clan
- /api/crm/forms/submissions/{id}/submit — submit + sha256 potpis sadržaja
- /api/crm/forms/submissions/{id}/sign — re-sign / potpis bez statusa change
- /api/crm/forms/submissions/{id}/approve|reject — workflow
- /api/crm/forms/submissions/{id}/pdf — generirani PDF s metapodacima i potpisom
- /api/crm/forms/{code|id}/submit — shortcut: kreiraj+submit u jednom POST

ZZJZ PGŽ (M8 dopuna):
- /api/crm/zzjz/info — dodan online_booking probe (HTTP scrape best-effort)
- /api/crm/lijecnicki/{id}/zakazi — vraća booking URL ako postoji, inače mailto:
- /api/crm/lijecnicki/zakazi-email — generira mailto: deeplink s pred-popunjenim
  podacima sportaša/kluba (fallback kad nema online termina)
- URL sportske medicine ispravljen na školska/adolescentna medicina (jedini stvarni
  odjel ZZJZ PGŽ koji obavlja sportske preglede).
This commit is contained in:
Damir Radulić
2026-05-05 00:14:59 +02:00
parent 85fd51bfd9
commit b93ca9a8bf
2 changed files with 888 additions and 7 deletions
+131 -7
View File
@@ -40,7 +40,8 @@ ZZJZ_INFO = {
"telefon": "+385 51 358 770",
"email": "info@zzjzpgz.hr",
"web": ZZJZ_BASE,
"url_sportska_medicina": f"{ZZJZ_BASE}/djelatnosti/sportska-medicina/",
# Najbliži postojeći odjel — sportski liječnički ide preko adolescentne medicine
"url_sportska_medicina": f"{ZZJZ_BASE}/zavod/odjeli/odjel-za-skolsku-i-adolescentnu-medicinu/",
}
@@ -382,7 +383,46 @@ def _mock_zzjz_termini(week_start: date) -> list[dict]:
@router.get("/zzjz/info")
def zzjz_info():
return ZZJZ_INFO
"""Vraća kontakt + provjerava ima li online termin sustav (best-effort scrape)."""
online_booking = _detect_zzjz_booking()
return {**ZZJZ_INFO, "online_booking": online_booking}
def _detect_zzjz_booking() -> dict:
"""
Best-effort detekcija da li ZZJZ PGŽ ima online termin formu na stranici.
Vraća: {available: bool, url: str|None, kind: 'iframe'|'link'|'email'}
Ne baca iznimku — uvijek vrati strukturu (fallback je email).
"""
try:
import urllib.request
import re as _re
req = urllib.request.Request(ZZJZ_INFO["url_sportska_medicina"],
headers={"User-Agent": "PGZSport/1.0"})
with urllib.request.urlopen(req, timeout=4) as resp:
html = resp.read(200_000).decode("utf-8", errors="ignore")
# tražimo standardne oznake online booking sustava
patterns = [
r'(https?://[^"\']*(?:doktor|booking|narucivanje|naruci|termin)[^"\']*)',
r'<iframe[^>]+src="([^"]+)"',
]
for p in patterns:
m = _re.search(p, html, _re.IGNORECASE)
if m:
url = m.group(1)
if "iframe" in p:
return {"available": True, "url": url, "kind": "iframe"}
return {"available": True, "url": url, "kind": "link"}
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
"kind": "email",
"fallback_email": ZZJZ_INFO["email"],
"note": "Nije pronađen online sustav — koristi e-mail kontakt."}
except Exception as e:
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
"kind": "email",
"fallback_email": ZZJZ_INFO["email"],
"error": str(e)[:120],
"note": "Detekcija nije uspjela — fallback na e-mail."}
@router.get("/zzjz/termini")
@@ -412,11 +452,22 @@ def zzjz_termini(
@router.post("/lijecnicki/{lid}/zakazi")
def zakazi_termin(lid: int, body: ZakaziIn):
"""
Stvara zakazani termin (mock) za pregled koji još nije obavljen.
Realna integracija: POST na ZZJZ PGŽ booking endpoint kad bude dostupan.
Zakazuje termin za pregled.
- Ako ZZJZ PGŽ ima online booking → vraća iframe/deeplink URL.
- Ako nema → vraća mailto: deeplink za zahtjev e-mailom.
Status pregleda u DB se ažurira (ustanova + napomena).
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT id, clan_id, ustanova FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,))
cur.execute("""
SELECT l.id, l.clan_id, l.ustanova,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email,
k.naziv AS klub
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.id=%s
""", (lid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
@@ -434,12 +485,85 @@ def zakazi_termin(lid: int, body: ZakaziIn):
""", (body.ustanova, new_napomena, lid))
upd = cur.fetchone()
conn.commit()
booking = _detect_zzjz_booking()
from urllib.parse import quote as _q
subj = _q(f"Zahtjev za termin sportske medicine — {r.get('clan') or '(sportaš)'}")
body_email = _q(
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
f"Sportaš: {r.get('clan') or ''}\n"
f"Klub: {r.get('klub') or ''}\n"
f"Željeni datum: {body.datum.isoformat()} oko {body.vrijeme}\n"
f"Kontakt: {r.get('clan_email') or '(nepoznato)'}\n\n"
f"Lijep pozdrav,\nPGŽ Sport platforma"
)
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
return {
"ok": True,
"id": lid,
"zakazano_za": f"{body.datum.isoformat()} {body.vrijeme}",
"ustanova": body.ustanova,
"zzjz_url": ZZJZ_INFO["url_sportska_medicina"],
"note": "Mock booking — realna ZZJZ PGŽ integracija čeka API/scraper.",
"zzjz": ZZJZ_INFO,
"booking": booking,
"mailto": mailto,
"note": (
"Online booking detektiran — koristi 'booking.url' za iframe/redirect."
if booking.get("available") else
"Online booking nije pronađen — fallback: koristi 'mailto' za zahtjev e-mailom."
),
"pregled": _row(upd),
}
class ZakaziEmailIn(BaseModel):
klub_id: Optional[int] = None
clan_id: int
zeljeni_datum: Optional[date] = None
zeljeno_vrijeme: Optional[str] = "09:00"
napomena: Optional[str] = None
@router.post("/lijecnicki/zakazi-email")
def zakazi_email(body: ZakaziEmailIn):
"""
Bez postojećeg pregleda — generira mailto: link s pred-popunjenim
podacima sportaša/kluba za slanje zahtjeva ZZJZ PGŽ.
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT cl.id, cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
cl.datum_rodenja, cl.oib AS clan_oib,
k.naziv AS klub, k.oib AS klub_oib
FROM pgz_sport.clanovi cl
LEFT JOIN pgz_sport.klubovi k ON k.id = cl.klub_id
WHERE cl.id=%s
""", (body.clan_id,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
from urllib.parse import quote as _q
when = (body.zeljeni_datum.isoformat() if body.zeljeni_datum else "po dogovoru")
subj = _q(f"Zahtjev za termin sportske medicine — {r['clan']}")
body_email = _q(
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
f"Sportaš: {r['clan']}\n"
f"OIB: {r['clan_oib'] or ''}\n"
f"Datum rođenja: {r['datum_rodenja'] or ''}\n"
f"Klub: {r['klub'] or ''}\n"
f"Željeni termin: {when} oko {body.zeljeno_vrijeme}\n"
f"Kontakt: {r['clan_email'] or ''} / {r['clan_telefon'] or ''}\n\n"
f"Napomena: {body.napomena or ''}\n\n"
f"Lijep pozdrav,\nPGŽ Sport platforma"
)
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
booking = _detect_zzjz_booking()
return {
"ok": True,
"clan": r["clan"],
"zzjz": ZZJZ_INFO,
"booking": booking,
"mailto": mailto,
}