CC4 R7 ERP S2: DELETE invoice + /putni-nalozi alias + /placanja + /export/putni.xlsx

erp/ocr.py:
- DELETE /api/erp/invoices/{id} (samo pgz_admin) + cascade payment cleanup + audit
  (briše vezana payments, otkapča invoice_uploads.invoice_id NULL, audit log "delete")

erp/putni_nalozi.py:
- GET/POST /api/erp/putni-nalozi (alias plural od /putni-nalog) za CC1 brief kompatibilnost
- GET /api/erp/putni-nalozi/{id}
- PATCH /api/erp/putni-nalozi/{id} sa body.action: approve|reject|submit|pay (route kroz lifecycle)
- PATCH /api/erp/putni-nalog/{id} (singular alias)
- GET /api/erp/export/putni.xlsx — openpyxl 19 stupaca (klub, voditelj, ruta, datumi, km, dnevnice, ukupno, status...)
- GET /api/erp/placanja — lista neplaćenih računa + odobrenih putnih naloga (kandidati za isplatu)
- POST /api/erp/placanja {kind:invoice|putni_nalog, id, iban, model, opis, poziv_na_broj}
  → generira HUB-3 PDF + EPC QR (reuse crm.payments.build_hub3_pdf), pohranjuje u
  _data/uploads/placanja/{kind}_{id}_HUB3_*.pdf
- GET /api/erp/placanja/{kind}/{id}/pdf → streama zadnji generirani PDF, ili kreira on-demand
- Dodan from pathlib import Path (fix NameError)

