Files
pgz-sport/routers/erp_full_router.py
T
2026-05-05 18:35:01 +02:00

1976 lines
86 KiB
Python

#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.2.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/erp_full_router.py
# Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga,
# partneri, ulazni/izlazni računi (+ FINA e-Račun XML import),
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export,
# invoice_uploads (OCR), expense_reports (Putni nalozi), payments.
# v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E).
# v1.2.0 (2026-05-05): + Full CRUD za Putni nalozi (/putni-nalozi + alias
# /expense-reports): GET/POST/PATCH/DELETE + status workflow
# (draft → poslano → odobreno/odbijeno → isplaceno) + auto cost_total
# + approved_at/paid_at na prijelazima (Agent 2).
# Mount: /api/v2/erp/*
# ═══════════════════════════════════════════════════════════════════
from __future__ import annotations
import csv
import hashlib
import io
import os
import re
import uuid
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from io import BytesIO, StringIO
from pathlib import Path
from typing import Optional, List
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape as xml_escape
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
from fastapi.responses import StreamingResponse, Response
from pydantic import BaseModel, Field
# ── Upload destination (relative to web root /uploads/...) ──────────
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
INVOICE_UPLOAD_DIR = UPLOAD_BASE / "invoices"
INVOICE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
router = APIRouter(prefix="/api/v2/erp", tags=["erp_full"])
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
def db_query(sql: str, params=()):
with psycopg2.connect(**DB) as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
if cur.description:
return cur.fetchall()
return []
def db_one(sql: str, params=()):
rows = db_query(sql, params)
return rows[0] if rows else None
def db_exec(sql: str, params=(), returning: bool = False):
with psycopg2.connect(**DB) as c:
cur = c.cursor()
cur.execute(sql, params)
if returning and cur.description:
r = cur.fetchone()
c.commit()
return r[0] if r else None
c.commit()
return None
def db_tx(operations):
"""operations: callable(cursor) → result. Single transaction."""
with psycopg2.connect(**DB) as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
try:
result = operations(cur)
c.commit()
return result
except Exception:
c.rollback()
raise
def _f(v, default=0):
"""Safe float/decimal conversion."""
if v is None or v == "":
return Decimal(str(default))
if isinstance(v, Decimal):
return v
return Decimal(str(v))
def _konto_id(cur, sifra: str) -> int:
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", (sifra,))
r = cur.fetchone()
if not r:
raise HTTPException(500, f"Konto '{sifra}' ne postoji u kontnom planu")
return r["id"] if isinstance(r, dict) else r[0]
# ═══════════════════════════════════════════════════════════════════
# 1) KONTNI PLAN
# ═══════════════════════════════════════════════════════════════════
class KontoIn(BaseModel):
sifra: str
naziv: str
klasa: int
vrsta: str
parent_id: Optional[int] = None
aktivan: bool = True
napomena: Optional[str] = None
@router.get("/kontni-plan")
def kontni_plan_list(klasa: Optional[int] = None, vrsta: Optional[str] = None,
aktivan: Optional[bool] = None, q: Optional[str] = None):
where = ["1=1"]
params: list = []
if klasa is not None:
where.append("klasa=%s"); params.append(klasa)
if vrsta:
where.append("vrsta=%s"); params.append(vrsta)
if aktivan is not None:
where.append("aktivan=%s"); params.append(aktivan)
if q:
where.append("(sifra ILIKE %s OR naziv ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%"])
rows = db_query(
f"SELECT * FROM pgz_sport.kontni_plan WHERE {' AND '.join(where)} "
"ORDER BY sifra", tuple(params))
return {"count": len(rows), "rows": rows}
@router.post("/kontni-plan")
def kontni_plan_create(body: KontoIn):
rid = db_exec(
"INSERT INTO pgz_sport.kontni_plan (sifra, naziv, klasa, vrsta, parent_id, aktivan, napomena) "
"VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id",
(body.sifra, body.naziv, body.klasa, body.vrsta, body.parent_id, body.aktivan, body.napomena),
returning=True)
return {"ok": True, "id": rid}
@router.put("/kontni-plan/{kid}")
def kontni_plan_update(kid: int, body: KontoIn):
db_exec("UPDATE pgz_sport.kontni_plan SET sifra=%s, naziv=%s, klasa=%s, vrsta=%s, "
"parent_id=%s, aktivan=%s, napomena=%s WHERE id=%s",
(body.sifra, body.naziv, body.klasa, body.vrsta, body.parent_id, body.aktivan,
body.napomena, kid))
return {"ok": True}
@router.delete("/kontni-plan/{kid}")
def kontni_plan_delete(kid: int):
used = db_one("SELECT 1 FROM pgz_sport.knjizenja WHERE konto_id=%s LIMIT 1", (kid,))
if used:
db_exec("UPDATE pgz_sport.kontni_plan SET aktivan=false WHERE id=%s", (kid,))
return {"ok": True, "soft_delete": True}
db_exec("DELETE FROM pgz_sport.kontni_plan WHERE id=%s", (kid,))
return {"ok": True, "hard_delete": True}
# ═══════════════════════════════════════════════════════════════════
# 2) PARTNERI
# ═══════════════════════════════════════════════════════════════════
class PartnerIn(BaseModel):
oib: Optional[str] = None
naziv: str
vrsta: str = "oba"
iban: Optional[str] = None
adresa: Optional[str] = None
grad: Optional[str] = None
drzava: str = "HR"
email: Optional[str] = None
telefon: Optional[str] = None
aktivan: bool = True
napomena: Optional[str] = None
@router.get("/partneri")
def partneri_list(vrsta: Optional[str] = None, oib: Optional[str] = None,
q: Optional[str] = None, limit: int = 200):
where = ["1=1"]
params: list = []
if vrsta:
where.append("vrsta=%s"); params.append(vrsta)
if oib:
where.append("oib=%s"); params.append(oib)
if q:
where.append("(naziv ILIKE %s OR oib ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%"])
params.append(limit)
rows = db_query(
f"SELECT * FROM pgz_sport.partneri WHERE {' AND '.join(where)} "
"ORDER BY naziv LIMIT %s", tuple(params))
return {"count": len(rows), "rows": rows}
@router.post("/partneri")
def partner_create(body: PartnerIn):
rid = db_exec(
"INSERT INTO pgz_sport.partneri (oib, naziv, vrsta, iban, adresa, grad, drzava, "
"email, telefon, aktivan, napomena) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
"RETURNING id",
(body.oib, body.naziv, body.vrsta, body.iban, body.adresa, body.grad,
body.drzava, body.email, body.telefon, body.aktivan, body.napomena),
returning=True)
return {"ok": True, "id": rid}
@router.put("/partneri/{pid}")
def partner_update(pid: int, body: PartnerIn):
db_exec(
"UPDATE pgz_sport.partneri SET oib=%s, naziv=%s, vrsta=%s, iban=%s, adresa=%s, "
"grad=%s, drzava=%s, email=%s, telefon=%s, aktivan=%s, napomena=%s WHERE id=%s",
(body.oib, body.naziv, body.vrsta, body.iban, body.adresa, body.grad, body.drzava,
body.email, body.telefon, body.aktivan, body.napomena, pid))
return {"ok": True}
@router.get("/partneri/{pid}/saldo")
def partner_saldo(pid: int):
info = db_one("SELECT * FROM pgz_sport.v_partner_saldo WHERE partner_id=%s", (pid,))
stavke = db_query(
"SELECT k.id, dz.datum, dz.opis, kp.sifra AS konto_sifra, kp.naziv AS konto, "
"k.duguje, k.potrazuje "
"FROM pgz_sport.knjizenja k "
"JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
"JOIN pgz_sport.kontni_plan kp ON kp.id=k.konto_id "
"WHERE k.partner_id=%s ORDER BY dz.datum DESC, k.id DESC LIMIT 50",
(pid,))
return {"info": info, "stavke": stavke}
# ═══════════════════════════════════════════════════════════════════
# 3) DNEVNIK + GLAVNA KNJIGA
# ═══════════════════════════════════════════════════════════════════
class KnjizenjeLine(BaseModel):
konto_id: Optional[int] = None
konto_sifra: Optional[str] = None
partner_id: Optional[int] = None
duguje: float = 0
potrazuje: float = 0
opis: Optional[str] = None
class DnevnikIn(BaseModel):
datum: str
opis: Optional[str] = None
dokument_tip: str = "rucno"
dokument_id: Optional[int] = None
stavke: List[KnjizenjeLine]
@router.get("/dnevnik")
def dnevnik_list(godina: Optional[int] = None, datum_od: Optional[str] = None,
datum_do: Optional[str] = None, dokument_tip: Optional[str] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if godina:
where.append("godina=%s"); params.append(godina)
if datum_od:
where.append("datum>=%s"); params.append(datum_od)
if datum_do:
where.append("datum<=%s"); params.append(datum_do)
if dokument_tip:
where.append("dokument_tip=%s"); params.append(dokument_tip)
params.append(limit)
rows = db_query(
f"SELECT dz.*, "
f" (SELECT COUNT(*) FROM pgz_sport.knjizenja k WHERE k.dnevnik_id=dz.id) AS broj_stavki, "
f" (SELECT COALESCE(SUM(duguje),0) FROM pgz_sport.knjizenja WHERE dnevnik_id=dz.id) AS uk_duguje "
f"FROM pgz_sport.dnevnik_zapisa dz "
f"WHERE {' AND '.join(where)} ORDER BY datum DESC, id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.get("/dnevnik/{did}")
def dnevnik_detail(did: int):
head = db_one("SELECT * FROM pgz_sport.dnevnik_zapisa WHERE id=%s", (did,))
if not head:
raise HTTPException(404, "Dnevnik zapis ne postoji")
stavke = db_query(
"SELECT k.*, kp.sifra AS konto_sifra, kp.naziv AS konto_naziv, "
"p.naziv AS partner_naziv "
"FROM pgz_sport.knjizenja k "
"JOIN pgz_sport.kontni_plan kp ON kp.id=k.konto_id "
"LEFT JOIN pgz_sport.partneri p ON p.id=k.partner_id "
"WHERE k.dnevnik_id=%s ORDER BY k.id", (did,))
return {"head": head, "stavke": stavke}
@router.post("/dnevnik")
def dnevnik_create(body: DnevnikIn):
if not body.stavke or len(body.stavke) < 2:
raise HTTPException(400, "Dnevnik mora imati barem 2 stavke")
sum_d = sum(_f(s.duguje) for s in body.stavke)
sum_p = sum(_f(s.potrazuje) for s in body.stavke)
if sum_d != sum_p:
raise HTTPException(400, f"Dnevnik nije u balansu: duguje={sum_d}, potrazuje={sum_p}")
if sum_d == 0:
raise HTTPException(400, "Dnevnik ne smije biti nula")
def op(cur):
cur.execute("SELECT COALESCE(MAX(redni_broj),0)+1 AS rb FROM pgz_sport.dnevnik_zapisa "
"WHERE EXTRACT(YEAR FROM datum)=EXTRACT(YEAR FROM %s::date)", (body.datum,))
rb = cur.fetchone()["rb"]
cur.execute(
"INSERT INTO pgz_sport.dnevnik_zapisa (datum, opis, dokument_tip, dokument_id, "
"redni_broj) VALUES (%s,%s,%s,%s,%s) RETURNING id",
(body.datum, body.opis, body.dokument_tip, body.dokument_id, rb))
did = cur.fetchone()["id"]
for s in body.stavke:
kid = s.konto_id
if not kid and s.konto_sifra:
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", (s.konto_sifra,))
r = cur.fetchone()
if not r:
raise HTTPException(400, f"Konto '{s.konto_sifra}' ne postoji")
kid = r["id"]
if not kid:
raise HTTPException(400, "Stavka mora imati konto_id ili konto_sifra")
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, "
"duguje, potrazuje, opis) VALUES (%s,%s,%s,%s,%s,%s)",
(did, kid, s.partner_id, _f(s.duguje), _f(s.potrazuje), s.opis))
return did
did = db_tx(op)
return {"ok": True, "id": did}
@router.post("/dnevnik/{did}/storno")
def dnevnik_storno(did: int):
head = db_one("SELECT * FROM pgz_sport.dnevnik_zapisa WHERE id=%s", (did,))
if not head:
raise HTTPException(404, "Dnevnik zapis ne postoji")
if head.get("storno_od_id"):
raise HTTPException(400, "Storno zapis se ne može storinati")
def op(cur):
cur.execute("SELECT * FROM pgz_sport.knjizenja WHERE dnevnik_id=%s", (did,))
stavke = cur.fetchall()
cur.execute(
"INSERT INTO pgz_sport.dnevnik_zapisa (datum, opis, dokument_tip, dokument_id, "
"redni_broj, storno_od_id) VALUES (CURRENT_DATE, %s, 'storno', %s, "
"(SELECT COALESCE(MAX(redni_broj),0)+1 FROM pgz_sport.dnevnik_zapisa "
" WHERE EXTRACT(YEAR FROM datum)=EXTRACT(YEAR FROM CURRENT_DATE)), %s) RETURNING id",
(f"STORNO #{did}: {head.get('opis') or ''}", did, did))
sdid = cur.fetchone()["id"]
for s in stavke:
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, "
"duguje, potrazuje, opis) VALUES (%s,%s,%s,%s,%s,%s)",
(sdid, s["konto_id"], s["partner_id"], s["potrazuje"], s["duguje"],
f"STORNO: {s.get('opis') or ''}"))
return sdid
sdid = db_tx(op)
return {"ok": True, "storno_id": sdid}
@router.get("/glavna-knjiga")
def glavna_knjiga(konto_id: Optional[int] = None, klasa: Optional[int] = None,
datum_od: Optional[str] = None, datum_do: Optional[str] = None):
where = ["1=1"]
params: list = []
if klasa is not None:
where.append("kp.klasa=%s"); params.append(klasa)
if konto_id:
where.append("kp.id=%s"); params.append(konto_id)
sql = (
"SELECT kp.id AS konto_id, kp.sifra, kp.naziv, kp.klasa, kp.vrsta, "
" COALESCE(SUM(k.duguje),0) AS sum_duguje, "
" COALESCE(SUM(k.potrazuje),0) AS sum_potrazuje, "
" COALESCE(SUM(k.duguje),0) - COALESCE(SUM(k.potrazuje),0) AS saldo, "
" COUNT(k.id) AS broj_stavki "
"FROM pgz_sport.kontni_plan kp "
"LEFT JOIN pgz_sport.knjizenja k ON k.konto_id=kp.id "
"LEFT JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
)
if datum_od:
sql += " AND dz.datum>=%s "; params.insert(0, datum_od)
if datum_do:
sql += " AND dz.datum<=%s "; params.insert(0, datum_do)
sql = sql.replace("LEFT JOIN pgz_sport.dnevnik_zapisa", "LEFT JOIN pgz_sport.dnevnik_zapisa")
sql += f" WHERE {' AND '.join(where)} GROUP BY kp.id, kp.sifra, kp.naziv, kp.klasa, kp.vrsta ORDER BY kp.sifra"
rows = db_query(sql, tuple(params))
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# 4) RAČUNI ULAZNI / IZLAZNI + STAVKE + AUTO-KNJIŽENJE
# ═══════════════════════════════════════════════════════════════════
class RacunStavkaIn(BaseModel):
naziv: str
kolicina: float = 1
jed_mjera: str = "kom"
cijena_jed: float = 0
popust_pct: float = 0
pdv_pct: float = 25
class RacunIn(BaseModel):
broj: Optional[str] = None
partner_id: int
klub_id: Optional[int] = None
datum_izdavanja: str
datum_dospjeca: Optional[str] = None
napomena: Optional[str] = None
stavke: List[RacunStavkaIn] = []
status: str = "nacrt" # nacrt|knjizen
def _calc_stavka(s: RacunStavkaIn) -> dict:
qty = _f(s.kolicina, 1)
cij = _f(s.cijena_jed, 0)
pop = _f(s.popust_pct, 0)
pdv = _f(s.pdv_pct, 25)
bruto_pre = qty * cij
popust = bruto_pre * pop / Decimal(100)
neto = bruto_pre - popust
iznos_pdv = neto * pdv / Decimal(100)
brutto = neto + iznos_pdv
return {
"naziv": s.naziv, "kolicina": qty, "jed_mjera": s.jed_mjera,
"cijena_jed": cij, "popust_pct": pop, "pdv_pct": pdv,
"iznos_neto": neto.quantize(Decimal("0.01")),
"iznos_pdv": iznos_pdv.quantize(Decimal("0.01")),
"iznos_brutto": brutto.quantize(Decimal("0.01")),
}
def _book_racun_ulazni(cur, rid: int, datum: str, broj: str,
partner_id: int, neto: Decimal, pdv: Decimal, brutto: Decimal):
"""Auto-knjiženje ulaznog računa: 4xx + 1400 / 2200."""
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", ('44',))
konto_rashod = cur.fetchone()["id"]
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", ('1400',))
konto_pretporez = cur.fetchone()["id"]
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", ('2200',))
konto_dobavljac = cur.fetchone()["id"]
cur.execute(
"INSERT INTO pgz_sport.dnevnik_zapisa (datum, opis, dokument_tip, dokument_id, redni_broj) "
"VALUES (%s, %s, 'racun_u', %s, "
" (SELECT COALESCE(MAX(redni_broj),0)+1 FROM pgz_sport.dnevnik_zapisa "
" WHERE EXTRACT(YEAR FROM datum)=EXTRACT(YEAR FROM %s::date))) RETURNING id",
(datum, f"Ulazni račun {broj or ''}", rid, datum))
did = cur.fetchone()["id"]
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,%s,0,'Trošak iz ulaznog računa')",
(did, konto_rashod, partner_id, neto))
if pdv > 0:
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,%s,0,'Pretporez')",
(did, konto_pretporez, partner_id, pdv))
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,0,%s,'Obveza prema dobavljaču')",
(did, konto_dobavljac, partner_id, brutto))
cur.execute("UPDATE pgz_sport.racuni_ulazni SET dnevnik_id=%s WHERE id=%s", (did, rid))
return did
def _book_racun_izlazni(cur, rid: int, datum: str, broj: str,
partner_id: int, neto: Decimal, pdv: Decimal, brutto: Decimal):
"""Auto-knjiženje izlaznog računa: 1200 / 7xx + 2500."""
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", ('1200',))
konto_kupac = cur.fetchone()["id"]
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", ('74',))
konto_prihod = cur.fetchone()["id"]
cur.execute("SELECT id FROM pgz_sport.kontni_plan WHERE sifra=%s", ('2500',))
konto_pdv = cur.fetchone()["id"]
cur.execute(
"INSERT INTO pgz_sport.dnevnik_zapisa (datum, opis, dokument_tip, dokument_id, redni_broj) "
"VALUES (%s, %s, 'racun_i', %s, "
" (SELECT COALESCE(MAX(redni_broj),0)+1 FROM pgz_sport.dnevnik_zapisa "
" WHERE EXTRACT(YEAR FROM datum)=EXTRACT(YEAR FROM %s::date))) RETURNING id",
(datum, f"Izlazni račun {broj or ''}", rid, datum))
did = cur.fetchone()["id"]
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,%s,0,'Potraživanje od kupca')",
(did, konto_kupac, partner_id, brutto))
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,0,%s,'Prihod')",
(did, konto_prihod, partner_id, neto))
if pdv > 0:
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, partner_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,0,%s,'PDV')",
(did, konto_pdv, partner_id, pdv))
cur.execute("UPDATE pgz_sport.racuni_izlazni SET dnevnik_id=%s WHERE id=%s", (did, rid))
return did
def _create_racun(tip: str, body: RacunIn):
if tip not in ("ulazni", "izlazni"):
raise HTTPException(400, "Nepoznat tip računa")
table = f"racuni_{tip}"
stavke_calc = [_calc_stavka(s) for s in body.stavke]
neto = sum((s["iznos_neto"] for s in stavke_calc), Decimal(0))
pdv = sum((s["iznos_pdv"] for s in stavke_calc), Decimal(0))
brutto = sum((s["iznos_brutto"] for s in stavke_calc), Decimal(0))
def op(cur):
cur.execute(
f"INSERT INTO pgz_sport.{table} (broj, partner_id, klub_id, datum_izdavanja, "
f"datum_dospjeca, iznos_neto, iznos_pdv, iznos_brutto, status, napomena) "
f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
(body.broj, body.partner_id, body.klub_id, body.datum_izdavanja,
body.datum_dospjeca, neto, pdv, brutto, body.status, body.napomena))
rid = cur.fetchone()["id"]
for s in stavke_calc:
cur.execute(
"INSERT INTO pgz_sport.racun_stavke (racun_tip, racun_id, naziv, kolicina, "
"jed_mjera, cijena_jed, popust_pct, pdv_pct, iznos_neto, iznos_pdv, iznos_brutto) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(tip, rid, s["naziv"], s["kolicina"], s["jed_mjera"], s["cijena_jed"],
s["popust_pct"], s["pdv_pct"], s["iznos_neto"], s["iznos_pdv"], s["iznos_brutto"]))
did = None
if body.status == "knjizen":
if tip == "ulazni":
did = _book_racun_ulazni(cur, rid, body.datum_izdavanja, body.broj or "",
body.partner_id, neto, pdv, brutto)
else:
did = _book_racun_izlazni(cur, rid, body.datum_izdavanja, body.broj or "",
body.partner_id, neto, pdv, brutto)
return rid, did
rid, did = db_tx(op)
return {"ok": True, "id": rid, "dnevnik_id": did,
"iznos_neto": float(neto), "iznos_pdv": float(pdv), "iznos_brutto": float(brutto)}
def _list_racuni(tip: str, partner_id: Optional[int], status: Optional[str],
godina: Optional[int], limit: int):
table = f"racuni_{tip}"
where = ["1=1"]
params: list = []
if partner_id:
where.append("r.partner_id=%s"); params.append(partner_id)
if status:
where.append("r.status=%s"); params.append(status)
if godina:
where.append("EXTRACT(YEAR FROM r.datum_izdavanja)=%s"); params.append(godina)
params.append(limit)
rows = db_query(
f"SELECT r.*, p.naziv AS partner_naziv, p.oib AS partner_oib "
f"FROM pgz_sport.{table} r LEFT JOIN pgz_sport.partneri p ON p.id=r.partner_id "
f"WHERE {' AND '.join(where)} ORDER BY r.datum_izdavanja DESC, r.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.get("/racuni/ulazni")
def racuni_ulazni_list(partner_id: Optional[int] = None, status: Optional[str] = None,
godina: Optional[int] = None, limit: int = 200):
return _list_racuni("ulazni", partner_id, status, godina, limit)
@router.get("/racuni/izlazni")
def racuni_izlazni_list(partner_id: Optional[int] = None, status: Optional[str] = None,
godina: Optional[int] = None, limit: int = 200):
return _list_racuni("izlazni", partner_id, status, godina, limit)
@router.get("/racuni/{tip}/{rid}")
def racun_detail(tip: str, rid: int):
if tip not in ("ulazni", "izlazni"):
raise HTTPException(400, "Nepoznat tip")
head = db_one(
f"SELECT r.*, p.naziv AS partner_naziv, p.oib AS partner_oib "
f"FROM pgz_sport.racuni_{tip} r LEFT JOIN pgz_sport.partneri p ON p.id=r.partner_id "
f"WHERE r.id=%s", (rid,))
if not head:
raise HTTPException(404, "Račun ne postoji")
stavke = db_query(
"SELECT * FROM pgz_sport.racun_stavke WHERE racun_tip=%s AND racun_id=%s ORDER BY id",
(tip, rid))
return {"head": head, "stavke": stavke}
@router.post("/racuni/ulazni")
def racun_ulazni_create(body: RacunIn):
return _create_racun("ulazni", body)
@router.post("/racuni/izlazni")
def racun_izlazni_create(body: RacunIn):
return _create_racun("izlazni", body)
@router.post("/racuni/{tip}/{rid}/knjizi")
def racun_knjizi(tip: str, rid: int):
if tip not in ("ulazni", "izlazni"):
raise HTTPException(400, "Nepoznat tip")
head = db_one(f"SELECT * FROM pgz_sport.racuni_{tip} WHERE id=%s", (rid,))
if not head:
raise HTTPException(404, "Račun ne postoji")
if head["status"] == "knjizen":
raise HTTPException(400, "Račun je već knjižen")
def op(cur):
if tip == "ulazni":
did = _book_racun_ulazni(cur, rid, str(head["datum_izdavanja"]), head["broj"] or "",
head["partner_id"], _f(head["iznos_neto"]),
_f(head["iznos_pdv"]), _f(head["iznos_brutto"]))
else:
did = _book_racun_izlazni(cur, rid, str(head["datum_izdavanja"]), head["broj"] or "",
head["partner_id"], _f(head["iznos_neto"]),
_f(head["iznos_pdv"]), _f(head["iznos_brutto"]))
cur.execute(f"UPDATE pgz_sport.racuni_{tip} SET status='knjizen' WHERE id=%s", (rid,))
return did
did = db_tx(op)
return {"ok": True, "dnevnik_id": did}
# ═══════════════════════════════════════════════════════════════════
# 5) E-RAČUN XML IMPORT (FINA UBL 2.1)
# ═══════════════════════════════════════════════════════════════════
def _findtxt(elem, *paths):
"""Try multiple xpath-ish lookups, ignore namespaces."""
for path in paths:
for el in elem.iter():
tag = el.tag.split("}")[-1]
if tag == path and el.text and el.text.strip():
return el.text.strip()
return None
@router.post("/racuni/eracun-import")
async def eracun_import(file: UploadFile = File(...)):
raw = await file.read()
try:
root = ET.fromstring(raw)
except ET.ParseError as e:
raise HTTPException(400, f"Nevažeći XML: {e}")
invoice_id = _findtxt(root, "ID")
issue_date = _findtxt(root, "IssueDate")
due_date = _findtxt(root, "DueDate")
supplier_name = None
supplier_oib = None
for cust in root.iter():
if cust.tag.split("}")[-1] == "AccountingSupplierParty":
supplier_name = _findtxt(cust, "RegistrationName", "Name")
supplier_oib = _findtxt(cust, "CompanyID")
break
total_neto = _findtxt(root, "TaxExclusiveAmount", "LineExtensionAmount") or "0"
total_pdv = _findtxt(root, "TaxAmount") or "0"
total_brutto = _findtxt(root, "TaxInclusiveAmount", "PayableAmount") or "0"
if not supplier_oib:
raise HTTPException(400, "Nije pronađen OIB dobavljača u e-Računu")
def op(cur):
cur.execute("SELECT id FROM pgz_sport.partneri WHERE oib=%s", (supplier_oib,))
r = cur.fetchone()
if r:
pid = r["id"]
else:
cur.execute(
"INSERT INTO pgz_sport.partneri (oib, naziv, vrsta, drzava) "
"VALUES (%s, %s, 'dobavljac', 'HR') RETURNING id",
(supplier_oib, supplier_name or f"Dobavljač {supplier_oib}"))
pid = cur.fetchone()["id"]
cur.execute(
"INSERT INTO pgz_sport.racuni_ulazni (broj, partner_id, datum_izdavanja, "
"datum_dospjeca, iznos_neto, iznos_pdv, iznos_brutto, status, eracun_xml) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,'nacrt',%s) RETURNING id",
(invoice_id, pid, issue_date or date.today().isoformat(),
due_date, _f(total_neto), _f(total_pdv), _f(total_brutto),
raw.decode("utf-8", errors="replace")))
rid = cur.fetchone()["id"]
# InvoiceLine parse
for line in root.iter():
if line.tag.split("}")[-1] != "InvoiceLine":
continue
naz = _findtxt(line, "Name", "Description") or "stavka"
qty = _findtxt(line, "InvoicedQuantity") or "1"
price = _findtxt(line, "PriceAmount") or "0"
line_neto = _findtxt(line, "LineExtensionAmount") or "0"
cur.execute(
"INSERT INTO pgz_sport.racun_stavke (racun_tip, racun_id, naziv, kolicina, "
"cijena_jed, iznos_neto, iznos_pdv, iznos_brutto) "
"VALUES ('ulazni',%s,%s,%s,%s,%s,0,%s)",
(rid, naz, _f(qty), _f(price), _f(line_neto), _f(line_neto)))
return rid, pid
rid, pid = db_tx(op)
return {"ok": True, "racun_id": rid, "partner_id": pid,
"broj": invoice_id, "neto": float(_f(total_neto)),
"pdv": float(_f(total_pdv)), "brutto": float(_f(total_brutto))}
# ═══════════════════════════════════════════════════════════════════
# 6) PDV (knjige + obrazac)
# ═══════════════════════════════════════════════════════════════════
@router.get("/pdv/knjiga-u")
def pdv_knjiga_u(godina: int = Query(...), mjesec: Optional[int] = None,
kvartal: Optional[int] = None):
where = ["EXTRACT(YEAR FROM r.datum_izdavanja)=%s", "r.status IN ('knjizen','placen')"]
params: list = [godina]
if mjesec:
where.append("EXTRACT(MONTH FROM r.datum_izdavanja)=%s"); params.append(mjesec)
elif kvartal:
m1 = (kvartal - 1) * 3 + 1
m3 = m1 + 2
where.append("EXTRACT(MONTH FROM r.datum_izdavanja) BETWEEN %s AND %s")
params.extend([m1, m3])
rows = db_query(
f"SELECT r.id, r.broj, r.datum_izdavanja, r.datum_primitka, "
f" p.naziv AS partner_naziv, p.oib AS partner_oib, "
f" r.iznos_neto, r.iznos_pdv, r.iznos_brutto, r.status "
f"FROM pgz_sport.racuni_ulazni r LEFT JOIN pgz_sport.partneri p ON p.id=r.partner_id "
f"WHERE {' AND '.join(where)} ORDER BY r.datum_izdavanja",
tuple(params))
sum_neto = sum(_f(r["iznos_neto"]) for r in rows)
sum_pdv = sum(_f(r["iznos_pdv"]) for r in rows)
sum_brutto = sum(_f(r["iznos_brutto"]) for r in rows)
return {"count": len(rows), "rows": rows,
"summary": {"neto": float(sum_neto), "pdv": float(sum_pdv), "brutto": float(sum_brutto)}}
@router.get("/pdv/knjiga-i")
def pdv_knjiga_i(godina: int = Query(...), mjesec: Optional[int] = None,
kvartal: Optional[int] = None):
where = ["EXTRACT(YEAR FROM r.datum_izdavanja)=%s", "r.status IN ('knjizen','placen')"]
params: list = [godina]
if mjesec:
where.append("EXTRACT(MONTH FROM r.datum_izdavanja)=%s"); params.append(mjesec)
elif kvartal:
m1 = (kvartal - 1) * 3 + 1
m3 = m1 + 2
where.append("EXTRACT(MONTH FROM r.datum_izdavanja) BETWEEN %s AND %s")
params.extend([m1, m3])
rows = db_query(
f"SELECT r.id, r.broj, r.datum_izdavanja, "
f" p.naziv AS partner_naziv, p.oib AS partner_oib, "
f" r.iznos_neto, r.iznos_pdv, r.iznos_brutto, r.status "
f"FROM pgz_sport.racuni_izlazni r LEFT JOIN pgz_sport.partneri p ON p.id=r.partner_id "
f"WHERE {' AND '.join(where)} ORDER BY r.datum_izdavanja",
tuple(params))
sum_neto = sum(_f(r["iznos_neto"]) for r in rows)
sum_pdv = sum(_f(r["iznos_pdv"]) for r in rows)
sum_brutto = sum(_f(r["iznos_brutto"]) for r in rows)
return {"count": len(rows), "rows": rows,
"summary": {"neto": float(sum_neto), "pdv": float(sum_pdv), "brutto": float(sum_brutto)}}
@router.get("/pdv/obrazac")
def pdv_obrazac(godina: int = Query(...), mjesec: Optional[int] = None,
kvartal: Optional[int] = None):
u = pdv_knjiga_u(godina, mjesec, kvartal)
i = pdv_knjiga_i(godina, mjesec, kvartal)
obveza = _f(i["summary"]["pdv"]) - _f(u["summary"]["pdv"])
return {
"godina": godina, "mjesec": mjesec, "kvartal": kvartal,
"ulazni": u["summary"], "izlazni": i["summary"],
"obveza_za_uplatu": float(obveza if obveza > 0 else Decimal(0)),
"pretporez_za_povrat": float(-obveza if obveza < 0 else Decimal(0)),
}
# ═══════════════════════════════════════════════════════════════════
# 7) ZAPOSLENICI + PLAĆE (HR 2026 stope)
# ═══════════════════════════════════════════════════════════════════
class ZaposlenikIn(BaseModel):
oib: Optional[str] = None
ime: str
prezime: str
klub_id: Optional[int] = None
radno_mjesto: Optional[str] = None
plata_bruto: float = 0
iban: Optional[str] = None
datum_pocetka: Optional[str] = None
datum_kraja: Optional[str] = None
aktivan: bool = True
@router.get("/zaposlenici")
def zaposlenici_list(klub_id: Optional[int] = None, aktivan: Optional[bool] = None):
where = ["1=1"]
params: list = []
if klub_id:
where.append("klub_id=%s"); params.append(klub_id)
if aktivan is not None:
where.append("aktivan=%s"); params.append(aktivan)
rows = db_query(
f"SELECT z.*, k.naziv AS klub_naziv FROM pgz_sport.zaposlenici z "
f"LEFT JOIN pgz_sport.klubovi k ON k.id=z.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY prezime, ime", tuple(params))
return {"count": len(rows), "rows": rows}
@router.post("/zaposlenici")
def zaposlenik_create(body: ZaposlenikIn):
rid = db_exec(
"INSERT INTO pgz_sport.zaposlenici (oib, ime, prezime, klub_id, radno_mjesto, "
"plata_bruto, iban, datum_pocetka, datum_kraja, aktivan) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
(body.oib, body.ime, body.prezime, body.klub_id, body.radno_mjesto,
body.plata_bruto, body.iban, body.datum_pocetka, body.datum_kraja, body.aktivan),
returning=True)
return {"ok": True, "id": rid}
@router.put("/zaposlenici/{zid}")
def zaposlenik_update(zid: int, body: ZaposlenikIn):
db_exec(
"UPDATE pgz_sport.zaposlenici SET oib=%s, ime=%s, prezime=%s, klub_id=%s, "
"radno_mjesto=%s, plata_bruto=%s, iban=%s, datum_pocetka=%s, datum_kraja=%s, "
"aktivan=%s WHERE id=%s",
(body.oib, body.ime, body.prezime, body.klub_id, body.radno_mjesto,
body.plata_bruto, body.iban, body.datum_pocetka, body.datum_kraja, body.aktivan, zid))
return {"ok": True}
def _calc_placa(bruto: Decimal, osobni_odbitak: Decimal = Decimal("600"),
prirez_pct: Decimal = Decimal(0)) -> dict:
"""HR 2026 plaća kalkulacija (pojednostavljeno).
Doprinosi iz plaće: 20% MIO (15% I stup + 5% II stup).
Doprinosi na plaću: 16.5% zdravstveno.
Dohodnina: 23.6% do 60.000€/god (5000€/mj), 35.4% iznad. Osobni odbitak 600€.
Prirez: % varira po općini (default 0)."""
doprinosi_iz = (bruto * Decimal("0.20")).quantize(Decimal("0.01"))
osnovica = bruto - doprinosi_iz - osobni_odbitak
if osnovica < 0:
osnovica = Decimal(0)
if osnovica <= Decimal("5000"):
dohodnina = (osnovica * Decimal("0.236")).quantize(Decimal("0.01"))
else:
dohodnina = (Decimal("5000") * Decimal("0.236") +
(osnovica - Decimal("5000")) * Decimal("0.354")).quantize(Decimal("0.01"))
prirez = (dohodnina * prirez_pct / Decimal(100)).quantize(Decimal("0.01"))
neto = (bruto - doprinosi_iz - dohodnina - prirez).quantize(Decimal("0.01"))
doprinosi_na = (bruto * Decimal("0.165")).quantize(Decimal("0.01"))
ukupni_trosak = (bruto + doprinosi_na).quantize(Decimal("0.01"))
return {
"bruto": bruto, "osobni_odbitak": osobni_odbitak,
"doprinosi_iz_plate": doprinosi_iz, "porezna_osnovica": osnovica,
"dohodnina": dohodnina, "prirez": prirez, "neto": neto,
"doprinosi_na_plate": doprinosi_na, "ukupni_trosak": ukupni_trosak,
}
class PlacaIn(BaseModel):
zaposlenik_id: int
godina: int
mjesec: int
bruto: Optional[float] = None # ako None, uzme z.plata_bruto
osobni_odbitak: float = 600
prirez_pct: float = 0
datum_isplate: Optional[str] = None
knjizi: bool = False
@router.get("/place/obracun")
def place_obracun_list(godina: Optional[int] = None, mjesec: Optional[int] = None,
zaposlenik_id: Optional[int] = None):
where = ["1=1"]
params: list = []
if godina:
where.append("po.godina=%s"); params.append(godina)
if mjesec:
where.append("po.mjesec=%s"); params.append(mjesec)
if zaposlenik_id:
where.append("po.zaposlenik_id=%s"); params.append(zaposlenik_id)
rows = db_query(
f"SELECT po.*, z.ime, z.prezime, z.oib FROM pgz_sport.place_obracun po "
f"JOIN pgz_sport.zaposlenici z ON z.id=po.zaposlenik_id "
f"WHERE {' AND '.join(where)} ORDER BY po.godina DESC, po.mjesec DESC, z.prezime",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.post("/place/obracun")
def placa_obracun(body: PlacaIn):
z = db_one("SELECT * FROM pgz_sport.zaposlenici WHERE id=%s", (body.zaposlenik_id,))
if not z:
raise HTTPException(404, "Zaposlenik ne postoji")
bruto = _f(body.bruto if body.bruto is not None else z["plata_bruto"])
if bruto <= 0:
raise HTTPException(400, "Bruto mora biti veći od 0")
calc = _calc_placa(bruto, _f(body.osobni_odbitak), _f(body.prirez_pct))
def op(cur):
cur.execute(
"INSERT INTO pgz_sport.place_obracun (zaposlenik_id, godina, mjesec, bruto, "
"osobni_odbitak, doprinosi_iz_plate, porezna_osnovica, dohodnina, prirez, neto, "
"doprinosi_na_plate, ukupni_trosak, datum_isplate) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
"ON CONFLICT (zaposlenik_id, godina, mjesec) DO UPDATE SET "
"bruto=EXCLUDED.bruto, doprinosi_iz_plate=EXCLUDED.doprinosi_iz_plate, "
"porezna_osnovica=EXCLUDED.porezna_osnovica, dohodnina=EXCLUDED.dohodnina, "
"prirez=EXCLUDED.prirez, neto=EXCLUDED.neto, "
"doprinosi_na_plate=EXCLUDED.doprinosi_na_plate, "
"ukupni_trosak=EXCLUDED.ukupni_trosak, datum_isplate=EXCLUDED.datum_isplate "
"RETURNING id",
(body.zaposlenik_id, body.godina, body.mjesec, calc["bruto"],
calc["osobni_odbitak"], calc["doprinosi_iz_plate"], calc["porezna_osnovica"],
calc["dohodnina"], calc["prirez"], calc["neto"], calc["doprinosi_na_plate"],
calc["ukupni_trosak"], body.datum_isplate))
pid = cur.fetchone()["id"]
did = None
if body.knjizi:
datum = body.datum_isplate or date.today().isoformat()
cur.execute(
"INSERT INTO pgz_sport.dnevnik_zapisa (datum, opis, dokument_tip, dokument_id, "
"redni_broj) VALUES (%s, %s, 'placa', %s, "
" (SELECT COALESCE(MAX(redni_broj),0)+1 FROM pgz_sport.dnevnik_zapisa "
" WHERE EXTRACT(YEAR FROM datum)=EXTRACT(YEAR FROM %s::date))) RETURNING id",
(datum, f"Plaća {z['ime']} {z['prezime']} {body.godina}/{body.mjesec:02d}",
pid, datum))
did = cur.fetchone()["id"]
kid = lambda s: _konto_id(cur, s)
cur.execute(
"INSERT INTO pgz_sport.knjizenja (dnevnik_id, konto_id, duguje, potrazuje, opis) "
"VALUES (%s,%s,%s,0,'Bruto plaća'), (%s,%s,%s,0,'Doprinosi na plaću'), "
"(%s,%s,0,%s,'Neto plaća'), (%s,%s,0,%s,'Doprinosi iz plaće'), "
"(%s,%s,0,%s,'Dohodnina'), (%s,%s,0,%s,'Doprinosi na plaću - obveza')",
(did, kid('420'), calc["bruto"],
did, kid('421'), calc["doprinosi_na_plate"],
did, kid('240'), calc["neto"],
did, kid('241'), calc["doprinosi_iz_plate"],
did, kid('242'), calc["dohodnina"],
did, kid('244'), calc["doprinosi_na_plate"]))
cur.execute("UPDATE pgz_sport.place_obracun SET dnevnik_id=%s WHERE id=%s", (did, pid))
return pid, did
pid, did = db_tx(op)
return {"ok": True, "id": pid, "dnevnik_id": did,
"calc": {k: float(v) if isinstance(v, Decimal) else v for k, v in calc.items()}}
# ═══════════════════════════════════════════════════════════════════
# 8) IZVJEŠTAJI: Bilanca, PnL, Cashflow
# ═══════════════════════════════════════════════════════════════════
@router.get("/izvjestaji/bilanca")
def izvj_bilanca(godina: int = Query(...)):
aktiva = db_query(
"SELECT kp.sifra, kp.naziv, kp.klasa, "
" COALESCE(SUM(k.duguje),0) - COALESCE(SUM(k.potrazuje),0) AS saldo "
"FROM pgz_sport.kontni_plan kp "
"LEFT JOIN pgz_sport.knjizenja k ON k.konto_id=kp.id "
"LEFT JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
" AND EXTRACT(YEAR FROM dz.datum)<=%s "
"WHERE kp.vrsta='aktiva' "
"GROUP BY kp.sifra, kp.naziv, kp.klasa ORDER BY kp.sifra", (godina,))
pasiva = db_query(
"SELECT kp.sifra, kp.naziv, kp.klasa, "
" COALESCE(SUM(k.potrazuje),0) - COALESCE(SUM(k.duguje),0) AS saldo "
"FROM pgz_sport.kontni_plan kp "
"LEFT JOIN pgz_sport.knjizenja k ON k.konto_id=kp.id "
"LEFT JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
" AND EXTRACT(YEAR FROM dz.datum)<=%s "
"WHERE kp.vrsta IN ('pasiva','kapital') "
"GROUP BY kp.sifra, kp.naziv, kp.klasa ORDER BY kp.sifra", (godina,))
sum_a = sum(_f(r["saldo"]) for r in aktiva)
sum_p = sum(_f(r["saldo"]) for r in pasiva)
return {
"godina": godina,
"aktiva": aktiva, "pasiva": pasiva,
"ukupno_aktiva": float(sum_a), "ukupno_pasiva": float(sum_p),
"balans_ok": sum_a == sum_p,
}
@router.get("/izvjestaji/pnl")
def izvj_pnl(godina: int = Query(...)):
prihodi = db_query(
"SELECT kp.sifra, kp.naziv, "
" COALESCE(SUM(k.potrazuje),0) - COALESCE(SUM(k.duguje),0) AS iznos "
"FROM pgz_sport.kontni_plan kp "
"LEFT JOIN pgz_sport.knjizenja k ON k.konto_id=kp.id "
"LEFT JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
" AND EXTRACT(YEAR FROM dz.datum)=%s "
"WHERE kp.vrsta='prihod' "
"GROUP BY kp.sifra, kp.naziv ORDER BY kp.sifra", (godina,))
rashodi = db_query(
"SELECT kp.sifra, kp.naziv, "
" COALESCE(SUM(k.duguje),0) - COALESCE(SUM(k.potrazuje),0) AS iznos "
"FROM pgz_sport.kontni_plan kp "
"LEFT JOIN pgz_sport.knjizenja k ON k.konto_id=kp.id "
"LEFT JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
" AND EXTRACT(YEAR FROM dz.datum)=%s "
"WHERE kp.vrsta='rashod' "
"GROUP BY kp.sifra, kp.naziv ORDER BY kp.sifra", (godina,))
sum_pr = sum(_f(r["iznos"]) for r in prihodi)
sum_ra = sum(_f(r["iznos"]) for r in rashodi)
rezultat = sum_pr - sum_ra
return {
"godina": godina,
"prihodi": prihodi, "rashodi": rashodi,
"ukupno_prihodi": float(sum_pr), "ukupno_rashodi": float(sum_ra),
"rezultat": float(rezultat),
"tip_rezultata": "visak" if rezultat >= 0 else "manjak",
}
@router.get("/izvjestaji/cashflow")
def izvj_cashflow(godina: int = Query(...)):
"""Cashflow approximated from blagajna (10x) + ziro (101x) + dev (102x)."""
rows = db_query(
"SELECT kp.sifra, kp.naziv, "
" COALESCE(SUM(k.duguje),0) AS uplate, "
" COALESCE(SUM(k.potrazuje),0) AS isplate, "
" COALESCE(SUM(k.duguje),0) - COALESCE(SUM(k.potrazuje),0) AS saldo "
"FROM pgz_sport.kontni_plan kp "
"LEFT JOIN pgz_sport.knjizenja k ON k.konto_id=kp.id "
"LEFT JOIN pgz_sport.dnevnik_zapisa dz ON dz.id=k.dnevnik_id "
" AND EXTRACT(YEAR FROM dz.datum)=%s "
"WHERE kp.klasa=1 AND kp.sifra LIKE ANY (ARRAY['10%%','101%%','102%%']) "
"GROUP BY kp.sifra, kp.naziv ORDER BY kp.sifra", (godina,))
monthly = db_query(
"SELECT EXTRACT(MONTH FROM dz.datum)::int AS mjesec, "
" COALESCE(SUM(CASE WHEN kp.klasa=1 AND kp.sifra LIKE ANY(ARRAY['10%%','101%%','102%%']) "
" THEN k.duguje END),0) AS uplate, "
" COALESCE(SUM(CASE WHEN kp.klasa=1 AND kp.sifra LIKE ANY(ARRAY['10%%','101%%','102%%']) "
" THEN k.potrazuje END),0) AS isplate "
"FROM pgz_sport.dnevnik_zapisa dz "
"JOIN pgz_sport.knjizenja k ON k.dnevnik_id=dz.id "
"JOIN pgz_sport.kontni_plan kp ON kp.id=k.konto_id "
"WHERE EXTRACT(YEAR FROM dz.datum)=%s "
"GROUP BY 1 ORDER BY 1", (godina,))
return {"godina": godina, "po_kontu": rows, "po_mjesecu": monthly}
# ═══════════════════════════════════════════════════════════════════
# 9) EXPORT (XLSX + PDF)
# ═══════════════════════════════════════════════════════════════════
def _xlsx_response(rows: list, headers: list, sheet: str, fname: str) -> StreamingResponse:
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = sheet
ws.append(headers)
for r in rows:
ws.append([r.get(h) if isinstance(r, dict) else getattr(r, h, "") for h in headers])
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return StreamingResponse(
buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
def _pdf_response(title: str, lines: list, fname: str) -> StreamingResponse:
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
buf = BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
c.setFont("Helvetica-Bold", 14)
c.drawString(40, 800, title)
c.setFont("Helvetica", 10)
y = 770
for ln in lines:
if y < 40:
c.showPage()
c.setFont("Helvetica", 10)
y = 800
c.drawString(40, y, str(ln)[:120])
y -= 14
c.showPage()
c.save()
buf.seek(0)
return StreamingResponse(
buf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@router.get("/export/xlsx/{report}")
def export_xlsx(report: str, godina: int = Query(...), mjesec: Optional[int] = None,
kvartal: Optional[int] = None):
if report == "bilanca":
data = izvj_bilanca(godina)
rows = [{"sekcija": "AKTIVA", **r} for r in data["aktiva"]] + \
[{"sekcija": "PASIVA", **r} for r in data["pasiva"]]
return _xlsx_response(rows, ["sekcija", "sifra", "naziv", "saldo"],
"Bilanca", f"bilanca_{godina}.xlsx")
if report == "pnl":
data = izvj_pnl(godina)
rows = [{"sekcija": "PRIHOD", **r} for r in data["prihodi"]] + \
[{"sekcija": "RASHOD", **r} for r in data["rashodi"]]
return _xlsx_response(rows, ["sekcija", "sifra", "naziv", "iznos"],
"PnL", f"pnl_{godina}.xlsx")
if report == "glavna-knjiga":
rows = glavna_knjiga()["rows"]
return _xlsx_response(rows, ["sifra", "naziv", "klasa", "vrsta",
"sum_duguje", "sum_potrazuje", "saldo", "broj_stavki"],
"Glavna knjiga", "glavna_knjiga.xlsx")
if report == "pdv-u":
d = pdv_knjiga_u(godina, mjesec, kvartal)
return _xlsx_response(d["rows"], ["broj", "datum_izdavanja", "partner_naziv",
"partner_oib", "iznos_neto", "iznos_pdv", "iznos_brutto"],
"PDV ulazni", f"pdv_u_{godina}.xlsx")
if report == "pdv-i":
d = pdv_knjiga_i(godina, mjesec, kvartal)
return _xlsx_response(d["rows"], ["broj", "datum_izdavanja", "partner_naziv",
"partner_oib", "iznos_neto", "iznos_pdv", "iznos_brutto"],
"PDV izlazni", f"pdv_i_{godina}.xlsx")
raise HTTPException(400, f"Nepoznat report: {report}")
@router.get("/export/pdf/{report}")
def export_pdf(report: str, godina: int = Query(...)):
if report == "bilanca":
d = izvj_bilanca(godina)
lines = [f"BILANCA {godina}", "", "AKTIVA:"]
for r in d["aktiva"]:
lines.append(f" {r['sifra']:<8} {r['naziv'][:50]:<50} {float(r['saldo']):>14,.2f}")
lines += ["", f" UKUPNO AKTIVA: {d['ukupno_aktiva']:>14,.2f}", "", "PASIVA:"]
for r in d["pasiva"]:
lines.append(f" {r['sifra']:<8} {r['naziv'][:50]:<50} {float(r['saldo']):>14,.2f}")
lines += ["", f" UKUPNO PASIVA: {d['ukupno_pasiva']:>14,.2f}"]
return _pdf_response(f"Bilanca {godina}", lines, f"bilanca_{godina}.pdf")
if report == "pnl":
d = izvj_pnl(godina)
lines = [f"RAČUN DOBITI I GUBITKA {godina}", "", "PRIHODI:"]
for r in d["prihodi"]:
lines.append(f" {r['sifra']:<8} {r['naziv'][:50]:<50} {float(r['iznos']):>14,.2f}")
lines += ["", f" UKUPNO PRIHODI: {d['ukupno_prihodi']:>14,.2f}", "", "RASHODI:"]
for r in d["rashodi"]:
lines.append(f" {r['sifra']:<8} {r['naziv'][:50]:<50} {float(r['iznos']):>14,.2f}")
lines += ["", f" UKUPNO RASHODI: {d['ukupno_rashodi']:>14,.2f}",
f" REZULTAT ({d['tip_rezultata'].upper()}): {d['rezultat']:>14,.2f}"]
return _pdf_response(f"PnL {godina}", lines, f"pnl_{godina}.pdf")
raise HTTPException(400, f"Nepoznat report: {report}")
# ═══════════════════════════════════════════════════════════════════
# 10) PRORAČUN (read-only existing pgz_sport.proracun)
# ═══════════════════════════════════════════════════════════════════
@router.get("/proracun")
def proracun_list():
rows = db_query("SELECT * FROM pgz_sport.proracun ORDER BY godina DESC")
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# 11) INVOICE UPLOADS (PDF/scan attachments to ulazni računi)
# ═══════════════════════════════════════════════════════════════════
@router.get("/invoice-uploads")
def invoice_uploads_list(klub_id: Optional[int] = None,
ocr_status: Optional[str] = None,
q: Optional[str] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if klub_id:
where.append("klub_id=%s"); params.append(klub_id)
if ocr_status:
where.append("ocr_status=%s"); params.append(ocr_status)
if q:
where.append("(file_name ILIKE %s OR ai_vendor_name ILIKE %s OR ai_invoice_no ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
params.append(limit)
rows = db_query(
"SELECT id, klub_id, file_name, file_path, file_size, mime, ocr_status, "
"ocr_confidence, ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib, "
"ai_amount_gross, ai_currency, invoice_id, uploaded_by, "
"uploaded_at FROM pgz_sport.invoice_uploads "
f"WHERE {' AND '.join(where)} ORDER BY id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.get("/racuni/ulazni/{rid}/uploads")
def racuni_ulazni_uploads(rid: int):
"""Uploads (file attachments) linked to an ulazni racun via invoice_id."""
rows = db_query(
"SELECT id, file_name, file_path, file_size, mime, ocr_status, "
"ai_invoice_no, ai_vendor_name, ai_amount_gross, uploaded_at "
"FROM pgz_sport.invoice_uploads WHERE invoice_id=%s ORDER BY id DESC",
(rid,))
return {"count": len(rows), "rows": rows}
# ── Upload new invoice file (multipart) ─────────────────────────────
@router.post("/invoice-uploads")
async def invoice_uploads_create(
file: UploadFile = File(...),
klub_id: Optional[int] = Form(None),
invoice_id: Optional[int] = Form(None),
):
"""Accepts PDF/JPG/PNG of an invoice. Stores file under /uploads/invoices/
and inserts a row into invoice_uploads with ocr_status='pending'.
Returns the new id; OCR/AI extraction runs separately."""
raw = await file.read()
if not raw:
raise HTTPException(400, "Empty file")
if len(raw) > 25 * 1024 * 1024:
raise HTTPException(413, "File > 25 MB")
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", file.filename or "upload.bin")[:120]
sha = hashlib.sha256(raw).hexdigest()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
rel = f"invoices/{ts}_{sha[:10]}_{safe}"
abs_path = UPLOAD_BASE / rel
abs_path.parent.mkdir(parents=True, exist_ok=True)
with open(abs_path, "wb") as f:
f.write(raw)
new_id = db_exec(
"INSERT INTO pgz_sport.invoice_uploads "
"(klub_id, file_name, file_path, file_size, mime, sha256, "
" ocr_status, invoice_id, uploaded_at) "
"VALUES (%s,%s,%s,%s,%s,%s,'pending',%s, now()) RETURNING id",
(klub_id, file.filename, rel, len(raw),
file.content_type or "application/octet-stream",
sha, invoice_id),
returning=True,
)
return {"ok": True, "id": new_id, "file_path": rel,
"file_size": len(raw), "sha256": sha}
# ═══════════════════════════════════════════════════════════════════
# 12) PUTNI NALOZI / EXPENSE REPORTS
# ═══════════════════════════════════════════════════════════════════
def _expense_reports_list_impl(klub_id, status, report_type, godina, limit):
where = ["1=1"]
params: list = []
if klub_id:
where.append("er.klub_id=%s"); params.append(klub_id)
if status:
where.append("er.status=%s"); params.append(status)
if report_type:
where.append("er.report_type=%s"); params.append(report_type)
if godina:
where.append("EXTRACT(YEAR FROM er.date_from)=%s"); params.append(godina)
params.append(limit)
rows = db_query(
"SELECT er.id, er.klub_id, k.naziv AS klub_naziv, er.report_type, er.report_no, "
"er.destination, er.purpose, er.date_from, er.date_to, er.km_driven, "
"er.cost_total, er.dnevnice_count, er.dnevnice_amount, er.status, "
"er.approved_at, er.paid_at, er.created_at "
"FROM pgz_sport.expense_reports er "
"LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY er.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.get("/expense-reports")
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
return _expense_reports_list_impl(klub_id, status, report_type, godina, limit)
@router.get("/putni-nalozi")
def putni_nalozi_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
return _expense_reports_list_impl(klub_id, status, report_type, godina, limit)
# ── Putni nalog single + CRUD ──────────────────────────────────────
class PutniNalogIn(BaseModel):
klub_id: int
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: str = "sluzbeno_putovanje"
report_no: Optional[str] = None
destination: str
purpose: str
date_from: date
date_to: date
vehicle_type: Optional[str] = None
vehicle_plate: Optional[str] = None
km_driven: float = 0
km_rate: float = 0.42
cost_transport: float = 0
cost_lodging: float = 0
cost_meals: float = 0
cost_other: float = 0
dnevnice_count: float = 0
dnevnice_amount: float = 30.00
notes: Optional[str] = None
class PutniNalogPatch(BaseModel):
klub_id: Optional[int] = None
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: Optional[str] = None
report_no: Optional[str] = None
destination: Optional[str] = None
purpose: Optional[str] = None
date_from: Optional[date] = None
date_to: Optional[date] = None
vehicle_type: Optional[str] = None
vehicle_plate: Optional[str] = None
km_driven: Optional[float] = None
km_rate: Optional[float] = None
cost_transport: Optional[float] = None
cost_lodging: Optional[float] = None
cost_meals: Optional[float] = None
cost_other: Optional[float] = None
dnevnice_count: Optional[float] = None
dnevnice_amount: Optional[float] = None
notes: Optional[str] = None
status: Optional[str] = None
# Allowed status transitions
_PN_TRANSITIONS = {
"draft": {"poslano", "odbijeno"},
"poslano": {"odobreno", "odbijeno"},
"odobreno": {"isplaceno"},
"isplaceno": set(),
"odbijeno": set(),
}
def _pn_calc_total(km_driven, km_rate, c_tr, c_lo, c_me, c_ot, dn_c, dn_a):
return (
_f(km_driven) * _f(km_rate)
+ _f(c_tr) + _f(c_lo) + _f(c_me) + _f(c_ot)
+ _f(dn_c) * _f(dn_a)
)
def _pn_get_one(pid: int):
head = db_one(
"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", (pid,))
if not head:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
racuni = db_query(
"SELECT pnr.id, pnr.invoice_id, pnr.kategorija, pnr.napomena, pnr.attached_at, "
"i.invoice_no, i.vendor_name, i.amount_gross, i.currency "
"FROM pgz_sport.putni_nalog_racuni pnr "
"LEFT JOIN pgz_sport.invoices i ON i.id=pnr.invoice_id "
"WHERE pnr.putni_nalog_id=%s ORDER BY pnr.id DESC", (pid,))
return {"head": head, "racuni": racuni}
def _pn_create(body: PutniNalogIn):
cost_total = _pn_calc_total(
body.km_driven, body.km_rate,
body.cost_transport, body.cost_lodging, body.cost_meals, body.cost_other,
body.dnevnice_count, body.dnevnice_amount)
rid = db_exec(
"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, cost_total, "
" dnevnice_count, dnevnice_amount, status, notes, created_at, updated_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'draft',%s, now(), now()) "
"RETURNING id",
(body.klub_id, body.user_id, body.clan_id, body.report_type, body.report_no,
body.destination, body.purpose, body.date_from, body.date_to,
body.vehicle_type, body.vehicle_plate,
_f(body.km_driven), _f(body.km_rate),
_f(body.cost_transport), _f(body.cost_lodging),
_f(body.cost_meals), _f(body.cost_other), cost_total,
_f(body.dnevnice_count), _f(body.dnevnice_amount),
body.notes),
returning=True)
row = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (rid,))
return {"ok": True, "id": rid, "row": row}
def _pn_patch(pid: int, body: PutniNalogPatch):
current = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
if not current:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
data = body.dict(exclude_unset=True)
# Status workflow validation
new_status = data.get("status")
if new_status is not None and new_status != current["status"]:
allowed = _PN_TRANSITIONS.get(current["status"], set())
if new_status not in allowed:
raise HTTPException(
400,
f"Nedozvoljen prijelaz statusa: {current['status']}{new_status}. "
f"Dozvoljeni: {sorted(allowed) or '(nijedan)'}")
sets = []
params: list = []
cost_fields = {"km_driven", "km_rate", "cost_transport", "cost_lodging",
"cost_meals", "cost_other", "dnevnice_count", "dnevnice_amount"}
cost_changed = bool(cost_fields.intersection(data.keys()))
for col in ("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", "notes", "status"):
if col in data:
sets.append(f"{col}=%s")
params.append(data[col])
if cost_changed:
# Recompute using merged values
merged = dict(current)
merged.update(data)
new_total = _pn_calc_total(
merged["km_driven"], merged["km_rate"],
merged["cost_transport"], merged["cost_lodging"],
merged["cost_meals"], merged["cost_other"],
merged["dnevnice_count"], merged["dnevnice_amount"])
sets.append("cost_total=%s")
params.append(new_total)
if new_status == "odobreno":
sets.append("approved_at=now()")
if new_status == "isplaceno":
sets.append("paid_at=now()")
sets.append("updated_at=now()")
if not sets:
return {"ok": True, "no_changes": True, "row": current}
params.append(pid)
db_exec(f"UPDATE pgz_sport.expense_reports SET {', '.join(sets)} WHERE id=%s",
tuple(params))
row = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
return {"ok": True, "id": pid, "row": row}
def _pn_delete(pid: int):
cur = db_one("SELECT status FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
if not cur:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
if cur["status"] != "draft":
raise HTTPException(
400,
f"Brisanje dopušteno samo za status='draft' (trenutni: {cur['status']})")
db_exec("DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s", (pid,))
db_exec("DELETE FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
return {"ok": True, "deleted": pid}
# ── Routes (both /putni-nalozi and /expense-reports prefixes) ─────
@router.get("/putni-nalozi/{pid}")
def putni_nalog_get(pid: int):
return _pn_get_one(pid)
@router.get("/expense-reports/{pid}")
def expense_report_get(pid: int):
return _pn_get_one(pid)
@router.post("/putni-nalozi")
def putni_nalog_create(body: PutniNalogIn):
return _pn_create(body)
@router.post("/expense-reports")
def expense_report_create(body: PutniNalogIn):
return _pn_create(body)
@router.patch("/putni-nalozi/{pid}")
def putni_nalog_patch(pid: int, body: PutniNalogPatch):
return _pn_patch(pid, body)
@router.patch("/expense-reports/{pid}")
def expense_report_patch(pid: int, body: PutniNalogPatch):
return _pn_patch(pid, body)
@router.delete("/putni-nalozi/{pid}")
def putni_nalog_delete(pid: int):
return _pn_delete(pid)
@router.delete("/expense-reports/{pid}")
def expense_report_delete(pid: int):
return _pn_delete(pid)
@router.get("/putni-nalog-racuni")
def putni_nalog_racuni_list(putni_nalog_id: Optional[int] = None,
invoice_id: Optional[int] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if putni_nalog_id:
where.append("pnr.putni_nalog_id=%s"); params.append(putni_nalog_id)
if invoice_id:
where.append("pnr.invoice_id=%s"); params.append(invoice_id)
params.append(limit)
rows = db_query(
"SELECT pnr.id, pnr.putni_nalog_id, pnr.invoice_id, pnr.kategorija, "
"pnr.napomena, pnr.attached_at, "
"er.report_no, er.destination, er.purpose, "
"i.invoice_no, i.vendor_name, i.amount_gross, i.currency "
"FROM pgz_sport.putni_nalog_racuni pnr "
"LEFT JOIN pgz_sport.expense_reports er ON er.id=pnr.putni_nalog_id "
"LEFT JOIN pgz_sport.invoices i ON i.id=pnr.invoice_id "
f"WHERE {' AND '.join(where)} ORDER BY pnr.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# 13) PAYMENTS (uplate/isplate, bank reconciliation)
# ═══════════════════════════════════════════════════════════════════
@router.get("/payments")
def payments_list(klub_id: Optional[int] = None,
matched_status: Optional[str] = None,
payment_method: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if klub_id:
where.append("p.klub_id=%s"); params.append(klub_id)
if matched_status:
where.append("p.matched_status=%s"); params.append(matched_status)
if payment_method:
where.append("p.payment_method=%s"); params.append(payment_method)
if godina:
where.append("EXTRACT(YEAR FROM p.payment_date)=%s"); params.append(godina)
params.append(limit)
rows = db_query(
"SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.invoice_id, "
"p.expense_report_id, p.clanarina_id, p.payment_date, p.amount, p.currency, "
"p.payment_method, p.iban_from, p.iban_to, p.reference, p.description, "
"p.bank_statement_no, p.bank_transaction_id, p.matched_status, p.created_at "
"FROM pgz_sport.payments p "
"LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY p.payment_date DESC, p.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
# ── GET /payments/sepa-export — must be before /payments/{id} ───────
def _parse_ids_param(ids: Optional[str]) -> List[int]:
if not ids:
return []
out: List[int] = []
for chunk in ids.split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
out.append(int(chunk))
except ValueError:
raise HTTPException(400, f"Bad id in 'ids': {chunk!r}")
return out
@router.get("/payments/sepa-export")
def payments_sepa_export(
ids: Optional[str] = Query(None, description="CSV id list, e.g. 1,2,3"),
godina: Optional[int] = None,
klub_id: Optional[int] = None,
matched_status: Optional[str] = None,
payment_method: Optional[str] = None,
):
"""Generate pain.001.001.03 SEPA Credit Transfer XML — MOCK / placeholder.
Either pass ?ids=1,2,3 OR filter (godina/klub_id/matched_status/payment_method)."""
where = ["1=1"]
params: list = []
id_list = _parse_ids_param(ids)
if id_list:
where.append("p.id = ANY(%s)")
params.append(id_list)
else:
if godina:
where.append("EXTRACT(YEAR FROM p.payment_date)=%s"); params.append(godina)
if klub_id:
where.append("p.klub_id=%s"); params.append(klub_id)
if matched_status:
where.append("p.matched_status=%s"); params.append(matched_status)
if payment_method:
where.append("p.payment_method=%s"); params.append(payment_method)
rows = db_query(
"SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.payment_date, p.amount, "
"p.currency, p.iban_from, p.iban_to, p.reference, p.description "
"FROM pgz_sport.payments p "
"LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY p.payment_date, p.id LIMIT 5000",
tuple(params))
debtor_name = os.environ.get("PGZ_SEPA_DEBTOR_NAME", "PGŽ Sportski savez")
debtor_iban = os.environ.get("PGZ_SEPA_DEBTOR_IBAN", "HR0000000000000000000")
debtor_bic = os.environ.get("PGZ_SEPA_DEBTOR_BIC", "NOTPROVIDED")
now = datetime.now()
msg_id = f"PGZ-{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
pmt_inf_id = f"{msg_id}-PMT01"
creation_dt = now.strftime("%Y-%m-%dT%H:%M:%S")
nb_of_txs = len(rows)
ctrl_sum = sum((Decimal(str(r["amount"])) for r in rows if r["amount"] is not None), Decimal("0"))
ctrl_sum_str = f"{ctrl_sum:.2f}"
requested_exec_date = (rows[0]["payment_date"] if rows and rows[0]["payment_date"] else now.date()).isoformat()
parts: List[str] = []
parts.append('<?xml version="1.0" encoding="UTF-8"?>')
parts.append('<!-- MOCK / placeholder — for future banking integration -->')
parts.append('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
parts.append(' <CstmrCdtTrfInitn>')
# Group Header
parts.append(' <GrpHdr>')
parts.append(f' <MsgId>{xml_escape(msg_id)}</MsgId>')
parts.append(f' <CreDtTm>{creation_dt}</CreDtTm>')
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
parts.append(' <InitgPty>')
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
parts.append(' </InitgPty>')
parts.append(' </GrpHdr>')
# Payment Information
parts.append(' <PmtInf>')
parts.append(f' <PmtInfId>{xml_escape(pmt_inf_id)}</PmtInfId>')
parts.append(' <PmtMtd>TRF</PmtMtd>')
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
parts.append(' <PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl></PmtTpInf>')
parts.append(f' <ReqdExctnDt>{requested_exec_date}</ReqdExctnDt>')
parts.append(' <Dbtr>')
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
parts.append(' </Dbtr>')
parts.append(' <DbtrAcct>')
parts.append(f' <Id><IBAN>{xml_escape(debtor_iban)}</IBAN></Id>')
parts.append(' </DbtrAcct>')
parts.append(' <DbtrAgt>')
parts.append(f' <FinInstnId><BIC>{xml_escape(debtor_bic)}</BIC></FinInstnId>')
parts.append(' </DbtrAgt>')
parts.append(' <ChrgBr>SLEV</ChrgBr>')
for r in rows:
amt = r["amount"] if r["amount"] is not None else Decimal("0")
try:
amt_str = f"{Decimal(str(amt)):.2f}"
except (InvalidOperation, TypeError):
amt_str = "0.00"
ccy = (r.get("currency") or "EUR")[:3]
cdtr_name = r.get("klub_naziv") or (f"Klub #{r['klub_id']}" if r.get("klub_id") else "Beneficiary")
cdtr_iban = (r.get("iban_to") or "").strip() or "HR0000000000000000000"
end_to_end = (r.get("reference") or f"PGZ-PAY-{r['id']}")[:35]
rmt = (r.get("description") or r.get("reference") or f"Payment #{r['id']}")[:140]
parts.append(' <CdtTrfTxInf>')
parts.append(' <PmtId>')
parts.append(f' <EndToEndId>{xml_escape(end_to_end)}</EndToEndId>')
parts.append(' </PmtId>')
parts.append(' <Amt>')
parts.append(f' <InstdAmt Ccy="{xml_escape(ccy)}">{amt_str}</InstdAmt>')
parts.append(' </Amt>')
parts.append(' <Cdtr>')
parts.append(f' <Nm>{xml_escape(cdtr_name)}</Nm>')
parts.append(' </Cdtr>')
parts.append(' <CdtrAcct>')
parts.append(f' <Id><IBAN>{xml_escape(cdtr_iban)}</IBAN></Id>')
parts.append(' </CdtrAcct>')
parts.append(' <RmtInf>')
parts.append(f' <Ustrd>{xml_escape(rmt)}</Ustrd>')
parts.append(' </RmtInf>')
parts.append(' </CdtTrfTxInf>')
parts.append(' </PmtInf>')
parts.append(' </CstmrCdtTrfInitn>')
parts.append('</Document>')
xml_body = "\n".join(parts).encode("utf-8")
fname = f"sepa_export_{now.strftime('%Y%m%d_%H%M%S')}.xml"
return Response(
content=xml_body,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ── GET /payments/{id} — single record + linked info ────────────────
@router.get("/payments/{rid}")
def payments_get(rid: int):
row = db_one(
"SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.invoice_id, "
"p.expense_report_id, p.clanarina_id, p.payment_date, p.amount, p.currency, "
"p.payment_method, p.iban_from, p.iban_to, p.reference, p.description, "
"p.bank_statement_no, p.bank_transaction_id, p.matched_status, "
"p.matched_by, p.matched_at, p.created_at, "
"i.invoice_no, i.vendor_name, i.amount_gross AS invoice_amount, "
"er.report_no, er.purpose AS expense_purpose, er.cost_total AS expense_total "
"FROM pgz_sport.payments p "
"LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
"LEFT JOIN pgz_sport.invoices i ON i.id=p.invoice_id "
"LEFT JOIN pgz_sport.expense_reports er ON er.id=p.expense_report_id "
"WHERE p.id=%s",
(rid,))
if not row:
raise HTTPException(404, f"Payment id={rid} not found")
# Try clanarina (table may exist optionally)
if row.get("clanarina_id"):
try:
cl = db_one(
"SELECT id, godina, iznos, status FROM pgz_sport.clanarine WHERE id=%s",
(row["clanarina_id"],))
if cl:
row["clanarina"] = cl
except Exception:
row["clanarina"] = None
return row
# ── POST /payments — create ─────────────────────────────────────────
class PaymentIn(BaseModel):
klub_id: Optional[int] = None
invoice_id: Optional[int] = None
expense_report_id: Optional[int] = None
clanarina_id: Optional[int] = None
payment_date: date
amount: Decimal
currency: str = "EUR"
payment_method: Optional[str] = None
iban_from: Optional[str] = None
iban_to: Optional[str] = None
reference: Optional[str] = None
description: Optional[str] = None
bank_statement_no: Optional[str] = None
bank_transaction_id: Optional[str] = None
_ALLOWED_METHODS = {"iban", "cash", "card", "sepa", "transfer"}
@router.post("/payments")
def payments_create(body: PaymentIn):
if body.amount is None or Decimal(str(body.amount)) <= 0:
raise HTTPException(400, "amount must be > 0")
if body.payment_method and body.payment_method not in _ALLOWED_METHODS:
raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}")
new_id = db_exec(
"INSERT INTO pgz_sport.payments "
"(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, amount, "
" currency, payment_method, iban_from, iban_to, reference, description, "
" bank_statement_no, bank_transaction_id, matched_status, created_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now()) "
"RETURNING id",
(body.klub_id, body.invoice_id, body.expense_report_id, body.clanarina_id,
body.payment_date, body.amount, body.currency or "EUR",
body.payment_method, body.iban_from, body.iban_to,
body.reference, body.description,
body.bank_statement_no, body.bank_transaction_id),
returning=True,
)
return payments_get(new_id)
# ── PATCH /payments/{id} — partial update + manual matching ─────────
class PaymentPatch(BaseModel):
klub_id: Optional[int] = None
invoice_id: Optional[int] = None
expense_report_id: Optional[int] = None
clanarina_id: Optional[int] = None
payment_date: Optional[date] = None
amount: Optional[Decimal] = None
currency: Optional[str] = None
payment_method: Optional[str] = None
iban_from: Optional[str] = None
iban_to: Optional[str] = None
reference: Optional[str] = None
description: Optional[str] = None
bank_statement_no: Optional[str] = None
bank_transaction_id: Optional[str] = None
matched_status: Optional[str] = None
matched_by: Optional[str] = None
@router.patch("/payments/{rid}")
def payments_patch(rid: int, body: PaymentPatch):
existing = db_one("SELECT id FROM pgz_sport.payments WHERE id=%s", (rid,))
if not existing:
raise HTTPException(404, f"Payment id={rid} not found")
data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
if "amount" in data and data["amount"] is not None and Decimal(str(data["amount"])) <= 0:
raise HTTPException(400, "amount must be > 0")
if "payment_method" in data and data["payment_method"] and data["payment_method"] not in _ALLOWED_METHODS:
raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}")
if not data:
return payments_get(rid)
set_parts: List[str] = []
params: list = []
for col, val in data.items():
set_parts.append(f"{col}=%s")
params.append(val)
# Manual matching: when matched_status flips to 'matched', stamp matched_at
if data.get("matched_status") == "matched":
set_parts.append("matched_at=now()")
params.append(rid)
db_exec(
f"UPDATE pgz_sport.payments SET {', '.join(set_parts)} WHERE id=%s",
tuple(params),
)
return payments_get(rid)
# ── POST /payments/import-csv — batch import ────────────────────────
_CSV_REQUIRED = ["payment_date", "amount", "currency", "payment_method",
"iban_from", "iban_to", "reference", "description"]
_CSV_OPTIONAL = ["klub_id", "invoice_id", "expense_report_id",
"clanarina_id", "bank_statement_no", "bank_transaction_id"]
def _parse_csv_date(s: str) -> Optional[date]:
if not s or not s.strip():
return None
s = s.strip()
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d.%m.%Y.", "%d/%m/%Y", "%Y/%m/%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def _parse_csv_decimal(s: str) -> Optional[Decimal]:
if s is None or not str(s).strip():
return None
raw = str(s).strip().replace(" ", "")
# Croatian-style: 1.234,56 → 1234.56
if "," in raw and "." in raw:
raw = raw.replace(".", "").replace(",", ".")
elif "," in raw:
raw = raw.replace(",", ".")
try:
return Decimal(raw)
except (InvalidOperation, ValueError):
return None
def _parse_csv_int(s) -> Optional[int]:
if s is None or not str(s).strip():
return None
try:
return int(str(s).strip())
except ValueError:
return None
@router.post("/payments/import-csv")
async def payments_import_csv(file: UploadFile = File(...)):
"""Accepts CSV (UTF-8, header row). Required columns: payment_date,amount,currency,
payment_method,iban_from,iban_to,reference,description.
Optional: klub_id,invoice_id,expense_report_id,clanarina_id,bank_statement_no,
bank_transaction_id. All rows imported in a single transaction; bad rows are
skipped with an error entry."""
raw = await file.read()
if not raw:
raise HTTPException(400, "Empty file")
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(413, "File > 10 MB")
text: Optional[str] = None
for enc in ("utf-8-sig", "utf-8", "cp1250", "iso-8859-2", "latin-1"):
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise HTTPException(400, "Cannot decode CSV (try UTF-8)")
# Auto-detect delimiter (, vs ;)
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;|\t")
except csv.Error:
dialect = csv.excel
reader = csv.DictReader(StringIO(text), dialect=dialect)
if not reader.fieldnames:
raise HTTPException(400, "CSV has no header row")
missing = [c for c in _CSV_REQUIRED if c not in reader.fieldnames]
if missing:
raise HTTPException(400, f"CSV missing columns: {missing}")
errors: List[dict] = []
valid_rows: List[tuple] = []
for idx, row in enumerate(reader, start=2): # header is row 1
pd = _parse_csv_date(row.get("payment_date", ""))
if pd is None:
errors.append({"row": idx, "msg": f"bad payment_date: {row.get('payment_date')!r}"})
continue
amt = _parse_csv_decimal(row.get("amount", ""))
if amt is None or amt <= 0:
errors.append({"row": idx, "msg": f"bad amount: {row.get('amount')!r}"})
continue
method = (row.get("payment_method") or "").strip() or None
if method and method not in _ALLOWED_METHODS:
errors.append({"row": idx, "msg": f"bad payment_method: {method!r}"})
continue
valid_rows.append((
_parse_csv_int(row.get("klub_id")),
_parse_csv_int(row.get("invoice_id")),
_parse_csv_int(row.get("expense_report_id")),
_parse_csv_int(row.get("clanarina_id")),
pd,
amt,
(row.get("currency") or "EUR").strip() or "EUR",
method,
(row.get("iban_from") or "").strip() or None,
(row.get("iban_to") or "").strip() or None,
(row.get("reference") or "").strip() or None,
(row.get("description") or "").strip() or None,
(row.get("bank_statement_no") or "").strip() or None,
(row.get("bank_transaction_id") or "").strip() or None,
))
inserted = 0
if valid_rows:
def _do(cur):
nonlocal inserted
for tup in valid_rows:
cur.execute(
"INSERT INTO pgz_sport.payments "
"(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, "
" amount, currency, payment_method, iban_from, iban_to, reference, "
" description, bank_statement_no, bank_transaction_id, matched_status, "
" created_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now())",
tup,
)
inserted += 1
return inserted
db_tx(_do)
return {"ok": True, "inserted": inserted, "errors": errors,
"total_rows": inserted + len(errors)}
# ═══════════════════════════════════════════════════════════════════
# 14) HEALTH/DEBUG
# ═══════════════════════════════════════════════════════════════════
@router.get("/health")
def erp_health():
counts = {}
for t in ["kontni_plan", "partneri", "dnevnik_zapisa", "knjizenja",
"racuni_ulazni", "racuni_izlazni", "racun_stavke",
"zaposlenici", "place_obracun", "proracun",
"invoice_uploads", "expense_reports", "putni_nalog_racuni", "payments"]:
try:
r = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.{t}")
counts[t] = r["c"] if r else 0
except Exception as e:
counts[t] = f"ERR: {e}"
return {"ok": True, "tables": counts, "ts": datetime.now().isoformat()}