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:
+248
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user