feat: /api/v2/analiza/* endpoints - sport analytics backend

This commit is contained in:
Damir Radulic
2026-05-16 00:28:12 +02:00
parent 7ca5d7d94e
commit aca5051418
1355 changed files with 321891 additions and 4128 deletions
+5 -1
View File
@@ -1,11 +1,15 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
import os
# erp/audit_helper.py — centralni helper za pisanje u pgz_sport.audit_log
# Author: dradulic@outlook.com — 2026-05-05
# Description: Lightweight audit_log writer za CRM module i druge routere koji
# ne koriste erp/permissions.py.
import psycopg2
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
password=os.environ["DB_PASSWORD"])
def audit(tablica: str, op: str, record_id, korisnik: str = "anon",
field: str = None, old=None, new=None, ip: str = None) -> None:
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import os
# erp/audit_helper.py — centralni helper za pisanje u pgz_sport.audit_log
# Author: dradulic@outlook.com — 2026-05-05
# Description: Lightweight audit_log writer za CRM module i druge routere koji
# ne koriste erp/permissions.py.
import psycopg2
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password=os.environ["DB_PASSWORD"])
def audit(tablica: str, op: str, record_id, korisnik: str = "anon",
field: str = None, old=None, new=None, ip: str = None) -> None:
"""Sigurno upiši red u pgz_sport.audit_log. Greške se proguta."""
try:
with psycopg2.connect(**DB) as c:
c.autocommit = True
c.cursor().execute(
"""INSERT INTO pgz_sport.audit_log
(tablica, operacija, record_id, korisnik, ip,
promijenjeno_polje, stara_vrijednost, nova_vrijednost)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(tablica, op, record_id, korisnik or "anon", ip,
field,
None if old is None else str(old)[:500],
None if new is None else str(new)[:500]),
)
except Exception:
pass
+4 -1
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
# erp/notifications.py — PGŽ Sport ERP mock e-mail notifikacije (R6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
@@ -18,7 +21,7 @@ import psycopg2
import psycopg2.extras
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
password=os.environ["DB_PASSWORD"])
LOG_PATH = os.environ.get("ERP_NOTIFY_LOG", "/var/log/pgz-sport-erp-notify.log")
logger = logging.getLogger("erp.notifications")
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
# erp/notifications.py — PGŽ Sport ERP mock e-mail notifikacije (R6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: Mock e-mail / channel='email' notifikacije pri promjeni statusa
# ERP entiteta. Upisuje u pgz_sport.notifications + log line.
# U produkciji se može zamijeniti pravim SMTP/Mailgun adapterom.
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Optional, Iterable
import psycopg2
import psycopg2.extras
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password=os.environ["DB_PASSWORD"])
LOG_PATH = os.environ.get("ERP_NOTIFY_LOG", "/var/log/pgz-sport-erp-notify.log")
logger = logging.getLogger("erp.notifications")
if not logger.handlers:
logger.setLevel(logging.INFO)
try:
fh = logging.FileHandler(LOG_PATH)
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(fh)
except Exception:
pass
sh = logging.StreamHandler()
sh.setFormatter(logging.Formatter("[ERP-NOTIFY] %(message)s"))
logger.addHandler(sh)
def _db():
c = psycopg2.connect(**DB); c.autocommit = True; return c
def _resolve_recipients(klub_id: Optional[int], user_id: Optional[int]) -> list[dict]:
"""Vrati listu primatelja: voditelj putovanja (user_id), klub_admin svog kluba,
+ pgz_admin kao info kopija."""
out: list[dict] = []
seen = set()
try:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if user_id:
cur.execute(
"SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name "
"FROM pgz_sport.users WHERE id=%s AND status='active'", (user_id,))
r = cur.fetchone()
if r and r["email"] and r["id"] not in seen:
out.append({**r, "rola": "voditelj"}); seen.add(r["id"])
if klub_id:
cur.execute(
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
FROM pgz_sport.users
WHERE klub_id=%s AND user_type='klub_admin' AND status='active'""",
(klub_id,))
for r in cur.fetchall():
if r["email"] and r["id"] not in seen:
out.append({**r, "rola": "klub_admin"}); seen.add(r["id"])
cur.execute(
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
FROM pgz_sport.users
WHERE user_type='pgz_admin' AND status='active' LIMIT 5""")
for r in cur.fetchall():
if r["email"] and r["id"] not in seen:
out.append({**r, "rola": "pgz_admin"}); seen.add(r["id"])
except Exception as e:
logger.warning("recipients fetch fail: %s", e)
return out
def _store(user_id: Optional[int], subject: str, body: str, meta: dict,
channel: str = "email", status: str = "queued") -> Optional[int]:
try:
with _db() as c:
cur = c.cursor()
cur.execute(
"""INSERT INTO pgz_sport.notifications
(user_id, channel, subject, body, status, scheduled_at, meta)
VALUES (%s,%s,%s,%s,%s,NOW(),%s)
RETURNING id""",
(user_id, channel, subject[:200], body[:5000], status,
json.dumps(meta, ensure_ascii=False, default=str)),
)
return cur.fetchone()[0]
except Exception as e:
logger.warning("notification insert fail: %s", e)
return None
def _dispatch(subject: str, body: str, *, klub_id: Optional[int] = None,
user_id: Optional[int] = None, meta: Optional[dict] = None) -> dict:
meta = dict(meta or {})
recipients = _resolve_recipients(klub_id, user_id)
delivered = []
if not recipients:
# Mock: nemamo korisnika, samo log + jedan info zapis bez user_id
nid = _store(None, subject, body,
{**meta, "to": "(no_recipient)", "klub_id": klub_id})
logger.info("MOCK email (no recipient) [%s] %s", nid, subject)
return {"sent": 0, "queued": 1 if nid else 0, "ids": [nid] if nid else [],
"recipients": []}
for r in recipients:
nid = _store(r["id"], subject, body,
{**meta, "to": r["email"], "rola": r.get("rola"),
"name": r.get("name")})
if nid:
delivered.append({"id": nid, "user_id": r["id"], "email": r["email"]})
logger.info(
"MOCK email queued [%s] to=%s rola=%s subj=%r",
nid, r["email"], r.get("rola"), subject,
)
return {"sent": 0, "queued": len(delivered), "ids": [d["id"] for d in delivered],
"recipients": [d["email"] for d in delivered]}
# ─── Public helpers ────────────────────────────────────────────────────
def notify_invoice_created(invoice: dict) -> dict:
"""Račun spremljen iz OCR-a — info klub_admin."""
subj = f"Novi račun #{invoice.get('id')}: {invoice.get('vendor_name','')} (€{invoice.get('amount_gross')})"
body = (
f"Račun {invoice.get('invoice_no')} od {invoice.get('vendor_name')} "
f"(OIB {invoice.get('vendor_oib')}) iznosa €{invoice.get('amount_gross')} "
f"na datum {invoice.get('invoice_date')} unesen je u sustav.\n\n"
f"Klub: {invoice.get('klub_id')} · Vrsta: {invoice.get('invoice_kind')} · Status: {invoice.get('payment_status')}"
)
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
meta={"event": "invoice_created", "invoice_id": invoice.get("id")})
def notify_invoice_paid(invoice: dict, payment: Optional[dict] = None) -> dict:
iban = (payment or {}).get("iban_to") or invoice.get("iban_to") or "—"
subj = f"Račun #{invoice.get('id')} označen kao plaćen — €{invoice.get('amount_gross')}"
body = (
f"Račun {invoice.get('invoice_no')} izdan od {invoice.get('vendor_name')} "
f"je označen kao plaćen.\n"
f"Iznos: €{invoice.get('amount_gross')}\n"
f"Datum uplate: {invoice.get('paid_date')}\n"
f"IBAN primatelja: {iban}\n"
f"Referenca: {(payment or {}).get('reference','—')}"
)
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
meta={"event": "invoice_paid", "invoice_id": invoice.get("id")})
def notify_invoice_cancelled(invoice: dict, razlog: str = "") -> dict:
subj = f"Račun #{invoice.get('id')} otkazan"
body = f"Račun {invoice.get('invoice_no')} ({invoice.get('vendor_name')}) je otkazan.\nRazlog: {razlog or '—'}"
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
meta={"event": "invoice_cancelled", "invoice_id": invoice.get("id"),
"razlog": razlog})
def notify_pn_submitted(pn: dict) -> dict:
subj = f"Putni nalog #{pn.get('id')} poslan na odobrenje (€{pn.get('cost_total')})"
body = (
f"Putni nalog za destinaciju '{pn.get('destination')}' "
f"({pn.get('date_from')} {pn.get('date_to')}) "
f"poslan je na odobrenje.\nIznos: €{pn.get('cost_total')}"
)
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_submitted", "pn_id": pn.get("id")})
def notify_pn_approved(pn: dict) -> dict:
subj = f"Putni nalog #{pn.get('id')} ODOBREN — €{pn.get('cost_total')}"
body = (
f"Putni nalog za '{pn.get('destination')}' "
f"({pn.get('date_from')} {pn.get('date_to')}) je odobren.\n"
f"Iznos: €{pn.get('cost_total')}"
)
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_approved", "pn_id": pn.get("id")})
def notify_pn_rejected(pn: dict, razlog: str = "") -> dict:
subj = f"Putni nalog #{pn.get('id')} ODBIJEN"
body = f"Putni nalog za '{pn.get('destination')}' je odbijen.\nRazlog: {razlog or '—'}"
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_rejected", "pn_id": pn.get("id"), "razlog": razlog})
def notify_pn_paid(pn: dict, payment: Optional[dict] = None) -> dict:
iban = (payment or {}).get("iban_to") or "—"
subj = f"Putni nalog #{pn.get('id')} ISPLAĆEN — €{pn.get('cost_total')}"
body = (
f"Putni nalog za '{pn.get('destination')}' isplaćen je voditelju.\n"
f"Iznos: €{(payment or {}).get('amount') or pn.get('cost_total')}\n"
f"IBAN primatelja: {iban}\n"
f"Datum isplate: {pn.get('paid_at') or (payment or {}).get('payment_date')}\n"
f"Referenca: {(payment or {}).get('reference','—')}"
)
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_paid", "pn_id": pn.get("id")})
__all__ = [
"notify_invoice_created", "notify_invoice_paid", "notify_invoice_cancelled",
"notify_pn_submitted", "notify_pn_approved", "notify_pn_rejected", "notify_pn_paid",
]
+5 -2
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
# erp/ocr.py — PGŽ Sport ERP OCR router (M5)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
@@ -58,11 +61,11 @@ router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
# === Config ===
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
password=os.environ["DB_PASSWORD"])
UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7")
DEEPSEEK_API_KEY = os.environ['DEEPSEEK_API_KEY'] # /opt/rinet-gpu/.env.master (ZAKON#1)
DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions"
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
import os
# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
@@ -14,7 +18,7 @@ from typing import Optional, Dict, Any
import psycopg2, psycopg2.extras
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
password=os.environ["DB_PASSWORD"])
def _db():
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
import os
# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: Centralizirane provjere ovlasti za račune i putne naloge.
#
# Uloge (pgz_sport.roles):
# super_admin, pgz_admin, savez_admin, klub_admin, klub_user, clan, viewer
#
# Korisnik (dict iz auth_v2.get_current_user) ima: id, user_type, klub_id, savez_id.
from __future__ import annotations
from typing import Optional, Dict, Any
import psycopg2, psycopg2.extras
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password=os.environ["DB_PASSWORD"])
def _db():
c = psycopg2.connect(**DB); c.autocommit = True; return c
# ── role helpers ──────────────────────────────────────────────────────
def is_super(user) -> bool:
return bool(user) and user.get("user_type") == "super_admin"
def is_pgz_admin(user) -> bool:
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
def is_savez_admin(user) -> bool:
return bool(user) and user.get("user_type") == "savez_admin"
def is_klub_admin(user) -> bool:
return bool(user) and user.get("user_type") == "klub_admin"
def is_klub_user(user) -> bool:
return bool(user) and user.get("user_type") in ("klub_admin", "klub_user")
def klub_savez(klub_id: int) -> Optional[int]:
"""Vraća savez_id kojem klub pripada (preko klubovi.savez_id ili user_klub_links)."""
if not klub_id: return None
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (klub_id,))
r = cur.fetchone()
return r["savez_id"] if r else None
def user_can_see_klub(user, klub_id: Optional[int]) -> bool:
"""Tko može VIDJETI klub: super, pgz, savez (ako klub u savezu), klub_admin/user (ako vlastiti klub)."""
if not user or not klub_id:
return is_pgz_admin(user)
if is_pgz_admin(user):
return True
if is_klub_user(user):
return user.get("klub_id") == klub_id
if is_savez_admin(user):
return klub_savez(klub_id) == user.get("savez_id")
return False
# ── INVOICES ──────────────────────────────────────────────────────────
def can_view_invoice(user, invoice: Dict[str, Any]) -> bool:
"""Pgž admin vidi sve. Savez admin svoje saveze. Klub admin/user vlastiti klub."""
if not invoice: return False
if is_pgz_admin(user): return True
return user_can_see_klub(user, invoice.get("klub_id"))
def can_edit_invoice(user, invoice: Dict[str, Any]) -> bool:
"""
Edit (izmjena polja, korekcija OCR-a) — samo klub_admin vlastitog kluba ILI pgz_admin.
Savez admin može komentirati, ali NE editirati.
Plaćeni računi su read-only osim za pgz_admin.
"""
if not invoice: return False
if is_pgz_admin(user): return True
if invoice.get("payment_status") in ("paid",):
return False
if is_klub_admin(user):
return user.get("klub_id") == invoice.get("klub_id")
return False
def can_pay_invoice(user, invoice: Dict[str, Any]) -> bool:
"""Označi kao plaćen — klub_admin vlastitog kluba ili pgz_admin."""
if not invoice: return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == invoice.get("klub_id")
return False
def can_comment_invoice(user, invoice: Dict[str, Any]) -> bool:
"""Komentirati može pgz_admin, savez_admin (svog saveza) i klub_admin (svog kluba)."""
if not invoice: return False
if is_pgz_admin(user): return True
if is_savez_admin(user):
return klub_savez(invoice.get("klub_id")) == user.get("savez_id")
if is_klub_admin(user):
return user.get("klub_id") == invoice.get("klub_id")
return False
def invoice_actions(user, invoice: Dict[str, Any]) -> Dict[str, bool]:
"""UI hint — koji gumbi su dostupni."""
return {
"view": can_view_invoice(user, invoice),
"edit": can_edit_invoice(user, invoice),
"pay": can_pay_invoice(user, invoice) and invoice.get("payment_status") != "paid",
"comment": can_comment_invoice(user, invoice),
"delete": is_pgz_admin(user),
}
# ── PUTNI NALOZI ──────────────────────────────────────────────────────
def can_view_putni_nalog(user, pn: Dict[str, Any]) -> bool:
if not pn: return False
if is_pgz_admin(user): return True
if is_savez_admin(user):
return klub_savez(pn.get("klub_id")) == user.get("savez_id")
if is_klub_user(user):
if user.get("klub_id") == pn.get("klub_id"):
return True
# Voditelj vidi svoj
return pn.get("user_id") == user.get("id") if user else False
def can_edit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Edit dozvoljen samo na statusima draft/odbijen, i samo voditelju ili klub_admin/pgz."""
if not pn: return False
status = (pn.get("status") or "draft").lower()
if status not in ("draft", "odbijen"):
return is_pgz_admin(user)
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
# Voditelj
return pn.get("user_id") == user.get("id") if user else False
def can_submit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Slanje (draft → poslan) — voditelj ili klub_admin."""
if not pn: return False
if (pn.get("status") or "draft").lower() not in ("draft",):
return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
return pn.get("user_id") == user.get("id") if user else False
def can_approve_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Odobravanje (poslan → odobren ili odbijen) — klub_admin svog kluba ili pgz_admin."""
if not pn: return False
if (pn.get("status") or "").lower() not in ("poslan", "submitted", "draft"):
return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
return False
def can_pay_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Isplata (odobren → isplaćen) — klub_admin ili pgz_admin."""
if not pn: return False
if (pn.get("status") or "").lower() not in ("odobren", "approved", "zatvoren"):
return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
return False
def putni_nalog_actions(user, pn: Dict[str, Any]) -> Dict[str, bool]:
return {
"view": can_view_putni_nalog(user, pn),
"edit": can_edit_putni_nalog(user, pn),
"submit": can_submit_putni_nalog(user, pn),
"approve": can_approve_putni_nalog(user, pn),
"reject": can_approve_putni_nalog(user, pn),
"pay": can_pay_putni_nalog(user, pn),
"delete": is_pgz_admin(user),
}
# ── Audit logging helper ──────────────────────────────────────────────
def audit_invoice(user, invoice_id: int, op: str, field: Optional[str] = None,
old=None, new=None):
try:
with _db() as c:
c.cursor().execute(
"""INSERT INTO pgz_sport.audit_log
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
stara_vrijednost, nova_vrijednost)
VALUES ('pgz_sport.invoices', %s, %s, %s, %s, %s, %s)""",
(op, invoice_id,
(user.get("email") if user else "anon"),
field,
None if old is None else str(old)[:500],
None if new is None else str(new)[:500]),
)
except Exception:
pass
def audit_putni(user, pn_id: int, op: str, field: Optional[str] = None,
old=None, new=None):
try:
with _db() as c:
c.cursor().execute(
"""INSERT INTO pgz_sport.audit_log
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
stara_vrijednost, nova_vrijednost)
VALUES ('pgz_sport.expense_reports', %s, %s, %s, %s, %s, %s)""",
(op, pn_id,
(user.get("email") if user else "anon"),
field,
None if old is None else str(old)[:500],
None if new is None else str(new)[:500]),
)
except Exception:
pass
def fetch_audit(table: str, record_id: int, limit: int = 50):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT timestamp, operacija, korisnik, promijenjeno_polje,
stara_vrijednost, nova_vrijednost
FROM pgz_sport.audit_log
WHERE tablica=%s AND record_id=%s
ORDER BY timestamp DESC LIMIT %s""",
(table, record_id, limit),
)
return cur.fetchall()
+5 -1
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
import os
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
@@ -65,7 +69,7 @@ def _resolve_user(authorization):
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
password=os.environ["DB_PASSWORD"])
# === HR pravilnik 2025 — dnevnice ===
# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h.
File diff suppressed because it is too large Load Diff