#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════ # Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026 # Autor: Damir Radulić / damir@rinet.one # Lokacija: /opt/pgz-sport/routers/lijecnicki_router.py # Svrha: M8 — CRM Liječnički pregledi + ZZJZ PGŽ scheduling integracija # ═══════════════════════════════════════════════════════════════════ """M8 Liječnički router. Endpointi (montirani na /api/crm): GET /lijecnicki → lista (filteri) POST /lijecnicki → novi pregled GET /lijecnicki/{id} → detalji PUT /lijecnicki/{id} → update DELETE /lijecnicki/{id} → brisanje GET /lijecnicki/uskoro-isticu → istekao + idućih 30 dana POST /lijecnicki/{id}/zakazi → zakaži termin (ZZJZ PGŽ mock) GET /zzjz/termini → dostupni termini ZZJZ PGŽ (mock + scrape stub) """ from __future__ import annotations import sys from datetime import date, datetime, timedelta from decimal import Decimal from typing import Optional, List import psycopg2 from psycopg2.extras import RealDictCursor from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel router = APIRouter(prefix="/api/crm", tags=["crm-lijecnicki"]) DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" ZZJZ_BASE = "https://zzjzpgz.hr" ZZJZ_INFO = { "naziv": "Nastavni zavod za javno zdravstvo PGŽ", "adresa": "Krešimirova 52a, 51000 Rijeka", "telefon": "+385 51 358 770", "email": "info@zzjzpgz.hr", "web": ZZJZ_BASE, # 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/", } def _conn(): return psycopg2.connect(DSN, cursor_factory=RealDictCursor) def _conv(v): if isinstance(v, (date, datetime)): return v.isoformat() if isinstance(v, Decimal): return float(v) return v def _row(d): return {k: _conv(v) for k, v in dict(d).items()} # ───────────── modeli ───────────── class LijecnickiIn(BaseModel): clan_id: int klub_id: Optional[int] = None datum_pregleda: date vrijedi_do: Optional[date] = None vrsta_pregleda: Optional[str] = "temeljni" ustanova: Optional[str] = "ZZJZ PGŽ" lijecnik: Optional[str] = None spreman_za_natjecanje: Optional[bool] = True ekg: Optional[bool] = False krv: Optional[bool] = False spirometrija: Optional[bool] = False nalaz: Optional[str] = None komentar_lijecnika: Optional[str] = None preporuke: Optional[str] = None iznos: Optional[float] = 0 iznos_zzjz: Optional[float] = 0 iznos_klub: Optional[float] = 0 iznos_clan: Optional[float] = 0 datum_placanja: Optional[date] = None placeno: Optional[bool] = False racun_broj: Optional[str] = None nacin_placanja: Optional[str] = None napomena: Optional[str] = None class LijecnickiPatch(BaseModel): klub_id: Optional[int] = None datum_pregleda: Optional[date] = None vrijedi_do: Optional[date] = None vrsta_pregleda: Optional[str] = None ustanova: Optional[str] = None lijecnik: Optional[str] = None spreman_za_natjecanje: Optional[bool] = None ekg: Optional[bool] = None krv: Optional[bool] = None spirometrija: Optional[bool] = None nalaz: Optional[str] = None komentar_lijecnika: Optional[str] = None preporuke: Optional[str] = None iznos: Optional[float] = None iznos_zzjz: Optional[float] = None iznos_klub: Optional[float] = None iznos_clan: Optional[float] = None datum_placanja: Optional[date] = None placeno: Optional[bool] = None racun_broj: Optional[str] = None nacin_placanja: Optional[str] = None napomena: Optional[str] = None class ZakaziIn(BaseModel): datum: date vrijeme: Optional[str] = "09:00" ustanova: Optional[str] = "ZZJZ PGŽ" napomena: Optional[str] = None # ───────────── lista ───────────── @router.get("/lijecnicki") def list_lijecnicki( klub_id: Optional[int] = Query(None), clan_id: Optional[int] = Query(None), status: Optional[str] = Query(None, description="vazeci|uskoro|istekao"), placeno: Optional[bool] = Query(None), sort: str = Query("vrijedi_do"), order: str = Query("asc"), limit: int = Query(500, le=2000), ): where, params = [], [] if klub_id: where.append("l.klub_id = %s"); params.append(klub_id) if clan_id: where.append("l.clan_id = %s"); params.append(clan_id) if placeno is not None: where.append("l.placeno = %s"); params.append(placeno) # status_calc: vazeci = >30d, uskoro = 0..30d, istekao = <0 if status == "vazeci": where.append("l.vrijedi_do > (CURRENT_DATE + INTERVAL '30 days')") elif status == "uskoro": where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '30 days')") elif status == "istekao": where.append("l.vrijedi_do < CURRENT_DATE") sort_map = { "vrijedi_do": "l.vrijedi_do", "datum_pregleda": "l.datum_pregleda", "klub": "k.naziv", "clan": "cl.prezime", "iznos": "l.iznos", } sort_col = sort_map.get(sort, "l.vrijedi_do") order_sql = "DESC" if order.lower() == "desc" else "ASC" where_sql = ("WHERE " + " AND ".join(where)) if where else "" params.append(limit) sql = f""" SELECT l.*, cl.ime || ' ' || cl.prezime AS clan, cl.oib AS clan_oib, cl.email AS clan_email, k.naziv AS klub, k.oib AS klub_oib, CASE WHEN l.vrijedi_do IS NULL THEN 'nepoznato' WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao' WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro' ELSE 'vazeci' END AS status_calc, (l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka 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_sql} ORDER BY {sort_col} {order_sql} NULLS LAST LIMIT %s """ sum_sql = f""" SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE l.vrijedi_do < CURRENT_DATE) AS istekli, COUNT(*) FILTER (WHERE l.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro, COUNT(*) FILTER (WHERE l.vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci, COUNT(*) FILTER (WHERE l.placeno IS TRUE) AS placeni, COALESCE(SUM(l.iznos), 0)::numeric(10,2) AS total_iznos FROM pgz_sport.lijecnicki_pregledi l LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id {where_sql} """ with _conn() as conn, conn.cursor() as cur: cur.execute(sql, params) rows = [_row(r) for r in cur.fetchall()] cur.execute(sum_sql, params[:-1]) summary = _row(cur.fetchone() or {}) return {"count": len(rows), "rows": rows, "summary": summary} # ───────────── uskoro isticu (30 dana + istekli) ───────────── @router.get("/lijecnicki/uskoro-isticu") def list_uskoro_isticu( klub_id: Optional[int] = Query(None), days: int = Query(30, ge=1, le=180), include_expired: bool = Query(True), ): where = ["l.vrijedi_do IS NOT NULL"] params: list = [] if include_expired: where.append("l.vrijedi_do <= (CURRENT_DATE + (%s || ' days')::interval)") else: where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + (%s || ' days')::interval)") params.append(str(days)) if klub_id: where.append("l.klub_id = %s"); params.append(klub_id) sql = f""" SELECT l.id, l.clan_id, l.klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.placeno, cl.ime || ' ' || cl.prezime AS clan, cl.email AS clan_email, cl.telefon AS clan_telefon, k.naziv AS klub, k.oib AS klub_oib, (l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka, CASE WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao' ELSE 'uskoro' END AS status_calc 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 {' AND '.join(where)} ORDER BY l.vrijedi_do ASC """ with _conn() as conn, conn.cursor() as cur: cur.execute(sql, params) rows = [_row(r) for r in cur.fetchall()] n_istekli = sum(1 for r in rows if (r.get("dana_do_isteka") or 0) < 0) n_uskoro = len(rows) - n_istekli return {"count": len(rows), "istekli": n_istekli, "uskoro": n_uskoro, "days_window": days, "rows": rows} # ───────────── detalji ───────────── @router.get("/lijecnicki/{lid}") def get_lijecnicki(lid: int): with _conn() as conn, conn.cursor() as cur: cur.execute(""" SELECT l.*, cl.ime || ' ' || cl.prezime AS clan, cl.oib AS clan_oib, cl.email AS clan_email, cl.telefon AS clan_telefon, k.naziv AS klub, k.oib AS klub_oib, CASE WHEN l.vrijedi_do IS NULL THEN 'nepoznato' WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao' WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro' ELSE 'vazeci' END AS status_calc, (l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka 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") return _row(r) # ───────────── kreiraj ───────────── @router.post("/lijecnicki") def create_lijecnicki(body: LijecnickiIn): klub_id = body.klub_id with _conn() as conn, conn.cursor() as cur: if not klub_id: cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,)) r = cur.fetchone() klub_id = r["klub_id"] if r else None # default vrijedi_do = +1 godina ako nije postavljeno vrijedi_do = body.vrijedi_do if vrijedi_do is None: vrijedi_do = body.datum_pregleda + timedelta(days=365) cur.execute(""" INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, racun_broj, nacin_placanja, napomena) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, (body.clan_id, klub_id, body.datum_pregleda, vrijedi_do, body.vrsta_pregleda, body.ustanova, body.lijecnik, body.spreman_za_natjecanje, body.ekg, body.krv, body.spirometrija, body.nalaz, body.komentar_lijecnika, body.preporuke, body.iznos, body.iznos_zzjz, body.iznos_klub, body.iznos_clan, body.datum_placanja, body.placeno, body.racun_broj, body.nacin_placanja, body.napomena)) r = cur.fetchone() conn.commit() try: from erp.audit_helper import audit as _audit _audit("pgz_sport.lijecnicki_pregledi", "create", r["id"], korisnik="api", field="datum_pregleda", new=f"clan={body.clan_id} klub={klub_id} datum={body.datum_pregleda} vrijedi_do={vrijedi_do}") except Exception: pass return _row(r) # ───────────── update / delete ───────────── @router.put("/lijecnicki/{lid}") def update_lijecnicki(lid: int, patch: LijecnickiPatch): fields, params = [], [] for f in ("klub_id", "datum_pregleda", "vrijedi_do", "vrsta_pregleda", "ustanova", "lijecnik", "spreman_za_natjecanje", "ekg", "krv", "spirometrija", "nalaz", "komentar_lijecnika", "preporuke", "iznos", "iznos_zzjz", "iznos_klub", "iznos_clan", "datum_placanja", "placeno", "racun_broj", "nacin_placanja", "napomena"): v = getattr(patch, f) if v is not None: fields.append(f"{f} = %s"); params.append(v) if not fields: raise HTTPException(400, "Nema polja za izmjenu") fields.append("updated_at = now()") params.append(lid) with _conn() as conn, conn.cursor() as cur: cur.execute(f"UPDATE pgz_sport.lijecnicki_pregledi SET {', '.join(fields)} WHERE id=%s RETURNING *", params) r = cur.fetchone() if not r: raise HTTPException(404, "Liječnički pregled ne postoji") conn.commit() return _row(r) @router.delete("/lijecnicki/{lid}") def delete_lijecnicki(lid: int): with _conn() as conn, conn.cursor() as cur: cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s RETURNING id", (lid,)) r = cur.fetchone() conn.commit() if not r: raise HTTPException(404, "Liječnički pregled ne postoji") return {"ok": True, "id": lid, "deleted": True} # ───────────── ZZJZ PGŽ scheduling ───────────── def _mock_zzjz_termini(week_start: date) -> list[dict]: """ Mock dostupnih termina za sportsku medicinu. TODO: zamijeniti realnim scrapeom iz https://zzjzpgz.hr/djelatnosti/sportska-medicina/ Format termina: po danu (pon-pet), 09:00-15:00 svakih 30 min. """ out = [] times = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30", "12:30", "13:00", "13:30", "14:00", "14:30"] for d in range(5): day = week_start + timedelta(days=d) if day.weekday() >= 5: continue for t in times: # pseudo-availability deterministic by day*hour h = int(t.split(":")[0]) available = ((day.day + h) % 3) != 0 out.append({ "datum": day.isoformat(), "vrijeme": t, "doktor": "Dr. Sportska medicina", "ustanova": "ZZJZ PGŽ", "available": available, "iznos_eur": 25.00, }) return out @router.get("/zzjz/info") def 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']+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") def zzjz_termini( od: Optional[date] = Query(None, description="Početak tjedna; default = ovaj tjedan"), ): """ Vraća dostupne termine za sportsku medicinu pri ZZJZ PGŽ. Trenutno: mock (deterministička dostupnost). Stvarna integracija čeka API ili scraping form-e na zzjzpgz.hr. """ if od is None: today = date.today() od = today - timedelta(days=today.weekday()) termini = _mock_zzjz_termini(od) return { "ustanova": ZZJZ_INFO, "week_start": od.isoformat(), "count": len(termini), "available": sum(1 for t in termini if t["available"]), "termini": termini, "note": "Mock podaci. Realni termini čekaju ZZJZ PGŽ API ili authorizirani scraper.", } @router.post("/lijecnicki/{lid}/zakazi") def zakazi_termin(lid: int, body: ZakaziIn): """ 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 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") new_napomena = ( f"Termin zakazan: {body.datum.isoformat()} {body.vrijeme} @ " f"{body.ustanova}. {body.napomena or ''}" ).strip() cur.execute(""" UPDATE pgz_sport.lijecnicki_pregledi SET ustanova = COALESCE(%s, ustanova), napomena = %s, updated_at = now() WHERE id = %s RETURNING * """, (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": 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, }