1d02c0897d
- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
1151 lines
52 KiB
Python
1151 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Fajl: routers/erp_full_router.py | v1.0.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.
|
|
# Mount: /api/v2/erp/*
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from io import BytesIO
|
|
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
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
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) 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"]:
|
|
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()}
|