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