diff --git a/erp/ocr.py b/erp/ocr.py index 12433fe..b90db69 100644 --- a/erp/ocr.py +++ b/erp/ocr.py @@ -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)): diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py index f704f0f..3322d6c 100644 --- a/erp/putni_nalozi.py +++ b/erp/putni_nalozi.py @@ -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)