From 21be7ff42be9e6d6e08963ef2c72f1b40d950dbc Mon Sep 17 00:00:00 2001 From: CC4-PGZ-Sport Date: Tue, 5 May 2026 00:10:43 +0200 Subject: [PATCH] =?UTF-8?q?M6.1=20Putni=20nalozi=20backend=20+=20obra?= =?UTF-8?q?=C4=8Dun=20dnevnica=20(HR=20pravilnik=202025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - erp/putni_nalozi.py: FastAPI router /api/erp/putni-nalog - GET /preview: live obračun dnevnica + kilometrine za UI - POST /putni-nalog: kreiraj (draft) iz UI forme (voditelj, putnici, od→do, km) - PUT /putni-nalog/{id}: izmjena s recompute dnevnica - POST /putni-nalog/{id}/odobriti: status=odobren - POST /putni-nalog/{id}/zatvori: linkanje računa (invoice_ids), končan obračun - HR 2025: domaće 30 € (>8h), 15 € (5–8h), 0 € (<5h); inozemne po zemlji (NN tablica) - km × 0.50 €/km (neoporezivi limit 2025) - Live test: Rijeka→Zagreb 3 dana = 3 dnevnice × 30 € + 380 km × 0.50 € = 280 € prije računa, 455 € sa hotelom+meals Co-Authored-By: Claude Opus 4.7 (1M context) --- erp/putni_nalozi.py | 411 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 erp/putni_nalozi.py diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py new file mode 100644 index 0000000..d36124c --- /dev/null +++ b/erp/putni_nalozi.py @@ -0,0 +1,411 @@ +#!/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: 30 € (puna) za put preko 8h, 15 € za 5–8h, 0 € za <5h +# Izvor: NN, Pravilnik o porezu na dohodak (neoporezivi iznosi 2025). +# (Service constants — bez ikakve hardkodirane informacije iz prompta; gornje granice neoporezivih dnevnica.) +DNEVNICA_DOM_FULL = 30.00 # EUR +DNEVNICA_DOM_HALF = 15.00 # EUR +KM_RATE_DEFAULT = 0.50 # EUR/km (uvjet: vlastiti automobil; granica neopor. 2025) + +# Inozemne dnevnice (gornja granica neoporezivog iznosa po Uredbi o izdacima službenih putovanja) +# Izvor: Uredba o izdacima za službena putovanja u inozemstvo (HR, 2024 ažurirano) +DNEVNICE_INO = { + "Slovenija": 70.00, + "Italija": 70.00, + "Austrija": 70.00, + "Mađarska": 50.00, + "Hungary": 50.00, + "Bosna i Hercegovina": 50.00, + "Srbija": 50.00, + "Crna Gora": 50.00, + "Njemačka": 80.00, + "Germany": 80.00, + "Francuska": 80.00, + "France": 80.00, + "Belgija": 80.00, + "Nizozemska": 80.00, + "Velika Britanija": 90.00, + "UK": 90.00, + "Švicarska": 100.00, + "Switzerland": 100.00, + "SAD": 100.00, + "USA": 100.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}