From a19558fc6cc47d62d16c4b41305c67751bcb7039 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 3 Oct 2026 14:00:55 +0200 Subject: [PATCH] =?UTF-8?q?KFE-Viewer:=20Web-Viewer=20f=C3=BCr=20die=20ZVE?= =?UTF-8?q?H-Kalkulationshilfe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 3 + .env.example | 3 + .gitattributes | 2 + .gitignore | 5 + Dockerfile | 22 ++ README.md | 76 ++++ compose.yaml | 12 + index.html | 955 +++++++++++++++++++++++++++++++++++++++++++++++++ server.py | 322 +++++++++++++++++ start.bat | 4 + 10 files changed, 1404 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 index.html create mode 100644 server.py create mode 100644 start.bat 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