#!/usr/bin/env python3 # erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6) # Author: Damir Radulić / dradulic@outlook.com # Date: 2026-05-04 # Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025). from __future__ import annotations import json from datetime import datetime, date, timedelta from typing import Optional, Any import psycopg2 import psycopg2.extras from fastapi import APIRouter, Body, HTTPException, Query, Header router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"]) DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", password="R1net2026!SecureDB#v7") # === HR pravilnik 2025 — dnevnice === # Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h. # Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €). DNEVNICA_DOM_FULL = 26.54 # EUR DNEVNICA_DOM_HALF = 13.27 # EUR KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil) # Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo). DNEVNICE_INO = { "Italija": 35.00, "Italy": 35.00, "Slovenija": 30.00, "Slovenia": 30.00, "Austrija": 35.00, "Austria": 35.00, "Mađarska": 30.00, "Madarska": 30.00, "Hungary": 30.00, "Bosna i Hercegovina": 30.00, "BiH": 30.00, "Bosnia": 30.00, "Srbija": 30.00, "Serbia": 30.00, "Crna Gora": 30.00, "Montenegro": 30.00, "Njemačka": 50.00, "Germany": 50.00, "Francuska": 50.00, "France": 50.00, "Švicarska": 60.00, "Switzerland": 60.00, "SAD": 70.00, "USA": 70.00, } def _db(): c = psycopg2.connect(**DB) c.autocommit = True return c def _parse_dt(v) -> Optional[datetime]: if v is None or v == "": return None if isinstance(v, datetime): return v s = str(v).strip().replace("Z", "+00:00") for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt) except Exception: continue try: return datetime.fromisoformat(s) except Exception: return None def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict: """ Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]} Pravila (HR pravilnik 2025, neoporeziv iznos): - Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna. - Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €. - Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima. Implementacija (jednostavna, transparentna): 1) ukupne sate računaj kao razliku. 2) full_segments = sati // 24 3) ostatak_sati = sati - full_segments*24 4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0. 5) puna dnevnica = pun_iznos po zemlji; pola = polovica. """ df = _parse_dt(date_from) dt = _parse_dt(date_to) if not df or not dt or dt < df: return {"error": "neispravni datumi", "hours": 0, "days_full": 0, "days_half": 0, "dnevnica_amount_total": 0.0, "breakdown": []} delta = dt - df hours = round(delta.total_seconds() / 3600, 2) full_segments = int(delta.total_seconds() // (24 * 3600)) remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0 days_full = full_segments days_half = 0.0 if remainder_h >= 8: days_full += 1 elif remainder_h >= 5: days_half += 1 # else: 0 is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr") if is_domestic: full_amt = DNEVNICA_DOM_FULL half_amt = DNEVNICA_DOM_HALF else: full_amt = DNEVNICE_INO.get(country.strip(), 50.00) half_amt = full_amt / 2.0 total = round(days_full * full_amt + days_half * half_amt, 2) return { "hours": hours, "days_full": days_full, "days_half": days_half, "country": country, "rate_full": full_amt, "rate_half": half_amt, "dnevnica_amount_total": total, "breakdown": [ f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €", f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "", ], } def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float: try: return round(float(km or 0) * float(km_rate or 0), 2) except Exception: return 0.0 # === Endpoints === @router.get("/putni-nalog/dnevnice/preview") def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska", km: float = 0.0, km_rate: float = KM_RATE_DEFAULT): """Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview.""" d = compute_dnevnice(date_from, date_to, country) km_amt = compute_kilometrina(km, km_rate) d["km_amount"] = km_amt d["km_driven"] = km d["km_rate"] = km_rate d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2) return {"ok": True, "preview": d} @router.get("/putni-nalog") def list_putni_nalozi(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = Query(100, le=500), offset: int = 0): sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv, er.user_id, er.clan_id, er.report_type, er.report_no, er.destination, er.purpose, er.date_from, er.date_to, er.vehicle_type, er.vehicle_plate, er.km_driven, er.km_rate, er.cost_transport, er.cost_lodging, er.cost_meals, er.cost_other, er.cost_total, er.dnevnice_count, er.dnevnice_amount, er.status, er.approved_at, er.paid_at, er.created_at, er.tenant_id, er.notes FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id WHERE er.report_type='putni_nalog'""" args: list = [] if klub_id is not None: sql += " AND er.klub_id=%s"; args.append(klub_id) if status: sql += " AND er.status=%s"; args.append(status) sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s" args += [limit, offset] with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(sql, args) rows = cur.fetchall() return {"ok": True, "rows": rows, "count": len(rows)} @router.get("/putni-nalog/{nalog_id}") def get_putni_nalog(nalog_id: int): with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("""SELECT er.*, k.naziv AS klub_naziv FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Putni nalog ne postoji") return {"ok": True, "putni_nalog": row} @router.post("/putni-nalog") def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)): """Kreiraj putni nalog. Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[], svrha (purpose), od_grada, do_grada (destination), datum_polaska (date_from), datum_povratka (date_to), registracija_vozila (vehicle_plate), vehicle_type, kilometara (km_driven), km_rate, predviđeni_troškovi (cost_estimate), country, notes.""" df = body.get("date_from") or body.get("datum_polaska") dt = body.get("date_to") or body.get("datum_povratka") if not df or not dt: raise HTTPException(400, "Datum polaska i povratka su obavezni") klub_id = body.get("klub_id") if not klub_id: raise HTTPException(400, "klub_id je obavezan") country = body.get("country", "Hrvatska") km = body.get("km_driven", body.get("kilometara", 0)) or 0 km_rate = body.get("km_rate") or KM_RATE_DEFAULT dnv = compute_dnevnice(df, dt, country) dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0) dnevnice_amount = dnv.get("dnevnica_amount_total") or 0 cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0) od = body.get("od_grada") or body.get("from_city") do = body.get("do_grada") or body.get("to_city") or body.get("destination") destination = " → ".join([x for x in [od, do] if x]) or do putnici = body.get("putnici") or [] voditelj = body.get("voditelj_ime") or body.get("voditelj") purpose = body.get("svrha") or body.get("purpose") or "" meta = { "voditelj": voditelj, "putnici": putnici, "from_city": od, "to_city": do, "country": country, "dnevnice_calc": dnv, "predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [], } with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """INSERT INTO pgz_sport.expense_reports (klub_id, user_id, clan_id, report_type, report_no, destination, purpose, date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate, cost_transport, cost_lodging, cost_meals, cost_other, dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id) VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, COALESCE(%s,'draft'), %s, %s, %s) RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount, cost_transport, date_from, date_to, destination""", ( klub_id, body.get("user_id"), body.get("clan_id"), body.get("report_no"), destination, purpose, df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"), float(km or 0), float(km_rate or 0), cost_transport, body.get("cost_lodging") or 0, body.get("cost_meals") or 0, body.get("cost_other") or 0, dnevnice_count, dnevnice_amount, body.get("status"), json.dumps(meta, ensure_ascii=False, default=str), body.get("notes"), body.get("tenant_id", 1), ), ) row = cur.fetchone() # cost_total via trigger maybe; recompute here cur.execute( """UPDATE pgz_sport.expense_reports SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) +COALESCE(cost_meals,0)+COALESCE(cost_other,0) +COALESCE(dnevnice_amount,0) WHERE id=%s RETURNING cost_total""", (row["id"],), ) ct = cur.fetchone() if ct: row["cost_total"] = ct["cost_total"] return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv} @router.put("/putni-nalog/{nalog_id}") def update_putni_nalog(nalog_id: int, body: dict = Body(...)): """Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe).""" cols = [] args: list = [] for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type", "vehicle_plate", "km_driven", "km_rate", "cost_transport", "cost_lodging", "cost_meals", "cost_other", "notes", "dnevnice_count", "dnevnice_amount"): if col in body: cols.append(f"{col}=%s"); args.append(body[col]) # Recompute dnevnice if dates provided if "date_from" in body or "date_to" in body or "country" in body: with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,)) cur_row = cur.fetchone() if cur_row: df = body.get("date_from") or cur_row["date_from"] dt = body.get("date_to") or cur_row["date_to"] country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska") d = compute_dnevnice(df, dt, country) cols += ["dnevnice_count=%s", "dnevnice_amount=%s"] args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0), d.get("dnevnica_amount_total") or 0] if not cols: raise HTTPException(400, "Nema polja za izmjenu") cols.append("updated_at=NOW()") args.append(nalog_id) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args) row = cur.fetchone() if row: cur.execute( """UPDATE pgz_sport.expense_reports SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) +COALESCE(cost_meals,0)+COALESCE(cost_other,0) +COALESCE(dnevnice_amount,0) WHERE id=%s""", (nalog_id,), ) if not row: raise HTTPException(404, "Putni nalog ne postoji") return {"ok": True, "putni_nalog": row} @router.post("/putni-nalog/{nalog_id}/odobriti") def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})): approved_by = body.get("approved_by") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """UPDATE pgz_sport.expense_reports SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW() WHERE id=%s AND report_type='putni_nalog' RETURNING id, status, approved_at""", (approved_by, nalog_id), ) row = cur.fetchone() if not row: raise HTTPException(404, "Putni nalog ne postoji") return {"ok": True, "putni_nalog": row} @router.post("/putni-nalog/{nalog_id}/zatvori") def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})): """Zatvori putni nalog: priloži račune i konačan obračun.""" invoice_ids = body.get("invoice_ids") or [] cost_lodging = body.get("cost_lodging") cost_meals = body.get("cost_meals") cost_other = body.get("cost_other") notes = body.get("notes") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) cur_row = cur.fetchone() if not cur_row: raise HTTPException(404, "Putni nalog ne postoji") # Aggregiraj iznose iz računa (ako su poslani) if invoice_ids: cur.execute( "SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)", (invoice_ids,), ) invs_total = float(cur.fetchone()["total"] or 0) else: invs_total = None sets = ["status='zatvoren'", "updated_at=NOW()"] args: list = [] if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging) if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals) if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other) if notes: sets.append("notes=%s"); args.append(notes) # Pohrani povezane račune u attachments atts = cur_row["attachments"] or {} if isinstance(atts, str): try: atts = json.loads(atts) except Exception: atts = {} atts["invoice_ids"] = invoice_ids if invs_total is not None: atts["invoices_total"] = invs_total sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str)) args.append(nalog_id) cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args) row = cur.fetchone() cur.execute( """UPDATE pgz_sport.expense_reports SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) +COALESCE(cost_meals,0)+COALESCE(cost_other,0) +COALESCE(dnevnice_amount,0) WHERE id=%s RETURNING cost_total""", (nalog_id,), ) ct = cur.fetchone() if ct: row["cost_total"] = ct["cost_total"] return {"ok": True, "putni_nalog": row}