CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6)
|
||||
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
|
||||
# Date: 2026-05-04
|
||||
# Description: Centralizirane provjere ovlasti za račune i putne naloge.
|
||||
#
|
||||
# Uloge (pgz_sport.roles):
|
||||
# super_admin, pgz_admin, savez_admin, klub_admin, klub_user, clan, viewer
|
||||
#
|
||||
# Korisnik (dict iz auth_v2.get_current_user) ima: id, user_type, klub_id, savez_id.
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Dict, Any
|
||||
import psycopg2, psycopg2.extras
|
||||
|
||||
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
|
||||
password="R1net2026!SecureDB#v7")
|
||||
|
||||
|
||||
def _db():
|
||||
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||
|
||||
|
||||
# ── role helpers ──────────────────────────────────────────────────────
|
||||
def is_super(user) -> bool:
|
||||
return bool(user) and user.get("user_type") == "super_admin"
|
||||
|
||||
def is_pgz_admin(user) -> bool:
|
||||
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
|
||||
|
||||
def is_savez_admin(user) -> bool:
|
||||
return bool(user) and user.get("user_type") == "savez_admin"
|
||||
|
||||
def is_klub_admin(user) -> bool:
|
||||
return bool(user) and user.get("user_type") == "klub_admin"
|
||||
|
||||
def is_klub_user(user) -> bool:
|
||||
return bool(user) and user.get("user_type") in ("klub_admin", "klub_user")
|
||||
|
||||
|
||||
def klub_savez(klub_id: int) -> Optional[int]:
|
||||
"""Vraća savez_id kojem klub pripada (preko klubovi.savez_id ili user_klub_links)."""
|
||||
if not klub_id: return None
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (klub_id,))
|
||||
r = cur.fetchone()
|
||||
return r["savez_id"] if r else None
|
||||
|
||||
|
||||
def user_can_see_klub(user, klub_id: Optional[int]) -> bool:
|
||||
"""Tko može VIDJETI klub: super, pgz, savez (ako klub u savezu), klub_admin/user (ako vlastiti klub)."""
|
||||
if not user or not klub_id:
|
||||
return is_pgz_admin(user)
|
||||
if is_pgz_admin(user):
|
||||
return True
|
||||
if is_klub_user(user):
|
||||
return user.get("klub_id") == klub_id
|
||||
if is_savez_admin(user):
|
||||
return klub_savez(klub_id) == user.get("savez_id")
|
||||
return False
|
||||
|
||||
|
||||
# ── INVOICES ──────────────────────────────────────────────────────────
|
||||
def can_view_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||
"""Pgž admin vidi sve. Savez admin svoje saveze. Klub admin/user vlastiti klub."""
|
||||
if not invoice: return False
|
||||
if is_pgz_admin(user): return True
|
||||
return user_can_see_klub(user, invoice.get("klub_id"))
|
||||
|
||||
|
||||
def can_edit_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Edit (izmjena polja, korekcija OCR-a) — samo klub_admin vlastitog kluba ILI pgz_admin.
|
||||
Savez admin može komentirati, ali NE editirati.
|
||||
Plaćeni računi su read-only osim za pgz_admin.
|
||||
"""
|
||||
if not invoice: return False
|
||||
if is_pgz_admin(user): return True
|
||||
if invoice.get("payment_status") in ("paid",):
|
||||
return False
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == invoice.get("klub_id")
|
||||
return False
|
||||
|
||||
|
||||
def can_pay_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||
"""Označi kao plaćen — klub_admin vlastitog kluba ili pgz_admin."""
|
||||
if not invoice: return False
|
||||
if is_pgz_admin(user): return True
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == invoice.get("klub_id")
|
||||
return False
|
||||
|
||||
|
||||
def can_comment_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||
"""Komentirati može pgz_admin, savez_admin (svog saveza) i klub_admin (svog kluba)."""
|
||||
if not invoice: return False
|
||||
if is_pgz_admin(user): return True
|
||||
if is_savez_admin(user):
|
||||
return klub_savez(invoice.get("klub_id")) == user.get("savez_id")
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == invoice.get("klub_id")
|
||||
return False
|
||||
|
||||
|
||||
def invoice_actions(user, invoice: Dict[str, Any]) -> Dict[str, bool]:
|
||||
"""UI hint — koji gumbi su dostupni."""
|
||||
return {
|
||||
"view": can_view_invoice(user, invoice),
|
||||
"edit": can_edit_invoice(user, invoice),
|
||||
"pay": can_pay_invoice(user, invoice) and invoice.get("payment_status") != "paid",
|
||||
"comment": can_comment_invoice(user, invoice),
|
||||
"delete": is_pgz_admin(user),
|
||||
}
|
||||
|
||||
|
||||
# ── PUTNI NALOZI ──────────────────────────────────────────────────────
|
||||
def can_view_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||
if not pn: return False
|
||||
if is_pgz_admin(user): return True
|
||||
if is_savez_admin(user):
|
||||
return klub_savez(pn.get("klub_id")) == user.get("savez_id")
|
||||
if is_klub_user(user):
|
||||
if user.get("klub_id") == pn.get("klub_id"):
|
||||
return True
|
||||
# Voditelj vidi svoj
|
||||
return pn.get("user_id") == user.get("id") if user else False
|
||||
|
||||
|
||||
def can_edit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||
"""Edit dozvoljen samo na statusima draft/odbijen, i samo voditelju ili klub_admin/pgz."""
|
||||
if not pn: return False
|
||||
status = (pn.get("status") or "draft").lower()
|
||||
if status not in ("draft", "odbijen"):
|
||||
return is_pgz_admin(user)
|
||||
if is_pgz_admin(user): return True
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == pn.get("klub_id")
|
||||
# Voditelj
|
||||
return pn.get("user_id") == user.get("id") if user else False
|
||||
|
||||
|
||||
def can_submit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||
"""Slanje (draft → poslan) — voditelj ili klub_admin."""
|
||||
if not pn: return False
|
||||
if (pn.get("status") or "draft").lower() not in ("draft",):
|
||||
return False
|
||||
if is_pgz_admin(user): return True
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == pn.get("klub_id")
|
||||
return pn.get("user_id") == user.get("id") if user else False
|
||||
|
||||
|
||||
def can_approve_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||
"""Odobravanje (poslan → odobren ili odbijen) — klub_admin svog kluba ili pgz_admin."""
|
||||
if not pn: return False
|
||||
if (pn.get("status") or "").lower() not in ("poslan", "submitted", "draft"):
|
||||
return False
|
||||
if is_pgz_admin(user): return True
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == pn.get("klub_id")
|
||||
return False
|
||||
|
||||
|
||||
def can_pay_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||
"""Isplata (odobren → isplaćen) — klub_admin ili pgz_admin."""
|
||||
if not pn: return False
|
||||
if (pn.get("status") or "").lower() not in ("odobren", "approved", "zatvoren"):
|
||||
return False
|
||||
if is_pgz_admin(user): return True
|
||||
if is_klub_admin(user):
|
||||
return user.get("klub_id") == pn.get("klub_id")
|
||||
return False
|
||||
|
||||
|
||||
def putni_nalog_actions(user, pn: Dict[str, Any]) -> Dict[str, bool]:
|
||||
return {
|
||||
"view": can_view_putni_nalog(user, pn),
|
||||
"edit": can_edit_putni_nalog(user, pn),
|
||||
"submit": can_submit_putni_nalog(user, pn),
|
||||
"approve": can_approve_putni_nalog(user, pn),
|
||||
"reject": can_approve_putni_nalog(user, pn),
|
||||
"pay": can_pay_putni_nalog(user, pn),
|
||||
"delete": is_pgz_admin(user),
|
||||
}
|
||||
|
||||
|
||||
# ── Audit logging helper ──────────────────────────────────────────────
|
||||
def audit_invoice(user, invoice_id: int, op: str, field: Optional[str] = None,
|
||||
old=None, new=None):
|
||||
try:
|
||||
with _db() as c:
|
||||
c.cursor().execute(
|
||||
"""INSERT INTO pgz_sport.audit_log
|
||||
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
|
||||
stara_vrijednost, nova_vrijednost)
|
||||
VALUES ('pgz_sport.invoices', %s, %s, %s, %s, %s, %s)""",
|
||||
(op, invoice_id,
|
||||
(user.get("email") if user else "anon"),
|
||||
field,
|
||||
None if old is None else str(old)[:500],
|
||||
None if new is None else str(new)[:500]),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def audit_putni(user, pn_id: int, op: str, field: Optional[str] = None,
|
||||
old=None, new=None):
|
||||
try:
|
||||
with _db() as c:
|
||||
c.cursor().execute(
|
||||
"""INSERT INTO pgz_sport.audit_log
|
||||
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
|
||||
stara_vrijednost, nova_vrijednost)
|
||||
VALUES ('pgz_sport.expense_reports', %s, %s, %s, %s, %s, %s)""",
|
||||
(op, pn_id,
|
||||
(user.get("email") if user else "anon"),
|
||||
field,
|
||||
None if old is None else str(old)[:500],
|
||||
None if new is None else str(new)[:500]),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def fetch_audit(table: str, record_id: int, limit: int = 50):
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"""SELECT timestamp, operacija, korisnik, promijenjeno_polje,
|
||||
stara_vrijednost, nova_vrijednost
|
||||
FROM pgz_sport.audit_log
|
||||
WHERE tablica=%s AND record_id=%s
|
||||
ORDER BY timestamp DESC LIMIT %s""",
|
||||
(table, record_id, limit),
|
||||
)
|
||||
return cur.fetchall()
|
||||
@@ -0,0 +1,724 @@
|
||||
#!/usr/bin/env python3
|
||||
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
|
||||
# Author: Damir Radulić <damir@rinet.one> / 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
|
||||
|
||||
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")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
|
||||
|
||||
@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}")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
|
||||
|
||||
@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")
|
||||
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
user = _resolve_user(authorization)
|
||||
approved_by = body.get("approved_by") or (user.get("id") if user else None)
|
||||
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")
|
||||
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}
|
||||
Reference in New Issue
Block a user