Files
pgz-sport/routers/erp_full_router.py
T
damir f7b5114f58 PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)
nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
2026-05-05 13:51:07 +02:00

1327 lines
60 KiB
Python

#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.1.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).
# Mount: /api/v2/erp/*
# ═══════════════════════════════════════════════════════════════════
from __future__ import annotations
import hashlib
import os
import re
from datetime import date, datetime
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from typing import Optional, List
from xml.etree import ElementTree as ET
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
# ── 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
# ═══════════════════════════════════════════════════════════════════
@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):
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("/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}
# ═══════════════════════════════════════════════════════════════════
# 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()}