Merge agent3-payments: SEPA + CSV import + match workflow
This commit is contained in:
+412
-4
@@ -16,21 +16,25 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from io import BytesIO
|
from io import BytesIO, StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
|
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# ── Upload destination (relative to web root /uploads/...) ──────────
|
# ── Upload destination (relative to web root /uploads/...) ──────────
|
||||||
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
|
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
|
||||||
@@ -1549,6 +1553,410 @@ def payments_list(klub_id: Optional[int] = None,
|
|||||||
return {"count": len(rows), "rows": rows}
|
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('<?xml version="1.0" encoding="UTF-8"?>')
|
||||||
|
parts.append('<!-- MOCK / placeholder — for future banking integration -->')
|
||||||
|
parts.append('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" '
|
||||||
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
|
||||||
|
parts.append(' <CstmrCdtTrfInitn>')
|
||||||
|
# Group Header
|
||||||
|
parts.append(' <GrpHdr>')
|
||||||
|
parts.append(f' <MsgId>{xml_escape(msg_id)}</MsgId>')
|
||||||
|
parts.append(f' <CreDtTm>{creation_dt}</CreDtTm>')
|
||||||
|
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
|
||||||
|
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
|
||||||
|
parts.append(' <InitgPty>')
|
||||||
|
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
|
||||||
|
parts.append(' </InitgPty>')
|
||||||
|
parts.append(' </GrpHdr>')
|
||||||
|
# Payment Information
|
||||||
|
parts.append(' <PmtInf>')
|
||||||
|
parts.append(f' <PmtInfId>{xml_escape(pmt_inf_id)}</PmtInfId>')
|
||||||
|
parts.append(' <PmtMtd>TRF</PmtMtd>')
|
||||||
|
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
|
||||||
|
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
|
||||||
|
parts.append(' <PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl></PmtTpInf>')
|
||||||
|
parts.append(f' <ReqdExctnDt>{requested_exec_date}</ReqdExctnDt>')
|
||||||
|
parts.append(' <Dbtr>')
|
||||||
|
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
|
||||||
|
parts.append(' </Dbtr>')
|
||||||
|
parts.append(' <DbtrAcct>')
|
||||||
|
parts.append(f' <Id><IBAN>{xml_escape(debtor_iban)}</IBAN></Id>')
|
||||||
|
parts.append(' </DbtrAcct>')
|
||||||
|
parts.append(' <DbtrAgt>')
|
||||||
|
parts.append(f' <FinInstnId><BIC>{xml_escape(debtor_bic)}</BIC></FinInstnId>')
|
||||||
|
parts.append(' </DbtrAgt>')
|
||||||
|
parts.append(' <ChrgBr>SLEV</ChrgBr>')
|
||||||
|
|
||||||
|
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(' <CdtTrfTxInf>')
|
||||||
|
parts.append(' <PmtId>')
|
||||||
|
parts.append(f' <EndToEndId>{xml_escape(end_to_end)}</EndToEndId>')
|
||||||
|
parts.append(' </PmtId>')
|
||||||
|
parts.append(' <Amt>')
|
||||||
|
parts.append(f' <InstdAmt Ccy="{xml_escape(ccy)}">{amt_str}</InstdAmt>')
|
||||||
|
parts.append(' </Amt>')
|
||||||
|
parts.append(' <Cdtr>')
|
||||||
|
parts.append(f' <Nm>{xml_escape(cdtr_name)}</Nm>')
|
||||||
|
parts.append(' </Cdtr>')
|
||||||
|
parts.append(' <CdtrAcct>')
|
||||||
|
parts.append(f' <Id><IBAN>{xml_escape(cdtr_iban)}</IBAN></Id>')
|
||||||
|
parts.append(' </CdtrAcct>')
|
||||||
|
parts.append(' <RmtInf>')
|
||||||
|
parts.append(f' <Ustrd>{xml_escape(rmt)}</Ustrd>')
|
||||||
|
parts.append(' </RmtInf>')
|
||||||
|
parts.append(' </CdtTrfTxInf>')
|
||||||
|
|
||||||
|
parts.append(' </PmtInf>')
|
||||||
|
parts.append(' </CstmrCdtTrfInitn>')
|
||||||
|
parts.append('</Document>')
|
||||||
|
|
||||||
|
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
|
# 14) HEALTH/DEBUG
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+203
-23
@@ -332,16 +332,27 @@ table tbody tr:hover{background:var(--bg3)}
|
|||||||
<!-- ============ PAYMENTS ============ -->
|
<!-- ============ PAYMENTS ============ -->
|
||||||
<section class="panel" id="panel-payments">
|
<section class="panel" id="panel-payments">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><div class="card-t">Plaćanja / Bank Reconciliation (payments)</div></div>
|
<div class="card-h">
|
||||||
|
<div class="card-t">Plaćanja / Bank Reconciliation (payments)</div>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<button class="btn gold" onclick="openPaymentModal()">+ Novo plaćanje</button>
|
||||||
|
<button class="btn sec" onclick="document.getElementById('py-csv-file').click()">📥 Import CSV</button>
|
||||||
|
<input type="file" id="py-csv-file" accept=".csv,text/csv" style="display:none" onchange="importPaymentsCSV(this)">
|
||||||
|
<button class="btn sec" onclick="exportPaymentsSepa()">📤 SEPA XML</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
|
|
||||||
<label>Način <select id="py-method"><option value="">— svi —</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
|
|
||||||
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
|
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
|
||||||
|
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
|
||||||
|
<label>Način <select id="py-method"><option value="">— svi —</option><option value="iban">iban</option><option value="sepa">sepa</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
|
||||||
|
<label>Klub ID <input type="number" id="py-klub" placeholder="—" style="width:90px"></label>
|
||||||
<button class="btn" onclick="loadPayments()">Osvježi</button>
|
<button class="btn" onclick="loadPayments()">Osvježi</button>
|
||||||
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
|
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="py-summary" class="kpi-grid"></div>
|
||||||
|
<div id="py-csv-result"></div>
|
||||||
<div class="tbl-wrap">
|
<div class="tbl-wrap">
|
||||||
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Način</th><th>IBAN OD</th><th>IBAN ZA</th><th>Referenca</th><th>Račun</th><th>Putni nalog</th><th>Match</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
|
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Metoda</th><th>IBAN→IBAN</th><th>Reference</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -586,6 +597,30 @@ table tbody tr:hover{background:var(--bg3)}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-bg" id="m-py" onclick="if(event.target===this)closeModal('m-py')">
|
||||||
|
<div class="modal" style="width:min(820px,96vw)">
|
||||||
|
<h3 id="m-py-title">Novo plaćanje</h3>
|
||||||
|
<div class="form-row"><label>Datum *</label><input type="date" id="py-date"></div>
|
||||||
|
<div class="form-row"><label>Iznos *</label><input type="number" step="0.01" id="py-amount"></div>
|
||||||
|
<div class="form-row"><label>Valuta</label><input id="py-currency" value="EUR" maxlength="3"></div>
|
||||||
|
<div class="form-row"><label>Metoda</label><select id="py-method-in"><option value="">—</option><option value="iban">iban</option><option value="sepa">sepa</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></div>
|
||||||
|
<div class="form-row"><label>Klub ID</label><input type="number" id="py-klub-in" placeholder="opcionalno"></div>
|
||||||
|
<div class="form-row"><label>Račun ID</label><input type="number" id="py-invoice-in" placeholder="opcionalno"></div>
|
||||||
|
<div class="form-row"><label>Putni nalog ID</label><input type="number" id="py-er-in" placeholder="opcionalno"></div>
|
||||||
|
<div class="form-row"><label>Članarina ID</label><input type="number" id="py-cl-in" placeholder="opcionalno"></div>
|
||||||
|
<div class="form-row"><label>IBAN OD</label><input id="py-iban-from" placeholder="HR..."></div>
|
||||||
|
<div class="form-row"><label>IBAN ZA</label><input id="py-iban-to" placeholder="HR..."></div>
|
||||||
|
<div class="form-row"><label>Reference</label><input id="py-ref"></div>
|
||||||
|
<div class="form-row"><label>Opis</label><input id="py-desc"></div>
|
||||||
|
<div class="form-row"><label>Bank statement #</label><input id="py-bs"></div>
|
||||||
|
<div class="form-row"><label>Bank txn ID</label><input id="py-btx"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn sec" onclick="closeModal('m-py')">Odustani</button>
|
||||||
|
<button class="btn gold" onclick="savePayment()">Spremi</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '/api/v2/erp';
|
const API = '/api/v2/erp';
|
||||||
const AUTH = () => ({ 'Authorization': 'Bearer ' + (localStorage.getItem('jwt') || localStorage.getItem('access_token') || 'admin-pgz-2026') });
|
const AUTH = () => ({ 'Authorization': 'Bearer ' + (localStorage.getItem('jwt') || localStorage.getItem('access_token') || 'admin-pgz-2026') });
|
||||||
@@ -1331,39 +1366,184 @@ async function deletePutni(id){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== PAYMENTS =====
|
// ===== PAYMENTS =====
|
||||||
|
function _pyFilterParams(){
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const s = document.getElementById('py-status').value;
|
||||||
|
const m = document.getElementById('py-method').value;
|
||||||
|
const g = document.getElementById('py-godina').value;
|
||||||
|
const k = document.getElementById('py-klub').value;
|
||||||
|
if(s) p.set('matched_status', s);
|
||||||
|
if(m) p.set('payment_method', m);
|
||||||
|
if(g) p.set('godina', g);
|
||||||
|
if(k) p.set('klub_id', k);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPayments(){
|
async function loadPayments(){
|
||||||
const tbody = document.querySelector('#py-tbl tbody');
|
const tbody = document.querySelector('#py-tbl tbody');
|
||||||
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
|
||||||
|
const sumDiv = document.getElementById('py-summary');
|
||||||
try {
|
try {
|
||||||
const s = document.getElementById('py-status').value;
|
const p = _pyFilterParams();
|
||||||
const m = document.getElementById('py-method').value;
|
if(!p.has('godina')){
|
||||||
const g = document.getElementById('py-godina').value;
|
// for summary tile we still want "this year" total
|
||||||
const p = new URLSearchParams();
|
}
|
||||||
if(s) p.set('matched_status', s);
|
const d = await api('/payments?'+p.toString()+'&limit=500');
|
||||||
if(m) p.set('payment_method', m);
|
const rows = d.rows || [];
|
||||||
if(g) p.set('godina', g);
|
const yearNow = new Date().getFullYear();
|
||||||
const d = await api('/payments?'+p.toString());
|
let totalYear = 0, cMatched = 0, cUnmatched = 0;
|
||||||
tbody.innerHTML = (d.rows||[]).length
|
for(const r of rows){
|
||||||
? d.rows.map(r=>`<tr>
|
const py = r.payment_date ? Number(r.payment_date.slice(0,4)) : null;
|
||||||
|
if(py === yearNow) totalYear += Number(r.amount||0);
|
||||||
|
if(r.matched_status === 'matched') cMatched++;
|
||||||
|
else if(r.matched_status === 'unmatched' || !r.matched_status) cUnmatched++;
|
||||||
|
}
|
||||||
|
sumDiv.innerHTML = `
|
||||||
|
<div class="kpi g"><div class="kpi-l">Ukupno ${yearNow}</div><div class="kpi-v">${fmt(totalYear)} €</div></div>
|
||||||
|
<div class="kpi r"><div class="kpi-l">Unmatched</div><div class="kpi-v">${cUnmatched}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-l">Matched</div><div class="kpi-v">${cMatched}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-l">Total rows</div><div class="kpi-v">${rows.length}</div></div>`;
|
||||||
|
tbody.innerHTML = rows.length
|
||||||
|
? rows.map(r=>`<tr>
|
||||||
<td>${r.id}</td>
|
<td>${r.id}</td>
|
||||||
<td>${r.payment_date||''}</td>
|
<td>${r.payment_date||''}</td>
|
||||||
<td>${r.klub_naziv||r.klub_id||''}</td>
|
<td>${(r.klub_naziv||r.klub_id||'').toString().replace(/</g,'<')}</td>
|
||||||
<td class="num"><b>${fmt(r.amount)}</b></td>
|
<td class="num"><b>${fmt(r.amount)}</b></td>
|
||||||
<td>${r.currency||''}</td>
|
<td>${r.currency||''}</td>
|
||||||
<td>${r.payment_method||''}</td>
|
<td>${r.payment_method||''}</td>
|
||||||
<td>${r.iban_from||''}</td>
|
<td style="font-family:var(--mono);font-size:10.5px">${(r.iban_from||'—')} → ${(r.iban_to||'—')}</td>
|
||||||
<td>${r.iban_to||''}</td>
|
<td>${(r.reference||'').replace(/</g,'<')}</td>
|
||||||
<td>${r.reference||''}</td>
|
|
||||||
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
|
|
||||||
<td>${r.expense_report_id?('#'+r.expense_report_id):'—'}</td>
|
|
||||||
<td><span class="badge ${r.matched_status||''}">${r.matched_status||''}</span></td>
|
<td><span class="badge ${r.matched_status||''}">${r.matched_status||''}</span></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button class="btn sec" style="padding:3px 8px" onclick="event.stopPropagation();viewPayment(${r.id})">👁</button>
|
||||||
|
${r.matched_status==='matched' ? '' : `<button class="btn green" style="padding:3px 8px" onclick="event.stopPropagation();matchPayment(${r.id})">✓ Match</button>`}
|
||||||
|
</td>
|
||||||
</tr>`).join('')
|
</tr>`).join('')
|
||||||
: `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`;
|
: `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
|
sumDiv.innerHTML = '';
|
||||||
|
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPaymentModal(){
|
||||||
|
['py-date','py-amount','py-klub-in','py-invoice-in','py-er-in','py-cl-in',
|
||||||
|
'py-iban-from','py-iban-to','py-ref','py-desc','py-bs','py-btx']
|
||||||
|
.forEach(i=>{ const el=document.getElementById(i); if(el) el.value=''; });
|
||||||
|
document.getElementById('py-currency').value='EUR';
|
||||||
|
document.getElementById('py-method-in').value='';
|
||||||
|
// Pre-fill today
|
||||||
|
document.getElementById('py-date').value = new Date().toISOString().slice(0,10);
|
||||||
|
openModal('m-py');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePayment(){
|
||||||
|
const v = id => { const el=document.getElementById(id); return el?el.value.trim():''; };
|
||||||
|
const numOrNull = s => s==='' ? null : Number(s);
|
||||||
|
const strOrNull = s => s==='' ? null : s;
|
||||||
|
const body = {
|
||||||
|
payment_date: v('py-date'),
|
||||||
|
amount: v('py-amount'),
|
||||||
|
currency: v('py-currency') || 'EUR',
|
||||||
|
payment_method: strOrNull(v('py-method-in')),
|
||||||
|
klub_id: numOrNull(v('py-klub-in')),
|
||||||
|
invoice_id: numOrNull(v('py-invoice-in')),
|
||||||
|
expense_report_id: numOrNull(v('py-er-in')),
|
||||||
|
clanarina_id: numOrNull(v('py-cl-in')),
|
||||||
|
iban_from: strOrNull(v('py-iban-from')),
|
||||||
|
iban_to: strOrNull(v('py-iban-to')),
|
||||||
|
reference: strOrNull(v('py-ref')),
|
||||||
|
description: strOrNull(v('py-desc')),
|
||||||
|
bank_statement_no: strOrNull(v('py-bs')),
|
||||||
|
bank_transaction_id: strOrNull(v('py-btx')),
|
||||||
|
};
|
||||||
|
if(!body.payment_date){ alert('Datum je obavezan'); return; }
|
||||||
|
if(!body.amount || Number(body.amount)<=0){ alert('Iznos mora biti > 0'); return; }
|
||||||
|
try {
|
||||||
|
await api('/payments', { method:'POST', body: JSON.stringify(body) });
|
||||||
|
closeModal('m-py');
|
||||||
|
loadPayments();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Greška: '+e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewPayment(id){
|
||||||
|
try {
|
||||||
|
const r = await api('/payments/'+id);
|
||||||
|
const lines = [
|
||||||
|
`ID: ${r.id}`,
|
||||||
|
`Datum: ${r.payment_date||''}`,
|
||||||
|
`Klub: ${r.klub_naziv||r.klub_id||'—'}`,
|
||||||
|
`Iznos: ${fmt(r.amount)} ${r.currency||''}`,
|
||||||
|
`Metoda: ${r.payment_method||'—'}`,
|
||||||
|
`IBAN: ${r.iban_from||'—'} → ${r.iban_to||'—'}`,
|
||||||
|
`Reference: ${r.reference||'—'}`,
|
||||||
|
`Opis: ${r.description||'—'}`,
|
||||||
|
`Status: ${r.matched_status||''}${r.matched_at?(' @ '+r.matched_at):''}`,
|
||||||
|
r.invoice_no ? `Račun: ${r.invoice_no} (${r.vendor_name||''}, ${fmt(r.invoice_amount)})` : null,
|
||||||
|
r.report_no ? `Putni nalog: ${r.report_no} (${r.expense_purpose||''})` : null,
|
||||||
|
r.bank_statement_no ? `Izvod: ${r.bank_statement_no}` : null,
|
||||||
|
r.bank_transaction_id ? `Bank txn: ${r.bank_transaction_id}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
alert(lines.join('\n'));
|
||||||
|
} catch(e) {
|
||||||
|
alert('Greška: '+e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchPayment(id){
|
||||||
|
const inv = prompt('Invoice ID za match (ostavi prazno za skip):', '');
|
||||||
|
let er = '';
|
||||||
|
if(!inv) er = prompt('Expense report ID za match (ostavi prazno za skip):', '') || '';
|
||||||
|
const body = { matched_status: 'matched', matched_by: 'user' };
|
||||||
|
if(inv && inv.trim()) body.invoice_id = parseInt(inv.trim(),10);
|
||||||
|
if(er && er.trim()) body.expense_report_id = parseInt(er.trim(),10);
|
||||||
|
try {
|
||||||
|
await api('/payments/'+id, { method:'PATCH', body: JSON.stringify(body) });
|
||||||
|
loadPayments();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Greška: '+e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPaymentsCSV(input){
|
||||||
|
const f = input.files && input.files[0];
|
||||||
|
if(!f) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', f);
|
||||||
|
const out = document.getElementById('py-csv-result');
|
||||||
|
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-radius:4px;margin:8px 0;color:var(--t2)">Importing ${f.name}…</div>`;
|
||||||
|
try {
|
||||||
|
const r = await fetch(API+'/payments/import-csv', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...AUTH() },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if(!r.ok){
|
||||||
|
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--red);margin:8px 0;color:var(--red)">Import error: ${j.detail||r.status}</div>`;
|
||||||
|
} else {
|
||||||
|
const errLines = (j.errors||[]).slice(0,20).map(e=>`<li>row ${e.row}: ${e.msg}</li>`).join('');
|
||||||
|
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--green);margin:8px 0">
|
||||||
|
<b style="color:var(--green)">Inserted: ${j.inserted}</b> · Errors: ${(j.errors||[]).length} · Total: ${j.total_rows||(j.inserted+(j.errors||[]).length)}
|
||||||
|
${errLines?`<ul style="margin-top:6px;color:var(--amber);font-size:11px">${errLines}</ul>`:''}
|
||||||
|
</div>`;
|
||||||
|
loadPayments();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--red);margin:8px 0;color:var(--red)">Greška: ${e.message}</div>`;
|
||||||
|
} finally {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPaymentsSepa(){
|
||||||
|
const p = _pyFilterParams();
|
||||||
|
const url = API+'/payments/sepa-export'+(p.toString()?('?'+p.toString()):'');
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// ===== IZVJEŠTAJI =====
|
// ===== IZVJEŠTAJI =====
|
||||||
async function loadIzvjestaj(){
|
async function loadIzvjestaj(){
|
||||||
const tip = document.getElementById('iz-tip').value;
|
const tip = document.getElementById('iz-tip').value;
|
||||||
|
|||||||
Reference in New Issue
Block a user