From c12a8e96989a22b25f1d0489392f695d6aada199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 00:08:42 +0200 Subject: [PATCH] =?UTF-8?q?M8=20CRM=20Lije=C4=8Dni=C4=8Dki=20pregledi:=20l?= =?UTF-8?q?ista=20+=20isteci=20+=20ZZJZ=20PG=C5=BD=20scheduling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- routers/lijecnicki_router.py | 445 +++++++++++++++++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 routers/lijecnicki_router.py diff --git a/routers/lijecnicki_router.py b/routers/lijecnicki_router.py new file mode 100644 index 0000000..e957156 --- /dev/null +++ b/routers/lijecnicki_router.py @@ -0,0 +1,445 @@ +#!/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), + }