Files
pgz-sport/routers/lijecnicki_router.py
T
Damir Radulić c12a8e9698 M8 CRM Liječnički pregledi: lista + isteci + ZZJZ PGŽ scheduling
- /api/crm/lijecnicki[CRUD] s filterima (klub/clan/status/placeno) + summary
- /api/crm/lijecnicki/uskoro-isticu — istekli + ≤30 dana (parametri days, include_expired)
- /api/crm/lijecnicki/{id} — detalji s status_calc + dana_do_isteka
- /api/crm/lijecnicki/{id}/zakazi — mock booking (upisuje termin u napomenu)
- /api/crm/zzjz/info — kontakt podaci ZZJZ PGŽ
- /api/crm/zzjz/termini — mock dostupnih termina za sportsku medicinu
  (deterministička dostupnost; realni scraper TODO)
2026-05-05 00:08:42 +02:00

446 lines
17 KiB
Python

#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / 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),
}