From 55a27fb315bb2f779d6351a9437a67397a6d1300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 18:29:51 +0200 Subject: [PATCH] =?UTF-8?q?Task=203:=20Pla=C4=87anja=20=E2=80=94=20POST/PA?= =?UTF-8?q?TCH=20+=20CSV=20batch=20import=20+=20SEPA=20XML=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routers/erp_full_router.py: POST/PATCH/import-csv/sepa-export - static/erp_full.html: high-end UI s match workflow + SEPA export + summary tiles Co-Authored-By: Claude Opus 4.7 (1M context) --- routers/erp_full_router.py | 416 ++++++++++++++++++++++++++++++++++++- static/erp_full.html | 226 ++++++++++++++++++-- 2 files changed, 615 insertions(+), 27 deletions(-) diff --git a/routers/erp_full_router.py b/routers/erp_full_router.py index b5563c6..c64422f 100644 --- a/routers/erp_full_router.py +++ b/routers/erp_full_router.py @@ -12,21 +12,25 @@ # ═══════════════════════════════════════════════════════════════════ from __future__ import annotations +import csv import hashlib +import io import os import re +import uuid from datetime import date, datetime -from decimal import Decimal -from io import BytesIO +from decimal import Decimal, InvalidOperation +from io import BytesIO, StringIO from pathlib import Path from typing import Optional, List from xml.etree import ElementTree as ET +from xml.sax.saxutils import escape as xml_escape import psycopg2 import psycopg2.extras from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form -from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from fastapi.responses import StreamingResponse, Response +from pydantic import BaseModel, Field # ── Upload destination (relative to web root /uploads/...) ────────── UPLOAD_BASE = Path("/opt/pgz-sport/uploads") @@ -1308,6 +1312,410 @@ def payments_list(klub_id: Optional[int] = None, return {"count": len(rows), "rows": rows} +# ── GET /payments/sepa-export — must be before /payments/{id} ─────── +def _parse_ids_param(ids: Optional[str]) -> List[int]: + if not ids: + return [] + out: List[int] = [] + for chunk in ids.split(","): + chunk = chunk.strip() + if not chunk: + continue + try: + out.append(int(chunk)) + except ValueError: + raise HTTPException(400, f"Bad id in 'ids': {chunk!r}") + return out + + +@router.get("/payments/sepa-export") +def payments_sepa_export( + ids: Optional[str] = Query(None, description="CSV id list, e.g. 1,2,3"), + godina: Optional[int] = None, + klub_id: Optional[int] = None, + matched_status: Optional[str] = None, + payment_method: Optional[str] = None, +): + """Generate pain.001.001.03 SEPA Credit Transfer XML — MOCK / placeholder. + Either pass ?ids=1,2,3 OR filter (godina/klub_id/matched_status/payment_method).""" + where = ["1=1"] + params: list = [] + id_list = _parse_ids_param(ids) + if id_list: + where.append("p.id = ANY(%s)") + params.append(id_list) + else: + if godina: + where.append("EXTRACT(YEAR FROM p.payment_date)=%s"); params.append(godina) + if klub_id: + where.append("p.klub_id=%s"); params.append(klub_id) + if matched_status: + where.append("p.matched_status=%s"); params.append(matched_status) + if payment_method: + where.append("p.payment_method=%s"); params.append(payment_method) + + rows = db_query( + "SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.payment_date, p.amount, " + "p.currency, p.iban_from, p.iban_to, p.reference, p.description " + "FROM pgz_sport.payments p " + "LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id " + f"WHERE {' AND '.join(where)} ORDER BY p.payment_date, p.id LIMIT 5000", + tuple(params)) + + debtor_name = os.environ.get("PGZ_SEPA_DEBTOR_NAME", "PGŽ Sportski savez") + debtor_iban = os.environ.get("PGZ_SEPA_DEBTOR_IBAN", "HR0000000000000000000") + debtor_bic = os.environ.get("PGZ_SEPA_DEBTOR_BIC", "NOTPROVIDED") + + now = datetime.now() + msg_id = f"PGZ-{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}" + pmt_inf_id = f"{msg_id}-PMT01" + creation_dt = now.strftime("%Y-%m-%dT%H:%M:%S") + nb_of_txs = len(rows) + ctrl_sum = sum((Decimal(str(r["amount"])) for r in rows if r["amount"] is not None), Decimal("0")) + ctrl_sum_str = f"{ctrl_sum:.2f}" + + requested_exec_date = (rows[0]["payment_date"] if rows and rows[0]["payment_date"] else now.date()).isoformat() + + parts: List[str] = [] + parts.append('') + parts.append('') + parts.append('') + parts.append(' ') + # Group Header + parts.append(' ') + parts.append(f' {xml_escape(msg_id)}') + parts.append(f' {creation_dt}') + parts.append(f' {nb_of_txs}') + parts.append(f' {ctrl_sum_str}') + parts.append(' ') + parts.append(f' {xml_escape(debtor_name)}') + parts.append(' ') + parts.append(' ') + # Payment Information + parts.append(' ') + parts.append(f' {xml_escape(pmt_inf_id)}') + parts.append(' TRF') + parts.append(f' {nb_of_txs}') + parts.append(f' {ctrl_sum_str}') + parts.append(' SEPA') + parts.append(f' {requested_exec_date}') + parts.append(' ') + parts.append(f' {xml_escape(debtor_name)}') + parts.append(' ') + parts.append(' ') + parts.append(f' {xml_escape(debtor_iban)}') + parts.append(' ') + parts.append(' ') + parts.append(f' {xml_escape(debtor_bic)}') + parts.append(' ') + parts.append(' SLEV') + + for r in rows: + amt = r["amount"] if r["amount"] is not None else Decimal("0") + try: + amt_str = f"{Decimal(str(amt)):.2f}" + except (InvalidOperation, TypeError): + amt_str = "0.00" + ccy = (r.get("currency") or "EUR")[:3] + cdtr_name = r.get("klub_naziv") or (f"Klub #{r['klub_id']}" if r.get("klub_id") else "Beneficiary") + cdtr_iban = (r.get("iban_to") or "").strip() or "HR0000000000000000000" + end_to_end = (r.get("reference") or f"PGZ-PAY-{r['id']}")[:35] + rmt = (r.get("description") or r.get("reference") or f"Payment #{r['id']}")[:140] + + parts.append(' ') + parts.append(' ') + parts.append(f' {xml_escape(end_to_end)}') + parts.append(' ') + parts.append(' ') + parts.append(f' {amt_str}') + parts.append(' ') + parts.append(' ') + parts.append(f' {xml_escape(cdtr_name)}') + parts.append(' ') + parts.append(' ') + parts.append(f' {xml_escape(cdtr_iban)}') + parts.append(' ') + parts.append(' ') + parts.append(f' {xml_escape(rmt)}') + parts.append(' ') + parts.append(' ') + + parts.append(' ') + parts.append(' ') + parts.append('') + + xml_body = "\n".join(parts).encode("utf-8") + fname = f"sepa_export_{now.strftime('%Y%m%d_%H%M%S')}.xml" + return Response( + content=xml_body, + media_type="application/xml", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +# ── GET /payments/{id} — single record + linked info ──────────────── +@router.get("/payments/{rid}") +def payments_get(rid: int): + row = db_one( + "SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.invoice_id, " + "p.expense_report_id, p.clanarina_id, p.payment_date, p.amount, p.currency, " + "p.payment_method, p.iban_from, p.iban_to, p.reference, p.description, " + "p.bank_statement_no, p.bank_transaction_id, p.matched_status, " + "p.matched_by, p.matched_at, p.created_at, " + "i.invoice_no, i.vendor_name, i.amount_gross AS invoice_amount, " + "er.report_no, er.purpose AS expense_purpose, er.cost_total AS expense_total " + "FROM pgz_sport.payments p " + "LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id " + "LEFT JOIN pgz_sport.invoices i ON i.id=p.invoice_id " + "LEFT JOIN pgz_sport.expense_reports er ON er.id=p.expense_report_id " + "WHERE p.id=%s", + (rid,)) + if not row: + raise HTTPException(404, f"Payment id={rid} not found") + # Try clanarina (table may exist optionally) + if row.get("clanarina_id"): + try: + cl = db_one( + "SELECT id, godina, iznos, status FROM pgz_sport.clanarine WHERE id=%s", + (row["clanarina_id"],)) + if cl: + row["clanarina"] = cl + except Exception: + row["clanarina"] = None + return row + + +# ── POST /payments — create ───────────────────────────────────────── +class PaymentIn(BaseModel): + klub_id: Optional[int] = None + invoice_id: Optional[int] = None + expense_report_id: Optional[int] = None + clanarina_id: Optional[int] = None + payment_date: date + amount: Decimal + currency: str = "EUR" + payment_method: Optional[str] = None + iban_from: Optional[str] = None + iban_to: Optional[str] = None + reference: Optional[str] = None + description: Optional[str] = None + bank_statement_no: Optional[str] = None + bank_transaction_id: Optional[str] = None + + +_ALLOWED_METHODS = {"iban", "cash", "card", "sepa", "transfer"} + + +@router.post("/payments") +def payments_create(body: PaymentIn): + if body.amount is None or Decimal(str(body.amount)) <= 0: + raise HTTPException(400, "amount must be > 0") + if body.payment_method and body.payment_method not in _ALLOWED_METHODS: + raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}") + new_id = db_exec( + "INSERT INTO pgz_sport.payments " + "(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, amount, " + " currency, payment_method, iban_from, iban_to, reference, description, " + " bank_statement_no, bank_transaction_id, matched_status, created_at) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now()) " + "RETURNING id", + (body.klub_id, body.invoice_id, body.expense_report_id, body.clanarina_id, + body.payment_date, body.amount, body.currency or "EUR", + body.payment_method, body.iban_from, body.iban_to, + body.reference, body.description, + body.bank_statement_no, body.bank_transaction_id), + returning=True, + ) + return payments_get(new_id) + + +# ── PATCH /payments/{id} — partial update + manual matching ───────── +class PaymentPatch(BaseModel): + klub_id: Optional[int] = None + invoice_id: Optional[int] = None + expense_report_id: Optional[int] = None + clanarina_id: Optional[int] = None + payment_date: Optional[date] = None + amount: Optional[Decimal] = None + currency: Optional[str] = None + payment_method: Optional[str] = None + iban_from: Optional[str] = None + iban_to: Optional[str] = None + reference: Optional[str] = None + description: Optional[str] = None + bank_statement_no: Optional[str] = None + bank_transaction_id: Optional[str] = None + matched_status: Optional[str] = None + matched_by: Optional[str] = None + + +@router.patch("/payments/{rid}") +def payments_patch(rid: int, body: PaymentPatch): + existing = db_one("SELECT id FROM pgz_sport.payments WHERE id=%s", (rid,)) + if not existing: + raise HTTPException(404, f"Payment id={rid} not found") + data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True) + if "amount" in data and data["amount"] is not None and Decimal(str(data["amount"])) <= 0: + raise HTTPException(400, "amount must be > 0") + if "payment_method" in data and data["payment_method"] and data["payment_method"] not in _ALLOWED_METHODS: + raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}") + if not data: + return payments_get(rid) + + set_parts: List[str] = [] + params: list = [] + for col, val in data.items(): + set_parts.append(f"{col}=%s") + params.append(val) + + # Manual matching: when matched_status flips to 'matched', stamp matched_at + if data.get("matched_status") == "matched": + set_parts.append("matched_at=now()") + + params.append(rid) + db_exec( + f"UPDATE pgz_sport.payments SET {', '.join(set_parts)} WHERE id=%s", + tuple(params), + ) + return payments_get(rid) + + +# ── POST /payments/import-csv — batch import ──────────────────────── +_CSV_REQUIRED = ["payment_date", "amount", "currency", "payment_method", + "iban_from", "iban_to", "reference", "description"] +_CSV_OPTIONAL = ["klub_id", "invoice_id", "expense_report_id", + "clanarina_id", "bank_statement_no", "bank_transaction_id"] + + +def _parse_csv_date(s: str) -> Optional[date]: + if not s or not s.strip(): + return None + s = s.strip() + for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d.%m.%Y.", "%d/%m/%Y", "%Y/%m/%d"): + try: + return datetime.strptime(s, fmt).date() + except ValueError: + continue + return None + + +def _parse_csv_decimal(s: str) -> Optional[Decimal]: + if s is None or not str(s).strip(): + return None + raw = str(s).strip().replace(" ", "") + # Croatian-style: 1.234,56 → 1234.56 + if "," in raw and "." in raw: + raw = raw.replace(".", "").replace(",", ".") + elif "," in raw: + raw = raw.replace(",", ".") + try: + return Decimal(raw) + except (InvalidOperation, ValueError): + return None + + +def _parse_csv_int(s) -> Optional[int]: + if s is None or not str(s).strip(): + return None + try: + return int(str(s).strip()) + except ValueError: + return None + + +@router.post("/payments/import-csv") +async def payments_import_csv(file: UploadFile = File(...)): + """Accepts CSV (UTF-8, header row). Required columns: payment_date,amount,currency, + payment_method,iban_from,iban_to,reference,description. + Optional: klub_id,invoice_id,expense_report_id,clanarina_id,bank_statement_no, + bank_transaction_id. All rows imported in a single transaction; bad rows are + skipped with an error entry.""" + raw = await file.read() + if not raw: + raise HTTPException(400, "Empty file") + if len(raw) > 10 * 1024 * 1024: + raise HTTPException(413, "File > 10 MB") + + text: Optional[str] = None + for enc in ("utf-8-sig", "utf-8", "cp1250", "iso-8859-2", "latin-1"): + try: + text = raw.decode(enc) + break + except UnicodeDecodeError: + continue + if text is None: + raise HTTPException(400, "Cannot decode CSV (try UTF-8)") + + # Auto-detect delimiter (, vs ;) + sample = text[:4096] + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;|\t") + except csv.Error: + dialect = csv.excel + reader = csv.DictReader(StringIO(text), dialect=dialect) + + if not reader.fieldnames: + raise HTTPException(400, "CSV has no header row") + missing = [c for c in _CSV_REQUIRED if c not in reader.fieldnames] + if missing: + raise HTTPException(400, f"CSV missing columns: {missing}") + + errors: List[dict] = [] + valid_rows: List[tuple] = [] + + for idx, row in enumerate(reader, start=2): # header is row 1 + pd = _parse_csv_date(row.get("payment_date", "")) + if pd is None: + errors.append({"row": idx, "msg": f"bad payment_date: {row.get('payment_date')!r}"}) + continue + amt = _parse_csv_decimal(row.get("amount", "")) + if amt is None or amt <= 0: + errors.append({"row": idx, "msg": f"bad amount: {row.get('amount')!r}"}) + continue + method = (row.get("payment_method") or "").strip() or None + if method and method not in _ALLOWED_METHODS: + errors.append({"row": idx, "msg": f"bad payment_method: {method!r}"}) + continue + valid_rows.append(( + _parse_csv_int(row.get("klub_id")), + _parse_csv_int(row.get("invoice_id")), + _parse_csv_int(row.get("expense_report_id")), + _parse_csv_int(row.get("clanarina_id")), + pd, + amt, + (row.get("currency") or "EUR").strip() or "EUR", + method, + (row.get("iban_from") or "").strip() or None, + (row.get("iban_to") or "").strip() or None, + (row.get("reference") or "").strip() or None, + (row.get("description") or "").strip() or None, + (row.get("bank_statement_no") or "").strip() or None, + (row.get("bank_transaction_id") or "").strip() or None, + )) + + inserted = 0 + if valid_rows: + def _do(cur): + nonlocal inserted + for tup in valid_rows: + cur.execute( + "INSERT INTO pgz_sport.payments " + "(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, " + " amount, currency, payment_method, iban_from, iban_to, reference, " + " description, bank_statement_no, bank_transaction_id, matched_status, " + " created_at) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now())", + tup, + ) + inserted += 1 + return inserted + db_tx(_do) + + return {"ok": True, "inserted": inserted, "errors": errors, + "total_rows": inserted + len(errors)} + + # ═══════════════════════════════════════════════════════════════════ # 14) HEALTH/DEBUG # ═══════════════════════════════════════════════════════════════════ diff --git a/static/erp_full.html b/static/erp_full.html index ab32bfd..bc7a6fd 100644 --- a/static/erp_full.html +++ b/static/erp_full.html @@ -272,15 +272,26 @@ table tbody tr:hover{background:var(--bg3)}
-
Plaćanja / Bank Reconciliation (payments)
+
+
Plaćanja / Bank Reconciliation (payments)
+
+ + + + +
+
- - + + +
+
+
-
#DatumKlubIznosValutaNačinIBAN ODIBAN ZAReferencaRačunPutni nalogMatch
Klikni "Osvježi"…
+
#DatumKlubIznosValutaMetodaIBAN→IBANReferenceStatusAkcije
Klikni "Osvježi"…
@@ -492,6 +503,30 @@ table tbody tr:hover{background:var(--bg3)} + +