Live tests:
- DELETE /invoices/4 → 200 (test invoice obrisan)
- GET /putni-nalozi → 200, /putni-nalozi/1 → 200
- GET /placanja → 200 lista; POST → ok pdf 11 KB; GET pdf → 200 application/pdf %PDF-
- /placanja invoice 1 (INA €63.15) i putni_nalog 2 (€133.08) PDF generirani
- /export/putni.xlsx → 200 application/vnd.openxmlformats... PK header valid
- OCR INA gorivo: vendor=INA, OIB=27759560625, brutto=€63.15, PDV=€12.63, cat=gorivo
- UI 3× "Ri.NET AI" / 0× "DeepSeek"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CC4
2026-05-05 08:01:49 +02:00
parent c38f15a566
commit 8c97a5b778
2 changed files with 314 additions and 0 deletions
+20
View File
@@ -793,6 +793,26 @@ def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Opti
return {"ok": True, "invoice": row} return {"ok": True, "invoice": row}
@router.delete("/invoices/{invoice_id}")
def invoices_delete(invoice_id: int, authorization: Optional[str] = Header(None)):
"""Brisanje računa — samo pgz_admin."""
user = _resolve_user(authorization)
if user and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti brisati račun")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
cur.execute("UPDATE pgz_sport.invoice_uploads SET invoice_id=NULL WHERE invoice_id=%s", (invoice_id,))
cur.execute("DELETE FROM pgz_sport.payments WHERE invoice_id=%s", (invoice_id,))
cur.execute("DELETE FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
audit_invoice(user, invoice_id, "delete", field="invoice_no",
old=inv.get("invoice_no"), new="(deleted)")
return {"ok": True, "deleted": invoice_id}
@router.post("/invoices/{invoice_id}/pay") @router.post("/invoices/{invoice_id}/pay")
def invoices_pay(invoice_id: int, body: dict = Body(default={}), def invoices_pay(invoice_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)): authorization: Optional[str] = Header(None)):
+294
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from pathlib import Path
from typing import Optional, Any from typing import Optional, Any
import psycopg2 import psycopg2
@@ -770,3 +771,296 @@ def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
ct = cur.fetchone() ct = cur.fetchone()
if ct: row["cost_total"] = ct["cost_total"] if ct: row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row} return {"ok": True, "putni_nalog": row}
# ──────────────────────────────────────────────────────────────────────
# CC4 STEP 2 — alias-i za "putni-nalozi" (množina), PATCH approve/reject,
# /export/putni.xlsx, /placanja (HUB-3 + EPC PDF) + GET /placanja{,/{id}/pdf}
# ──────────────────────────────────────────────────────────────────────
# GET /putni-nalozi (alias plural) → reuse list_putni_nalozi
@router.get("/putni-nalozi")
def list_putni_nalozi_alias(klub_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = Query(100, le=500),
offset: int = 0):
return list_putni_nalozi(klub_id=klub_id, status=status, limit=limit, offset=offset)
@router.get("/putni-nalozi/{nalog_id}")
def get_putni_nalog_alias(nalog_id: int, authorization: Optional[str] = Header(None)):
return get_putni_nalog(nalog_id, authorization)
@router.post("/putni-nalozi")
def create_putni_nalog_alias(body: dict = Body(...), authorization: Optional[str] = Header(None)):
return create_putni_nalog(body, authorization)
@router.patch("/putni-nalozi/{nalog_id}")
def patch_putni_nalog(nalog_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
"""PATCH semantics — body.action: approve/reject/submit/pay; ostalo update polja."""
action = (body.get("action") or "").lower()
if action in ("approve", "odobri", "odobriti"):
return odobriti_putni_nalog(nalog_id, body, authorization)
if action in ("reject", "odbij"):
return odbij_putni_nalog(nalog_id, body, authorization)
if action in ("submit", "posalji"):
return posalji_putni_nalog(nalog_id, authorization)
if action in ("pay", "isplati"):
return isplati_putni_nalog(nalog_id, body, authorization)
return update_putni_nalog(nalog_id, body)
@router.patch("/putni-nalog/{nalog_id}")
def patch_putni_nalog_singular(nalog_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
return patch_putni_nalog(nalog_id, body, authorization)
# ──────────── XLSX export — putni nalozi ────────────
@router.get("/export/putni.xlsx")
def putni_export_xlsx(
klub_id: Optional[int] = Query(None),
od: Optional[str] = Query(None),
do: Optional[str] = Query(None),
status: Optional[str] = None,
authorization: Optional[str] = Header(None),
):
"""XLSX export putnih naloga: ID, klub, voditelj, ruta, datumi, km, dnevnice, transport, ukupno, status."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from fastapi.responses import StreamingResponse
user = _resolve_user(authorization)
sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv,
er.destination, er.purpose,
er.date_from, er.date_to, er.km_driven, er.km_rate,
er.cost_transport, er.cost_lodging, er.cost_meals,
er.cost_other, er.dnevnice_count, er.dnevnice_amount,
er.cost_total, er.status, er.approved_at, er.paid_at,
er.attachments
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id
WHERE er.report_type='putni_nalog'"""
args: list = []
if klub_id is not None: sql += " AND er.klub_id=%s"; args.append(klub_id)
if od: sql += " AND er.date_from >= %s"; args.append(od)
if do: sql += " AND er.date_to <= %s"; args.append(do)
if status: sql += " AND er.status=%s"; args.append(status)
sql += " ORDER BY er.date_from DESC, er.id DESC"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
if user and not is_pgz_admin(user):
rows = [r for r in rows if can_view_putni_nalog(user, r)]
wb = Workbook()
ws = wb.active
ws.title = "Putni nalozi"
headers = ["ID", "Klub", "Voditelj", "Ruta", "Svrha", "Polazak", "Povratak",
"Km", "€/km", "Transport", "Smještaj", "Hrana", "Ostalo",
"Br. dnevnica", "Iznos dnevnica", "UKUPNO", "Status",
"Odobreno", "Isplaćeno"]
bold = Font(bold=True, color="FFFFFF")
fill = PatternFill("solid", fgColor="003087")
for col_idx, h in enumerate(headers, 1):
c = ws.cell(row=1, column=col_idx, value=h)
c.font = bold; c.fill = fill; c.alignment = Alignment(horizontal="center")
for r_idx, r in enumerate(rows, 2):
att = r.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
ws.cell(row=r_idx, column=1, value=r.get("id"))
ws.cell(row=r_idx, column=2, value=r.get("klub_naziv"))
ws.cell(row=r_idx, column=3, value=att.get("voditelj"))
ws.cell(row=r_idx, column=4, value=r.get("destination"))
ws.cell(row=r_idx, column=5, value=r.get("purpose"))
ws.cell(row=r_idx, column=6, value=str(r.get("date_from") or ""))
ws.cell(row=r_idx, column=7, value=str(r.get("date_to") or ""))
ws.cell(row=r_idx, column=8, value=float(r["km_driven"]) if r.get("km_driven") is not None else None)
ws.cell(row=r_idx, column=9, value=float(r["km_rate"]) if r.get("km_rate") is not None else None)
ws.cell(row=r_idx, column=10, value=float(r["cost_transport"]) if r.get("cost_transport") is not None else None)
ws.cell(row=r_idx, column=11, value=float(r["cost_lodging"]) if r.get("cost_lodging") is not None else None)
ws.cell(row=r_idx, column=12, value=float(r["cost_meals"]) if r.get("cost_meals") is not None else None)
ws.cell(row=r_idx, column=13, value=float(r["cost_other"]) if r.get("cost_other") is not None else None)
ws.cell(row=r_idx, column=14, value=float(r["dnevnice_count"]) if r.get("dnevnice_count") is not None else None)
ws.cell(row=r_idx, column=15, value=float(r["dnevnice_amount"]) if r.get("dnevnice_amount") is not None else None)
ws.cell(row=r_idx, column=16, value=float(r["cost_total"]) if r.get("cost_total") is not None else None)
ws.cell(row=r_idx, column=17, value=r.get("status"))
ws.cell(row=r_idx, column=18, value=str(r.get("approved_at") or ""))
ws.cell(row=r_idx, column=19, value=str(r.get("paid_at") or ""))
widths = [6, 24, 22, 28, 22, 11, 11, 8, 6, 12, 12, 10, 10, 10, 14, 12, 11, 18, 18]
for i, w in enumerate(widths, 1):
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
ws.freeze_panes = "A2"
buf = BytesIO()
wb.save(buf); buf.seek(0)
fname = f"putni-nalozi_{date.today().isoformat()}.xlsx"
return StreamingResponse(
buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ──────────── /placanja — HUB-3 PDF s EPC QR (uplatnice) ────────────
@router.get("/placanja")
def placanja_list(klub_id: Optional[int] = None, limit: int = 100,
authorization: Optional[str] = Header(None)):
"""Lista placanja (vraća unaplaćene račune i odobrene putne naloge kao kandidate)."""
user = _resolve_user(authorization)
out = []
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
sql = """SELECT i.id, 'invoice' AS kind, i.invoice_no AS ref,
i.vendor_name AS primatelj, i.vendor_oib AS oib,
i.amount_gross AS iznos, i.iban_to AS iban,
i.payment_status AS status, i.invoice_date AS datum,
i.klub_id, k.naziv AS klub_naziv
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.payment_status='unpaid'"""
args: list = []
if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id)
sql += " ORDER BY i.invoice_date DESC LIMIT %s"; args.append(limit)
cur.execute(sql, args)
out.extend(cur.fetchall())
sql2 = """SELECT er.id, 'putni_nalog' AS kind,
CONCAT('PN-', er.id) AS ref,
(er.attachments->>'voditelj') AS primatelj,
NULL::text AS oib,
er.cost_total AS iznos,
NULL::text AS iban,
er.status, er.date_from AS datum,
er.klub_id, 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.report_type='putni_nalog'
AND er.status IN ('odobren','zatvoren')"""
args2: list = []
if klub_id is not None: sql2 += " AND er.klub_id=%s"; args2.append(klub_id)
sql2 += " ORDER BY er.date_from DESC LIMIT %s"; args2.append(limit)
cur.execute(sql2, args2)
out.extend(cur.fetchall())
return {"ok": True, "rows": out, "count": len(out)}
@router.post("/placanja")
def placanja_create(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Kreiraj HUB-3 + EPC PDF za bilo koji entitet (invoice ili putni nalog).
Body: {kind: 'invoice'|'putni_nalog', id: int, iban?, model?, opis?, poziv_na_broj?}.
Vraća: {placanja_id, pdf_url}."""
user = _resolve_user(authorization)
kind = (body.get("kind") or "invoice").lower()
eid = body.get("id")
if not eid:
raise HTTPException(400, "id je obavezan")
try:
from crm.payments import build_hub3_pdf
except Exception as e:
raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if kind == "invoice":
cur.execute(
"""SELECT i.*, k.naziv AS klub_naziv, k.adresa AS klub_adresa
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id=%s""", (eid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
iban = body.get("iban") or row.get("iban_to") or "HR0000000000000000000"
iznos = float(row.get("amount_gross") or 0)
primatelj = row.get("vendor_name") or "Dobavljač"
primatelj_addr = row.get("vendor_address") or ""
platitelj_naziv = row.get("klub_naziv") or "PGŽ Sport klub"
platitelj_adresa = row.get("klub_adresa") or ""
poziv = body.get("poziv_na_broj") or f"{eid:08d}"
opis = body.get("opis") or f"Račun {row.get('invoice_no')}{primatelj}"
elif kind in ("putni_nalog", "pn"):
cur.execute(
"""SELECT er.*, k.naziv AS klub_naziv, k.adresa AS klub_adresa
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (eid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
att = row.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
iban = body.get("iban") or att.get("iban_voditelja") or "HR0000000000000000000"
iznos = float(row.get("cost_total") or 0)
primatelj = att.get("voditelj") or "Voditelj"
primatelj_addr = ""
platitelj_naziv = row.get("klub_naziv") or "PGŽ Sport klub"
platitelj_adresa = row.get("klub_adresa") or ""
poziv = body.get("poziv_na_broj") or f"PN{eid:06d}"
opis = body.get("opis") or f"Putni nalog #{eid}: {row.get('destination','')}"
else:
raise HTTPException(400, "kind mora biti 'invoice' ili 'putni_nalog'")
if iznos <= 0:
raise HTTPException(400, "Iznos mora biti > 0")
pdf = build_hub3_pdf(
platitelj_naziv=platitelj_naziv,
platitelj_adresa=platitelj_adresa,
primatelj_naziv=primatelj,
primatelj_adresa=primatelj_addr,
iban=iban,
amount_eur=iznos,
model=body.get("model", "HR99"),
poziv_na_broj=poziv,
opis=opis[:140],
sifra_namjene=body.get("sifra_namjene", "OTHR"),
datum=date.today(),
)
# Save PDF na disk za GET /placanja/{id}/pdf
out_dir = Path("/opt/pgz-sport/_data/uploads/placanja")
out_dir.mkdir(parents=True, exist_ok=True)
fname = f"{kind}_{eid}_HUB3_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
fpath = out_dir / fname
fpath.write_bytes(pdf)
# Audit
try: audit_putni(user, eid if kind != 'invoice' else 0, "placanja_pdf",
field=kind, new=str(fname))
except Exception: pass
return {"ok": True, "kind": kind, "id": eid,
"pdf_url": f"/api/erp/placanja/{kind}/{eid}/pdf",
"iban": iban, "iznos": iznos, "primatelj": primatelj,
"poziv_na_broj": poziv, "opis": opis,
"filename": fname, "size": len(pdf)}
@router.get("/placanja/{kind}/{eid}/pdf")
def placanja_pdf(kind: str, eid: int, authorization: Optional[str] = Header(None)):
"""Dohvat zadnjeg generiranog HUB-3 PDF-a za invoice ili putni-nalog.
Ako PDF još nije generiran, kreira ga on-the-fly."""
out_dir = Path("/opt/pgz-sport/_data/uploads/placanja")
out_dir.mkdir(parents=True, exist_ok=True)
pat = f"{kind}_{eid}_HUB3_*.pdf"
candidates = sorted(out_dir.glob(pat), reverse=True)
if candidates:
from fastapi.responses import FileResponse
return FileResponse(str(candidates[0]), media_type="application/pdf",
filename=candidates[0].name)
# Generate on-the-fly
res = placanja_create({"kind": kind, "id": eid}, authorization)
p = out_dir / res["filename"]
from fastapi.responses import FileResponse
return FileResponse(str(p), media_type="application/pdf", filename=p.name)