#!/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, "url_sportska_medicina": f"{ZZJZ_BASE}/djelatnosti/sportska-medicina/", } 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() 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(): return ZZJZ_INFO @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): """ Stvara zakazani termin (mock) za pregled koji još nije obavljen. Realna integracija: POST na ZZJZ PGŽ booking endpoint kad bude dostupan. """ with _conn() as conn, conn.cursor() as cur: cur.execute("SELECT id, clan_id, ustanova FROM pgz_sport.lijecnicki_pregledi WHERE 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() 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.", "pregled": _row(upd), }