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