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