diff --git a/src/main.py b/src/main.py
index 84674c3..e780557 100644
--- a/src/main.py
+++ b/src/main.py
@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT
-from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper
+from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing
logging.basicConfig(
level=logging.INFO,
@@ -44,6 +44,7 @@ app.include_router(token_usage.router)
app.include_router(audit.router)
app.include_router(translation.router)
app.include_router(x_scraper.router)
+app.include_router(pricing.router)
# --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
diff --git a/src/routers/pricing.py b/src/routers/pricing.py
new file mode 100644
index 0000000..5b10a96
--- /dev/null
+++ b/src/routers/pricing.py
@@ -0,0 +1,66 @@
+"""Preis-Empfehlung aus den echten API-Kosten der letzten Tage.
+
+Reine Leseabfrage. Die Empfehlung ist eine betreiberweite Kennzahl (Median der
+tatsaechlichen Refresh-Kosten je Lagentyp), deshalb wird die geteilte Live-DB
+read-only gelesen, unabhaengig vom DB_PATH des jeweiligen Portals.
+"""
+import logging
+import math
+import os
+import statistics
+from collections import defaultdict
+from datetime import datetime, timedelta
+
+import aiosqlite
+from fastapi import APIRouter, Depends
+from auth import get_current_admin
+
+logger = logging.getLogger("verwaltung.pricing")
+router = APIRouter(prefix="/api/pricing", tags=["Pricing"])
+
+COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db")
+USD_PER_CREDIT = 0.20
+
+
+@router.get("/cost-basis")
+async def cost_basis(days: int = 30, admin=Depends(get_current_admin)):
+ """Median der echten Refresh-Kosten je Lagentyp und daraus abgeleitete
+ Credit-Empfehlung (Median USD geteilt durch 0,20 USD, aufgerundet)."""
+ days = max(1, min(365, days))
+ cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
+ result = {"days": days, "usd_per_credit": USD_PER_CREDIT, "typen": {}}
+
+ try:
+ db = await aiosqlite.connect(f"file:{COST_BASIS_DB}?mode=ro", uri=True)
+ try:
+ cursor = await db.execute(
+ "SELECT i.type, r.total_cost_usd "
+ "FROM refresh_log r JOIN incidents i ON i.id = r.incident_id "
+ "WHERE r.status = 'completed' AND r.started_at >= ?",
+ (cutoff,),
+ )
+ rows = await cursor.fetchall()
+ finally:
+ await db.close()
+ except Exception as e:
+ logger.warning("cost-basis DB-Fehler: %s", e)
+ result["fehler"] = "Kostendaten nicht verfuegbar"
+ return result
+
+ gruppen = defaultdict(list)
+ for typ, kosten in rows:
+ gruppen[typ or "adhoc"].append(kosten or 0.0)
+
+ for db_key, label in (("adhoc", "live"), ("research", "research")):
+ werte = gruppen.get(db_key, [])
+ if werte:
+ median = statistics.median(werte)
+ result["typen"][label] = {
+ "median_usd": round(median, 2),
+ "n": len(werte),
+ "empfohlen": int(math.ceil(median / USD_PER_CREDIT)) if median > 0 else 0,
+ }
+ else:
+ result["typen"][label] = {"median_usd": None, "n": 0, "empfohlen": None}
+
+ return result
diff --git a/src/static/js/app.js b/src/static/js/app.js
index 694f08b..fa4f116 100644
--- a/src/static/js/app.js
+++ b/src/static/js/app.js
@@ -1051,6 +1051,6 @@ document.addEventListener("DOMContentLoaded", () => {
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
(function () {
var s = document.createElement("script");
- s.src = "/static/js/rechner.js?v=20260724b";
+ s.src = "/static/js/rechner.js?v=20260724c";
document.head.appendChild(s);
})();
diff --git a/src/static/js/rechner.js b/src/static/js/rechner.js
index e27eae9..4d652ab 100644
--- a/src/static/js/rechner.js
+++ b/src/static/js/rechner.js
@@ -3,15 +3,17 @@
// Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal.
// Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst,
// damit dashboard.html unveraendert bleiben kann.
+// Saetze sind ueber Regler frei einstellbar, mit Empfehlung aus den echten
+// API-Kosten der letzten 30 Tage (GET /api/pricing/cost-basis).
(function () {
- var SATZ_LIVE = 45;
- var SATZ_RECHERCHE_NEU = 120;
- var SATZ_AKTUALISIERUNG = 40;
- var SATZ_NEBEN = 1;
var USD = 0.20;
var MINIMUM_TAKT_MINUTEN = 30;
+ // Frei einstellbare Saetze (Credits je Aktion).
+ var saetze = { live: 45, research: 40, neben: 1 };
+ function rechercheNeu() { return saetze.research * 3; }
+
var EINHEITEN = [
{ wert: 1, text: "Minuten" },
{ wert: 60, text: "Stunden" },
@@ -28,6 +30,7 @@
};
function fmt(n) { return Math.round(n).toLocaleString("de-DE"); }
+ function fmtUsd(n) { return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"; }
function neuerPosten(typ) {
if (typ === "recherche") return { anzahl: 1, vertiefungen: 1 };
@@ -49,14 +52,16 @@
var intervallMin = Math.max(MINIMUM_TAKT_MINUTEN, p.taktWert * p.taktEinheit);
laeufe = Math.max(1, Math.floor(p.tage * 1440 / intervallMin));
}
- return (p.anzahl * laeufe + p.manuell) * SATZ_LIVE;
+ return (p.anzahl * laeufe + p.manuell) * saetze.live;
}
if (typ === "recherche") {
- return p.anzahl * SATZ_RECHERCHE_NEU + p.vertiefungen * SATZ_AKTUALISIERUNG;
+ return p.anzahl * rechercheNeu() + p.vertiefungen * saetze.research;
}
- return (p.auto ? autoAufrufe() : p.aufrufe) * SATZ_NEBEN;
+ return (p.auto ? autoAufrufe() : p.aufrufe) * saetze.neben;
}
+ // ---------- Posten-Felder ----------
+
function feldHtml(typ, i, name, labelText, hinweis, wert, min, max) {
var id = "vr-" + typ + "-" + i + "-" + name;
return '