#!/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 try: from erp.permissions import ( can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog, can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions, audit_putni, fetch_audit, is_pgz_admin, ) except Exception: def can_view_putni_nalog(u, p): return True def can_edit_putni_nalog(u, p): return True def can_submit_putni_nalog(u, p): return True def can_approve_putni_nalog(u, p): return True def can_pay_putni_nalog(u, p): return True def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False} def audit_putni(u, pid, op, field=None, old=None, new=None): pass def fetch_audit(t, r, limit=50): return [] def is_pgz_admin(u): return False try: from auth.auth_v2 import get_current_user as _auth_user except Exception: _auth_user = None try: from erp.notifications import ( notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid, ) except Exception: def notify_pn_submitted(*a, **k): return {} def notify_pn_approved(*a, **k): return {} def notify_pn_rejected(*a, **k): return {} def notify_pn_paid(*a, **k): return {} ADMIN_TOKEN = "admin-pgz-2026" def _resolve_user(authorization): if _auth_user: try: u = _auth_user(authorization) if u: return u except Exception: pass if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN: return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", "klub_id": None, "savez_id": None, "_synthetic": True} return None 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, authorization: Optional[str] = Header(None)): user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id 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") if user and not can_view_putni_nalog(user, row): raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog") # Vezani računi iz m2m tablice cur.execute( """SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib, i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category, pnr.kategorija AS attached_kategorija, pnr.attached_at FROM pgz_sport.putni_nalog_racuni pnr JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id WHERE pnr.putni_nalog_id=%s ORDER BY i.invoice_date DESC""", (nalog_id,)) invoices = cur.fetchall() # Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani cur.execute( """SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib, i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category FROM pgz_sport.invoices i LEFT JOIN pgz_sport.putni_nalog_racuni pnr ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s WHERE i.klub_id=%s AND i.invoice_date BETWEEN %s AND %s AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo') AND pnr.id IS NULL ORDER BY i.invoice_date DESC LIMIT 50""", (nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")), ) suggested = cur.fetchall() # Payments za ovaj putni nalog cur.execute( """SELECT id, payment_date, amount, currency, payment_method, iban_from, iban_to, reference, bank_transaction_id, matched_status, created_at FROM pgz_sport.payments WHERE expense_report_id=%s ORDER BY payment_date DESC""", (nalog_id,)) payments = cur.fetchall() audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50) actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False} return {"ok": True, "putni_nalog": row, "invoices": invoices, "suggested_invoices": suggested, "payments": payments, "audit": audit, "actions": actions} @router.post("/putni-nalog/{nalog_id}/attach-invoice") def attach_invoice(nalog_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)): """Veži postojeći račun na putni nalog (m2m).""" user = _resolve_user(authorization) inv_id = body.get("invoice_id") kategorija = body.get("kategorija") or body.get("category") if not inv_id: raise HTTPException(400, "invoice_id je obavezan") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT er.*, k.savez_id 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user): raise HTTPException(403, "Nemate ovlasti za vezivanje računa") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """INSERT INTO pgz_sport.putni_nalog_racuni (putni_nalog_id, invoice_id, kategorija, attached_by) VALUES (%s,%s,%s,%s) ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija RETURNING id, attached_at""", (nalog_id, inv_id, kategorija, (user.get("id") if user else None)), ) link = cur.fetchone() audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id) return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]} @router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}") def detach_invoice(nalog_id: int, invoice_id: int, authorization: Optional[str] = Header(None)): user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT er.*, k.savez_id 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user): raise HTTPException(403, "Nemate ovlasti") with _db() as c: cur = c.cursor() cur.execute( "DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s", (nalog_id, invoice_id), ) audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id) return {"ok": True} @router.post("/putni-nalog/{nalog_id}/posalji") def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): """Voditelj/klub_admin šalje draft → poslan.""" user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT er.*, k.savez_id 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_submit_putni_nalog(user, pn): raise HTTPException(403, "Nemate ovlasti slanja na odobrenje") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW() WHERE id=%s RETURNING id, status""", (nalog_id,)) row = cur.fetchone() audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan") notif = notify_pn_submitted({**pn, "status": "poslan"}) return {"ok": True, "putni_nalog": row, "notification": notif} @router.post("/putni-nalog/{nalog_id}/odbij") def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}), authorization: Optional[str] = Header(None)): """Klub_admin/pgz_admin odbija s razlogom.""" user = _resolve_user(authorization) razlog = (body.get("razlog") or body.get("reason") or "").strip() with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT er.*, k.savez_id 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_approve_putni_nalog(user, pn): raise HTTPException(403, "Nemate ovlasti odbiti") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """UPDATE pgz_sport.expense_reports SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW() WHERE id=%s RETURNING id, status, notes""", (razlog or "(bez razloga)", nalog_id), ) row = cur.fetchone() audit_putni(user, nalog_id, "reject", field="status", old=pn.get("status"), new=f"odbijen: {razlog}") notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog) return {"ok": True, "putni_nalog": row, "notification": notif} @router.post("/putni-nalog/{nalog_id}/isplati") def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}), authorization: Optional[str] = Header(None)): """Isplata putnog naloga (odobren/zatvoren → isplaćen). Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}""" user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT er.*, k.savez_id 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_pay_putni_nalog(user, pn): raise HTTPException(403, "Nemate ovlasti za isplatu") paid_date = body.get("paid_date") or date.today().isoformat() iban_to = body.get("iban_to") iban_from = body.get("iban_from") amount = body.get("amount") or pn.get("cost_total") reference = body.get("reference") tx_id = body.get("bank_transaction_id") or body.get("tx_id") payment_method = body.get("payment_method") or "transfer" with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """UPDATE pgz_sport.expense_reports SET status='isplacen', paid_at=%s, updated_at=NOW() WHERE id=%s RETURNING id, status, paid_at, cost_total""", (paid_date, nalog_id), ) row = cur.fetchone() cur.execute( """INSERT INTO pgz_sport.payments (klub_id, expense_report_id, payment_date, amount, currency, payment_method, iban_from, iban_to, reference, bank_transaction_id, matched_status) VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched') RETURNING id""", (pn.get("klub_id"), nalog_id, paid_date, amount, payment_method, iban_from, iban_to, reference, tx_id), ) pay = cur.fetchone() audit_putni(user, nalog_id, "pay", field="status", old=pn.get("status"), new="isplacen") notif = notify_pn_paid( {**pn, **(row or {}), "id": nalog_id}, {"iban_to": iban_to, "iban_from": iban_from, "amount": amount, "reference": reference, "payment_date": paid_date}, ) return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None, "notification": notif} @router.get("/putni-nalog/{nalog_id}/hub3.pdf") def putni_hub3(nalog_id: int, iban: Optional[str] = None, authorization: Optional[str] = Header(None)): """HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju.""" user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_view_putni_nalog(user, pn): raise HTTPException(403, "Nemate ovlasti") try: from crm.payments import build_hub3_pdf except Exception as e: raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}") from fastapi.responses import Response att = pn.get("attachments") or {} if isinstance(att, str): try: att = json.loads(att) except Exception: att = {} voditelj = att.get("voditelj") or "Voditelj putovanja" iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000" iznos = float(pn.get("cost_total") or 0) if iznos <= 0: raise HTTPException(400, "Iznos isplate mora biti veći od 0") poziv = f"{nalog_id:08d}" opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}–{pn.get('date_to')})"[:140] pdf = build_hub3_pdf( platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub", platitelj_adresa=pn.get("klub_adresa") or "—", primatelj_naziv=voditelj, primatelj_adresa="—", iban=iban_to, amount_eur=iznos, model="HR99", poziv_na_broj=poziv, opis=opis, sifra_namjene="SALA", datum=date.today(), ) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'}) @router.get("/putni-nalog/{nalog_id}/audit") def putni_audit(nalog_id: int, limit: int = 100, authorization: Optional[str] = Header(None)): user = _resolve_user(authorization) 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_view_putni_nalog(user, pn): raise HTTPException(403, "Nemate ovlasti") return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)} @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") user = _resolve_user(authorization) # Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub if user and not is_pgz_admin(user): if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id: raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub") 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"] audit_putni(user, row["id"], "create", field="status", new=f"draft (€{row.get('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={}), authorization: Optional[str] = Header(None)): user = _resolve_user(authorization) approved_by = body.get("approved_by") or (user.get("id") if user else None) if approved_by == 0 or (user and user.get("_synthetic")): approved_by = None # admin token nema realnog user_id u DB with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT er.*, k.savez_id 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,)) pn = cur.fetchone() if not pn: raise HTTPException(404, "Putni nalog ne postoji") if user and not can_approve_putni_nalog(user, pn): raise HTTPException(403, "Nemate ovlasti odobriti") 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() audit_putni(user, nalog_id, "approve", field="status", old=pn.get("status"), new="odobren") notif = notify_pn_approved({**pn, "status": "odobren"}) return {"ok": True, "putni_nalog": row, "notification": notif} # R6.2 — PUT alias za simetriju s briefom @router.put("/putni-nalog/{nalog_id}/odobri") def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), authorization: Optional[str] = Header(None)): return odobriti_putni_nalog(nalog_id, body, authorization) @router.put("/putni-nalog/{nalog_id}/odbij") def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), authorization: Optional[str] = Header(None)): return odbij_putni_nalog(nalog_id, body, authorization) @router.put("/putni-nalog/{nalog_id}/isplati") def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), authorization: Optional[str] = Header(None)): return isplati_putni_nalog(nalog_id, body, authorization) @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}