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}
@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")
def invoices_pay(invoice_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
+294
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json
from datetime import datetime, date, timedelta
from pathlib import Path
from typing import Optional, Any
import psycopg2
@@ -770,3 +771,296 @@ def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
ct = cur.fetchone()
if ct: row["cost_total"] = ct["cost_total"]
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)