commit a19558fc6cc47d62d16c4b41305c67751bcb7039 Author: Julian Appel Date: Sat Oct 3 14:00:55 2026 +0200 KFE-Viewer: Web-Viewer für die ZVEH-Kalkulationshilfe Python-Server ohne Abhängigkeiten, der die dBase/FoxPro-Dateien des originalen KFE-Viewers (KFE.DBF, VORTEXT, GRUPPEN, Parameter, Muster-LVs) direkt einliest, plus Single-Page-Frontend mit Live-Suche, Facettenfiltern, einstellbarer Kalkulation (Lohnsätze, Materialzuschlag, DEL-Notiz), Detailansicht und Merkliste mit CSV-Export. Docker-Image enthält nur die App; die lizenzpflichtigen KFE-Daten werden zur Laufzeit nach /data gemountet und sind nicht Teil des Repos. Co-Authored-By: Claude Opus 5.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d2aa0e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +* +!server.py +!index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2bcb3e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Kopieren nach .env und Pfad anpassen +# Pfad zum KFE-Datenverzeichnis (enthält KFE.DBF), relativ zu compose.yaml +KFE_DATA_DIR=./data diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cc23192 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d139bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# KFE-Daten sind lizenzpflichtig und gehören nicht ins Repo +data/ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b40b6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# KFE-Viewer als Container. Die KFE-Daten (lizenzpflichtig) sind nicht im Image enthalten, +# sondern werden zur Laufzeit nach /data gemountet – siehe compose.yaml bzw. README. +FROM python:3.13-alpine + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + KFE_DATA=/data \ + KFE_HOST=0.0.0.0 \ + KFE_PORT=8765 \ + KFE_NO_BROWSER=1 + +WORKDIR /app +COPY server.py index.html ./ + +RUN adduser -D -H -s /sbin/nologin kfe +USER kfe + +EXPOSE 8765 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/' % os.environ['KFE_PORT'], timeout=2)" + +CMD ["python", "server.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..343f2e1 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# KFE Viewer + +Leichtgewichtiger Web-Viewer für die ZVEH-Kalkulationshilfe (KFE). Liest die +dBase/FoxPro-Dateien des originalen KFE-Viewers direkt ein – keine Konvertierung, +keine Abhängigkeiten außer Python ≥ 3.8. + +## Daten + +Die KFE-Daten sind **nicht** in diesem Repository enthalten. Sie sind lizenzpflichtig +(Herausgeber ZVEH, Vertrieb META Handelsgesellschaft). Benötigt wird das Datenverzeichnis des +originalen KFE-Viewers, auf der KFE-CD unter `0_software/KFE_ViewerCD3/Daten`. Darin liegen +u. a. `KFE.DBF`/`.FPT`, `VORTEXT.DBF`/`.FPT`, `GRUPPEN.DBF`, `STDPARAM.DAT`, `LOHNSTD.DBF` und `Projekt/*.prj`. + +Am einfachsten kopierst du den Inhalt nach `./data` (per `.gitignore` ausgeschlossen) oder gibst den Pfad mit `--data` an. + +## Start + +```bash +python server.py +``` + +Öffnet automatisch http://127.0.0.1:8765. Unter Windows geht auch ein Doppelklick auf `start.bat`. + +| Option | Bedeutung | +|---|---| +| `--data PFAD` | Verzeichnis mit `KFE.DBF`. Standard: `./data`, alternativ `../2020-05-12-NEU/0_software/KFE_ViewerCD3/Daten` | +| `--port 8765` | Port | +| `--host 0.0.0.0` | im LAN erreichbar machen | +| `--no-browser` | Browser nicht automatisch öffnen | +| `--static kfe.html` | eine einzelne HTML-Datei mit eingebetteten Daten erzeugen (ohne Server nutzbar, ca. 5 MB) | + +Alle Optionen lassen sich auch per Umgebungsvariable setzen: `KFE_DATA`, `KFE_HOST`, `KFE_PORT`, `KFE_NO_BROWSER=1`. + +## Docker + +Das Image enthält nur die App (ca. 80 MB, Basis `python:3.13-alpine`, läuft als Nicht-root-Benutzer). +Die Daten werden schreibgeschützt nach `/data` gemountet. + +```bash +cp .env.example .env # KFE_DATA_DIR auf das Datenverzeichnis setzen (Standard: ./data) +docker compose up -d --build +``` + +Danach erreichbar unter http://127.0.0.1:8765. Weitere Befehle: `docker compose logs`, `docker compose down`. +Für Zugriff aus dem LAN in `compose.yaml` den Port auf `"8765:8765"` ändern. + +Ohne Compose: + +```bash +docker build -t kfe-viewer . +docker run -d -p 127.0.0.1:8765:8765 -v /pfad/zu/Daten:/data:ro --name kfe-viewer kfe-viewer +``` + +## Funktionen + +- **Live-Suche** über Positionsnummer, Matchcode, Kurztext, Zusatztext und optional den Langtext (Vortext) + - mehrere Begriffe = UND, `-wort` schließt aus, `"genaue phrase"` + - `04.03` sucht nach Positionsnummer-Präfix + - Umlaute/Komma egal: `schutzkontakt` findet „Schutzkontakt“, `3x1.5` findet „3x1,5“ +- **Filter** mit Trefferzahlen: Kapitel → Gruppe, Einheit, Lohngruppe, Rohstoff (Cu/Al), Änderungsjahr, Bauzeit- und Preisbereich +- **Kalkulation live**: Stundensätze je Lohngruppe (Satz 1/2 oder eigene), Materialzuschlag, Bauzeitfaktor, + aktuelle DEL-Notiz für Kupfer/Aluminium (Rohstoff-Mehr-/Minderpreis), netto/brutto +- **Detailansicht** mit Langtext, Kalkulationsaufschlüsselung, den vier KFE-Kalkulationssätzen, Rohstoffdaten +- **Merkliste** mit Mengen, Summen (Lohn/Material/netto/brutto/Bauzeit) und CSV-Export; die 60 Muster-LVs + (`Daten/Projekt/*.prj`) lassen sich direkt übernehmen +- URL enthält Suche und Filter (Lesezeichen/teilen), Tastatur: `/` Suche, `↑`/`↓` Auswahl, `Enter` Details, `+` merken, `Esc` schließen +- Hell/Dunkel-Modus, mobil nutzbar + +## Preisformel + +``` +EP = Bauzeit[min] × Bauzeitfaktor × Stundensatz(LG) / 60 + (Material + Rohstoffzuschlag) × (1 + Zuschlag %) +Rohstoffzuschlag = (DEL − Basis) / 100 × Cu-Zahl[kg/km] / 1000 +``` + +Mit den Standardwerten (43,75 €/h, 20 %, Cu 550) stimmen die Preise mit dem GAEB-Export der KFE überein. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4855bba --- /dev/null +++ b/compose.yaml @@ -0,0 +1,12 @@ +services: + kfe-viewer: + build: . + image: kfe-viewer:latest + container_name: kfe-viewer + ports: + # nur lokal erreichbar; für Zugriff aus dem LAN "8765:8765" verwenden + - "127.0.0.1:8765:8765" + volumes: + # Verzeichnis mit KFE.DBF; Pfad über KFE_DATA_DIR (z. B. in .env) anpassbar + - ${KFE_DATA_DIR:-./data}:/data:ro + restart: unless-stopped diff --git a/index.html b/index.html new file mode 100644 index 0000000..5a9467b --- /dev/null +++ b/index.html @@ -0,0 +1,955 @@ + + + + + +KFE Viewer + + + + + +
+ +
+ +
KFE Viewer…
+
+ + +
+ + +
+ +
+ + +
+
+ + + +
+
+ + + + + + + + + + + + +
Pos.KurztextMEZeitLohnMaterialEP netto
+
+ +
+
+ + +
+ + + +
+
Lade KFE-Daten …
+ + + + diff --git a/server.py b/server.py new file mode 100644 index 0000000..5ad0fc3 --- /dev/null +++ b/server.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""KFE-Viewer – leichtgewichtiger Web-Viewer für die ZVEH-Kalkulationshilfe (KFE). + +Liest die dBase/FoxPro-Dateien des originalen KFE-Viewers (KFE.DBF, VORTEXT.DBF, ...) +direkt ein und stellt sie über einen kleinen HTTP-Server als Web-App bereit. +Keine Abhängigkeiten außer der Python-Standardbibliothek (>= 3.8). + + python server.py # startet auf http://127.0.0.1:8765 + python server.py --port 9000 --no-browser + python server.py --data PFAD/ZU/Daten # anderes Datenverzeichnis + python server.py --static kfe.html # erzeugt eine einzelne HTML-Datei ohne Server +""" + +import argparse +import configparser +import gzip +import hashlib +import json +import os +import re +import signal +import struct +import sys +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HERE = Path(__file__).resolve().parent +# Standard: ./data neben dem Skript, sonst die Verzeichnisstruktur der KFE-CD daneben +DEFAULT_DATA = next((p for p in (HERE / "data", + HERE.parent / "2020-05-12-NEU" / "0_software" / "KFE_ViewerCD3" / "Daten") + if (p / "KFE.DBF").exists()), HERE / "data") + +# Mengeneinheiten-Codes der ZVEHNORM (abgeglichen mit den DATANORM-Exporten) +MEH = {"01": "Stk", "12": "cm", "13": "m", "23": "kg", "33": "l", "42": "cm²", "43": "m²", "53": "Std"} + + +# --------------------------------------------------------------------------- DBF + +def read_dbf(path, encoding="cp850"): + """Liest eine dBase/FoxPro-Tabelle. Memofelder werden als Blocknummer (int) geliefert.""" + with open(path, "rb") as f: + head = f.read(32) + count, header_len, record_len = struct.unpack("H", self.data[6:8])[0] or 64 + self.encoding = encoding + + def get(self, block): + if not block: + return "" + off = block * self.block + _typ, length = struct.unpack(">II", self.data[off:off + 8]) + raw = self.data[off + 8:off + 8 + length] + text = raw.decode(self.encoding, "replace").rstrip("\0\x1a ") + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def num(s, default=0.0): + try: + return float(str(s).replace(",", ".")) + except (TypeError, ValueError): + return default + + +def iso(d): + d = (d or "").strip() + return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if len(d) >= 8 and d[:8].isdigit() else "" + + +# --------------------------------------------------------------------------- Laden + +def load_params(data_dir): + p = { + "lohn": {"1": {"10": 43.75, "11": 90.77, "12": 67.22}, "2": {"10": 49.95, "11": 114.72, "12": 87.32}}, + "zuschlag": [20.0, 35.0], + "mwst": 19.0, + "bzfakt": 1.0, + "basis": {"CU": 550.0, "AL": 200.0}, + "stand": "", + "zeitraum": "", + } + std = data_dir / "STDPARAM.DAT" + if std.exists(): + cp = configparser.ConfigParser() + cp.read_string(std.read_text("cp1252", "replace")) + s = cp["PARAMETER"] if cp.has_section("PARAMETER") else {} + p["lohn"]["1"]["10"] = num(s.get("LOHN1"), 43.75) + p["lohn"]["2"]["10"] = num(s.get("LOHN2"), 49.95) + p["zuschlag"] = [num(s.get("ZU1"), 20), num(s.get("ZU2"), 35)] + p["mwst"] = num(s.get("MWST"), 19) + p["bzfakt"] = num(s.get("BZFAKT"), 1) + lstd = data_dir / "LOHNSTD.DBF" + if lstd.exists(): + rows = read_dbf(lstd) + if rows: + r = max(rows, key=lambda r: r["ZEIT"]) # aktuellster Zeitraum, z.B. "1920" + p["zeitraum"] = f"20{r['ZEIT'][:2]}/{r['ZEIT'][2:]}" + p["lohn"]["1"]["11"] = num(r["S1_PG11"]) + p["lohn"]["2"]["11"] = num(r["S2_PG11"]) + p["lohn"]["1"]["12"] = num(r["S1_PG12"]) + p["lohn"]["2"]["12"] = num(r["S2_PG12"]) + roh = data_dir / "ROHSTOFF.DBF" + if roh.exists(): + for r in read_dbf(roh): + p["basis"][r["ROHART"]] = num(r["ROHBASIS"]) + ver = data_dir / "DVERSION.DAT" + if ver.exists(): + m = re.search(r"UPDATE=(\d\d)/(\d\d)/(\d\d)", ver.read_text("cp1252", "replace")) + if m: + p["stand"] = f"20{m.group(1)}-{m.group(2)}-{m.group(3)}" + return p + + +def load_projects(proj_dir): + projects = [] + if not proj_dir.is_dir(): + return projects + for f in sorted(proj_dir.glob("*.prj")): + lines = f.read_text("cp1252", "replace").splitlines() + items = [] + for line in lines[1:]: + c = line.split(";") + if not c or not c[0] or c[0] == "V": + continue + if c[0] == "T": + text = c[1].strip() if len(c) > 1 else "" + if text: + items.append({"t": text}) + continue + if not re.match(r"^\d\d\.\d\d\.\d\d", c[0]) or len(c) < 5: + continue + item = {"p": c[0].strip(), "n": num(c[4], 1)} + if num(c[1]) > 0: + item["lohn"] = num(c[1]) + if num(c[2]) > 0: + item["zu"] = num(c[2]) + items.append(item) + m = re.match(r"^(\d\d)(\d\d)(\d\d)_(.*)$", f.stem) + nr = f"{m.group(1)}.{m.group(2)}.{m.group(3)}" if m else "" + name = (m.group(4) if m else f.stem).replace("_", " ") + projects.append({"file": f.name, "nr": nr, "name": name, "items": items}) + return projects + + +def load(data_dir): + t0 = time.time() + data_dir = Path(data_dir) + if not (data_dir / "KFE.DBF").exists(): + sys.exit(f"KFE.DBF nicht gefunden in: {data_dir}\nBitte mit --data das Daten-Verzeichnis angeben.") + + groups = {r["GR"]: r["BEZ"] for r in read_dbf(data_dir / "GRUPPEN.DBF")} + + vt_memo = Memo(data_dir / "VORTEXT.FPT") + vortexte = {r["VT_ID"]: vt_memo.get(r["VT_BEZ"]) for r in read_dbf(data_dir / "VORTEXT.DBF")} + + memo = Memo(data_dir / "KFE.FPT") + positions = [] + for r in read_dbf(data_dir / "KFE.DBF"): + roh = [] + for i in range(1, 5): + art = r.get(f"ROHART{i}", "") + if art: + roh.append([art, num(r[f"ROHGEW{i}"]), num(r[f"ROHBASIS{i}"]), num(r[f"ROHKORR{i}"])]) + lg = r.get("LOHNGR", "") + dates = [d for d in (r["INFO_DAT"], r["KNOTIZ_DAT"], r["PNOTIZ_DAT"]) if d] + vt = r["METAVTNR"] if r["METAVTNR"] in vortexte else "" + positions.append({ + "p": r["LPOSNR"], + "a": r["LPOSNR_A"], + "me": r["MEH"], + "ae": num(r["AE"]), + "mat": num(r["MAT"]), + "i": r["INFO"], + "k": memo.get(r["KNOTIZ"]), + "z": memo.get(r["PNOTIZ"]), + "v": vt, + "lg": lg if lg in ("11", "12") else "10", + "d": iso(max(dates)) if dates else "", + "r": roh, + }) + positions.sort(key=lambda x: x["p"]) + + payload = { + "meta": {"params": load_params(data_dir), "meh": MEH, "quelle": str(data_dir)}, + "groups": groups, + "vortexte": vortexte, + "positions": positions, + "projects": load_projects(data_dir / "Projekt"), + } + print(f" {len(positions)} Positionen, {len(vortexte)} Vortexte, {len(groups)} Gruppen, " + f"{len(payload['projects'])} Projekte geladen ({time.time() - t0:.1f}s)") + return payload + + +# --------------------------------------------------------------------------- HTTP + +def make_handler(index_html, data_json): + data_gz = gzip.compress(data_json, 6) + etag = '"' + hashlib.md5(data_json).hexdigest()[:16] + '"' + + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def send(self, code, body, ctype, extra=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path in ("/", "/index.html"): + # index.html bei jedem Aufruf neu lesen, damit Änderungen ohne Neustart wirken + html = (HERE / "index.html").read_bytes() if (HERE / "index.html").exists() else index_html + self.send(200, html, "text/html; charset=utf-8", {"Cache-Control": "no-cache"}) + elif path == "/api/data": + if self.headers.get("If-None-Match") == etag: + self.send_response(304) + self.end_headers() + return + hdr = {"ETag": etag, "Cache-Control": "no-cache"} + if "gzip" in self.headers.get("Accept-Encoding", ""): + hdr["Content-Encoding"] = "gzip" + self.send(200, data_gz, "application/json", hdr) + else: + self.send(200, data_json, "application/json", hdr) + else: + self.send(404, b"Not found", "text/plain") + + do_HEAD = do_GET + + return Handler + + +def main(): + ap = argparse.ArgumentParser(description="Web-Viewer für die ZVEH-Kalkulationshilfe (KFE)") + env = os.environ.get + ap.add_argument("--data", default=env("KFE_DATA", str(DEFAULT_DATA)), + help="Verzeichnis mit KFE.DBF (env KFE_DATA, default: %(default)s)") + ap.add_argument("--host", default=env("KFE_HOST", "127.0.0.1"), + help="Bind-Adresse, 0.0.0.0 für Zugriff im LAN (env KFE_HOST)") + ap.add_argument("--port", type=int, default=int(env("KFE_PORT", "8765")), help="Port (env KFE_PORT)") + ap.add_argument("--no-browser", action="store_true", default=env("KFE_NO_BROWSER") == "1", + help="Browser nicht automatisch öffnen (env KFE_NO_BROWSER=1)") + ap.add_argument("--static", metavar="DATEI", help="Einzelne HTML-Datei mit eingebetteten Daten erzeugen") + args = ap.parse_args() + + print("KFE-Viewer: lade Daten aus", args.data) + payload = load(args.data) + data_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + index_html = (HERE / "index.html").read_bytes() + + if args.static: + # "-Tag nicht beenden können + inline = data_json.decode("utf-8").replace("", f"", 1) + Path(args.static).write_text(html, "utf-8") + print(f" Statische Datei geschrieben: {args.static} ({len(html) / 1e6:.1f} MB)") + return + + server = ThreadingHTTPServer((args.host, args.port), make_handler(index_html, data_json)) + url = f"http://{'127.0.0.1' if args.host in ('0.0.0.0', '') else args.host}:{args.port}/" + print(f" Läuft auf {url} (Strg+C zum Beenden)") + if not args.no_browser: + webbrowser.open(url) + # SIGTERM (z.B. "docker stop") wie Strg+C behandeln – als PID 1 würde Python es sonst ignorieren + def stop(*_): + raise KeyboardInterrupt + signal.signal(signal.SIGTERM, stop) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nBeendet.") + + +if __name__ == "__main__": + main() diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..4bc86a1 --- /dev/null +++ b/start.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +python server.py %* +pause