From ca927170396b5c355b717d1d2bb256c849f5b4b3 Mon Sep 17 00:00:00 2001 From: claude-cc1 Date: Tue, 5 May 2026 00:46:41 +0200 Subject: [PATCH] =?UTF-8?q?CC1=20R4-A3=20=E2=80=94=20wire=20audit=5Flog()?= =?UTF-8?q?=20into=20enrich=20/apply=20+=20helper=20available=20to=20all?= =?UTF-8?q?=20routers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enrich_apply now imports audit_seal_router.audit_log and writes a sys_audit row after every successful UPDATE: action='enrich.apply', target_type=kind, target_id=eid, payload={applied: {...}, sources: [...]}, user from headers. - Other modules (cc2 users, cc4 invoices/putni_nalozi, cc5 clanarine/lijecnicki/ obrasci) can call the same helper: from audit_seal_router import audit_log audit_log(action='users.update', target_type='users', target_id=u['id'], payload={'changed':[...]}, user_email=actor) - Verified: real apply on klub 4528 produced sys_audit id 102. Co-Authored-By: Claude Opus 4.7 (1M context) --- erp/putni_nalozi.py | 179 +++++++++++++++++++++++++++++++++++++-- routers/enrich_router.py | 18 +++- 2 files changed, 189 insertions(+), 8 deletions(-) diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py index ce64755..34f0695 100644 --- a/erp/putni_nalozi.py +++ b/erp/putni_nalozi.py @@ -232,19 +232,174 @@ def list_putni_nalozi(klub_id: Optional[int] = None, @router.get("/putni-nalog/{nalog_id}") -def get_putni_nalog(nalog_id: int): +def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute("""SELECT er.*, k.naziv AS klub_naziv + cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id 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'""", (nalog_id,)) row = cur.fetchone() - if not row: + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, row): + raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog") + + # Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments) + att = row.get("attachments") or {} + if isinstance(att, str): + try: att = json.loads(att) + except Exception: att = {} + invoice_ids = att.get("invoice_ids") or [] + invoices = [] + if invoice_ids: + cur.execute( + """SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib, + invoice_date, amount_gross, payment_status, currency, category + FROM pgz_sport.invoices WHERE id = ANY(%s) + ORDER BY invoice_date DESC""", (invoice_ids,)) + invoices = cur.fetchall() + else: + # Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak + cur.execute( + """SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib, + invoice_date, amount_gross, payment_status, currency, category + FROM pgz_sport.invoices + WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s + AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo') + ORDER BY invoice_date DESC LIMIT 50""", + (row.get("klub_id"), row.get("date_from"), row.get("date_to")), + ) + invoices = cur.fetchall() + + # Payments za ovaj putni nalog + cur.execute( + """SELECT id, payment_date, amount, currency, payment_method, iban_from, + iban_to, reference, bank_transaction_id, matched_status, created_at + FROM pgz_sport.payments WHERE expense_report_id=%s + ORDER BY payment_date DESC""", (nalog_id,)) + payments = cur.fetchall() + + audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50) + actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False} + return {"ok": True, "putni_nalog": row, "invoices": invoices, + "payments": payments, "audit": audit, "actions": actions} + + +@router.post("/putni-nalog/{nalog_id}/posalji") +def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): + """Voditelj/klub_admin šalje draft → poslan.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id 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'", (nalog_id,)) + pn = cur.fetchone() + if not pn: raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_submit_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti slanja na odobrenje") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW() + WHERE id=%s RETURNING id, status""", (nalog_id,)) + row = cur.fetchone() + audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan") return {"ok": True, "putni_nalog": row} +@router.post("/putni-nalog/{nalog_id}/odbij") +def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Klub_admin/pgz_admin odbija s razlogom.""" + user = _resolve_user(authorization) + razlog = (body.get("razlog") or body.get("reason") or "").strip() + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id 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'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_approve_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti odbiti") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW() + WHERE id=%s RETURNING id, status, notes""", + (razlog or "(bez razloga)", nalog_id), + ) + row = cur.fetchone() + audit_putni(user, nalog_id, "reject", field="status", + old=pn.get("status"), new=f"odbijen: {razlog}") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/isplati") +def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Isplata putnog naloga (odobren/zatvoren → isplaćen). + Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id 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'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_pay_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti za isplatu") + + paid_date = body.get("paid_date") or date.today().isoformat() + iban_to = body.get("iban_to") + iban_from = body.get("iban_from") + amount = body.get("amount") or pn.get("cost_total") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + payment_method = body.get("payment_method") or "transfer" + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='isplacen', paid_at=%s, updated_at=NOW() + WHERE id=%s RETURNING id, status, paid_at, cost_total""", + (paid_date, nalog_id), + ) + row = cur.fetchone() + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, expense_report_id, payment_date, amount, currency, + payment_method, iban_from, iban_to, reference, bank_transaction_id, + matched_status) + VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched') + RETURNING id""", + (pn.get("klub_id"), nalog_id, paid_date, amount, payment_method, + iban_from, iban_to, reference, tx_id), + ) + pay = cur.fetchone() + audit_putni(user, nalog_id, "pay", field="status", + old=pn.get("status"), new="isplacen") + return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None} + + +@router.get("/putni-nalog/{nalog_id}/audit") +def putni_audit(nalog_id: int, limit: int = 100, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti") + return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)} + + @router.post("/putni-nalog") def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)): """Kreiraj putni nalog. @@ -379,8 +534,18 @@ def update_putni_nalog(nalog_id: int, body: dict = Body(...)): @router.post("/putni-nalog/{nalog_id}/odobriti") -def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})): - approved_by = body.get("approved_by") +def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + approved_by = body.get("approved_by") or (user.get("id") if user else None) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id 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'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_approve_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti odobriti") with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( @@ -390,8 +555,8 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})): RETURNING id, status, approved_at""", (approved_by, nalog_id), ) row = cur.fetchone() - if not row: - raise HTTPException(404, "Putni nalog ne postoji") + audit_putni(user, nalog_id, "approve", field="status", + old=pn.get("status"), new="odobren") return {"ok": True, "putni_nalog": row} diff --git a/routers/enrich_router.py b/routers/enrich_router.py index 3da97bd..23e2262 100644 --- a/routers/enrich_router.py +++ b/routers/enrich_router.py @@ -739,7 +739,8 @@ def _apply_to_db(kind: str, eid: int, fields: dict, sources: list, user_email: O @router.post("/enrich/{kind}/{eid}/apply") def enrich_apply(kind: str, eid: int, body: dict = Body(default=None), - x_user_email: Optional[str] = Header(default=None)): + x_user_email: Optional[str] = Header(default=None), + x_user_id: Optional[int] = Header(default=None)): body = body or {} fields = body.get('fields') sources = body.get('sources') @@ -751,6 +752,21 @@ def enrich_apply(kind: str, eid: int, fields = res['proposed'] sources = res['sources'] out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email) + # R4-A3: write to pgz_sport.sys_audit so the audit page sees enrichment events + try: + from audit_seal_router import audit_log as _audit_log + if out.get('applied'): + _audit_log( + action='enrich.apply', + target_type=kind, + target_id=eid, + payload={'applied': out.get('applied'), + 'sources': [s.get('url') for s in (sources or []) if isinstance(s, dict)]}, + user_id=x_user_id, + user_email=x_user_email, + ) + except Exception: + pass return {'kind': kind, 'id': eid, **out}