From efa15d00862e1a48e8423daf62165127869ef753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 18:28:53 +0200 Subject: [PATCH] =?UTF-8?q?Task=202:=20Putni=20nalozi=20=E2=80=94=20full?= =?UTF-8?q?=20CRUD=20+=20status=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- routers/erp_full_router.py | 255 ++++++++++++++++++++++++++++++++++++- static/erp_full.html | 210 +++++++++++++++++++++++++++--- 2 files changed, 443 insertions(+), 22 deletions(-) diff --git a/routers/erp_full_router.py b/routers/erp_full_router.py index b5563c6..a4bcd68 100644 --- a/routers/erp_full_router.py +++ b/routers/erp_full_router.py @@ -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ć / 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, diff --git a/static/erp_full.html b/static/erp_full.html index ab32bfd..38300c9 100644 --- a/static/erp_full.html +++ b/static/erp_full.html @@ -90,6 +90,11 @@ table tbody tr:hover{background:var(--bg3)} .badge.knjizen{background:var(--green);color:var(--bg0)} .badge.placen{background:var(--pgz-gold);color:var(--bg0)} .badge.otkazan{background:var(--red);color:#fff} +.badge.draft{background:var(--bg4);color:var(--t1)} +.badge.poslano{background:var(--cyan);color:var(--bg0)} +.badge.odobreno{background:var(--green);color:var(--bg0)} +.badge.odbijeno{background:var(--red);color:#fff} +.badge.isplaceno{background:var(--pgz-gold);color:var(--bg0)} .dnev-line-row{display:grid;grid-template-columns:140px 1fr 100px 100px 1fr 30px;gap:6px;margin-bottom:6px;align-items:center} .dnev-line-row input,.dnev-line-row select{background:var(--bg2);border:1px solid var(--rim);border-radius:4px;padding:5px 8px;color:var(--t1);font-size:12px;width:100%} @@ -247,15 +252,19 @@ table tbody tr:hover{background:var(--bg3)}
-
Putni nalozi i ostali troškovi (expense_reports)
+
+
Putni nalozi (expense_reports)
+ +
- - + + +
-
#TipKlubOdredišteSvrhaOdDoKmTrošakDnevniceStatus
Klikni "Osvježi"…
+
#BrKlubDestinacijaOdDoKmCost totalStatusAkcije
Klikni "Osvježi"…
+ +