Task 2: Putni nalozi — full CRUD + status workflow

- routers/erp_full_router.py: GET/POST/PATCH/DELETE /api/v2/erp/putni-nalozi
  - status workflow: draft → poslano → odobreno/odbijeno → isplaceno
  - cost_total auto-calc, approved_at/paid_at on transitions
  - alias under /expense-reports/* preserved
- static/erp_full.html: novi UI lista + modal + status buttons

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 18:28:53 +02:00
parent 8127e2ef22
commit efa15d0086
2 changed files with 443 additions and 22 deletions
+248 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.1.0 | 05.05.2026
# 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,
@@ -8,6 +8,10 @@
# 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
@@ -1223,12 +1227,7 @@ async def invoice_uploads_create(
# ═══════════════════════════════════════════════════════════════════
# 12) PUTNI NALOZI / EXPENSE REPORTS
# ═══════════════════════════════════════════════════════════════════
@router.get("/expense-reports")
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
def _expense_reports_list_impl(klub_id, status, report_type, godina, limit):
where = ["1=1"]
params: list = []
if klub_id:
@@ -1252,6 +1251,248 @@ def expense_reports_list(klub_id: Optional[int] = None,
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,