Files
Kundenverwaltung/CLAUDE.md
T
adminandClaude Opus 5 5b7704ff03 Testumgebung neben dem Repo dokumentiert
Die Umgebung zum Umbau (portable MariaDB, Testdaten, Vergleich von altem
und neuem Code) liegt jetzt dauerhaft in ../kundentest statt in einem
sitzungsgebundenen Temp-Verzeichnis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:19:36 +02:00

14 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Kundenverwaltung für ein Ingenieurbüro: Kunden, Termine, Projekte, Angebote, Lieferscheine, Rechnungen, Zeiterfassung. Gewachsene PHP-Anwendung (seit ~2011) mit Smarty-Templates und MariaDB/MySQL. Domänenbegriffe, Bezeichner und UI-Texte sind durchgehend deutsch — das bitte beibehalten.

Tooling

Es gibt kein Composer, npm, Build, Linter oder Testsuite. Abhängigkeiten liegen als Kopie in incs/ und werden dort direkt gepflegt. Ein Update einer dieser Bibliotheken ist immer ein manueller Eingriff, kein Paketmanager-Aufruf.

Syntaxprüfung der eigenen Dateien:

for f in $(git ls-files "*.php" | grep -vE "^(incs/(tcpdf|pChart|PHPMailer|WebAuthn|drawer|font)|smarty|spellcheck|js)/"); do php -l "$f"; done

Zum Ausführen braucht es einen Webserver mit PHP, eine MariaDB/MySQL-Instanz und die externe Konfigurationsdatei (siehe unten). Ohne die Konfiguration springt index.php in den Setup-Modus (update/firststart.php). Nur dann: Das Setup läuft ohne Login, schreibt die Config und kann die Datenbank leeren. Deshalb wird action=setup bei gültiger Config verworfen, und firststart.php und rebuild-settings.php brechen selbst ab. Werte, die in die Config geschrieben werden, immer über var_export() maskieren die Config ist ausführbarer PHP-Code.

Lokal testen (auch unter Windows): Die Umgebungsvariable KV_CONFIG_PATH ersetzt CONFIG_PATH. Die Config-Datei heißt nach dem Verzeichnisnamen des Checkouts, also z.B. Kundenverwaltung_settings.conf; $lokale_netze muss 127.0.0.1/::1 enthalten, sonst gibt es kein Passwortfeld. Start im Repo-Verzeichnis mit php -S 127.0.0.1:8765. Die PDF-Vorschau als Bild (makeImageFromPDF()) braucht ImageMagick unter /opt/bin/convert und funktioniert lokal nicht.

Die Testumgebung zum Umbau liegt neben dem Repo in ../kundentest (portable MariaDB auf Port 3307, Testdatengenerator, Vergleich von altem und neuem Code, Schreib- und Template-Tests). ../kundentest/LIESMICH.md erklärt sie; alles auf einmal läuft mit sh alles.sh neu in dem Verzeichnis.

Die zwei zentralen Mechanismen

1. Das Verzeichnis bestimmt die Instanz

incs/constants.php leitet aus getcwd() den Namen des Installationsverzeichnisses ab. Der wählt aber nur noch die Konfigurationsdatei aus. Der eigentliche Instanzname steht in der Config als $instanz und wird zur Konstante INSTANZ:

Konstante abgeleitet aus Wert bei Installation in …/kunden
CONFIG_FILE Verzeichnisname kunden_settings.conf
INSTANZ $instanz in der Config kunden
UPLOADS_PATH INSTANZ uploads_kunden/
SESSION_NAME INSTANZ KUNDENSESS

Fehlt $instanz in der Config (ältere Configs, Ersteinrichtung), fällt INSTANZ auf den Verzeichnisnamen zurück. Erlaubt sind nur Buchstaben und Ziffern, sonst bricht der Request ab. Die Config wird dafür schon in constants.php einmal eingelesen, also vor session_start(). Code in der Config darf deshalb nichts ausgeben und keine Verbindung voraussetzen.

Aus einer Codebasis laufen dadurch mehrere getrennte Instanzen — die uploads_dev, uploads_ingwa, uploads_kunden, uploads_lsg im Baum sind die Spuren davon. Jede Instanz hat eigene Config, eigene Session, eigene Uploads.

Konsequenz für alles, was per CLI/Cron läuft: Das Arbeitsverzeichnis muss das Installationsverzeichnis sein, sonst wird die falsche (oder keine) Konfiguration geladen.

cd /home/intranet/kunden && php db-backup.php

constants.php kompensiert dabei nur die Aufrufe aus ajax/ und ajax/auto/, indem es ein bzw. drei Ebenen hochgeht.

2. Zugangsdaten liegen außerhalb des Repos

CONFIG_PATH zeigt auf /volume1/homes/wagner/kundenconfig/ (Linux) bzw. c:/kundenverwaltung/config/ (Windows). Geschrieben wird die Datei von update/rebuild-settings.php beim Ersteinrichten.

Neben den DB-Zugangsdaten stehen dort auch $instanz (siehe oben), die SMTP-Zugänge je Firma ($smtp_konten, Schlüssel "*" als Rückfall, genutzt von mail_attachment()), der Zugang zur Statistik-Datenbank für stats.php ($stats_mysql_*) und die Wetter-API-Schlüssel ($openweathermap_appid, $aeris_client_*). Die Datei deklariert $mysql_user, $mysql_pw, $mysql_server, $mysql_db, $firma und weitere ausdrücklich als global und weist sie erst danach zu. Nur deshalb funktioniert readsettings() in incs/settings.php: Das include passiert im Funktionsrumpf, ohne die global-Zeilen blieben die Variablen dort lokal. Wer die Config-Struktur ändert, muss diese Eigenheit erhalten.

Zugangsdaten gehören nie in den Code — db-backup.php zeigt das Muster für Skripte außerhalb des Request-Zyklus (Config einlesen, Passwort über eine temporäre Optionsdatei an mysqldump, nicht per Kommandozeile).

Request-Ablauf

index.php ist der einzige Einstieg für die Oberfläche:

  1. Konstanten, Session, Konfiguration, DB-Verbindung
  2. Schema-Migration (siehe unten)
  3. head.tpl
  4. bei gültiger Session: checkRightsNInclude() aus incs/funcs.php — ein switch über $_GET["action"], das Rechte prüft und den Pfad in sites/ zurückgibt, der dann inkludiert wird
  5. foot.tpl

Eine neue Seite anzulegen heißt: Datei in sites/ plus ein case in checkRightsNInclude(). Ohne den case ist die Seite nicht erreichbar.

Die Endpunkte in ajax/ gehen nicht über index.php. Sie bootstrappen selbst über incs/connectmysql.php (Konstanten, Session, Config, DB, Escape-Wrapper) und rendern eigene Fragmente nach ajax/templates_c/.

Rechtesystem

$_SESSION["rights"] ist ein String, keine Zahl. Die Zeichenposition benennt den Bereich, der Zeichenwert ist eine Bitmaske:

  • Position: RIGHTS_KUNDEN=0, RIGHTS_TERMINE=1, RIGHTS_ANGEBOTE=2, RIGHTS_LIEFER=3, RIGHTS_RECHNUNGEN=4, RIGHTS_SUPERADMIN=5
  • Wert: RIGHTS_LOOK=1, RIGHTS_CHANGE=2, RIGHTS_ADD=4

Geprüft wird immer nach diesem Muster:

if(substr($_SESSION["rights"], RIGHTS_KUNDEN, 1) & RIGHTS_LOOK)

Schema-Migrationen laufen bei jedem Seitenaufruf

Es gibt keine SQL-Migrationsdateien. Der Stand steht in update/thisversion/currentversion.txt (dreistellig, aktuell 010), die Zielversion ist der erste Eintrag in $versions in update/versions.php. index.php ruft bei jedem Request checknewversion() und manageupdate() aus incs/update.php; liegt die Zielversion höher, arbeitet update_tables() in update/database-update.php sich in einem switch von Version zu Version hoch und schreibt die neue Nummer zurück.

Eine Schemaänderung besteht also aus: neuer Eintrag vorn in update/versions.php und passender case in update_tables(). update/thisversion/ muss für den Webserver schreibbar sein.

Datenbankzugriff

Umbau läuft (Branch db-umbau): Verbindungen entstehen nur noch über db_verbinden() aus incs/db.php. Die Funktion setzt utf8mb4, den strikten sql_mode und schaltet die mysqli-Exceptions ab. Neue und umgebaute Abfragen nutzen db_abfrage(), db_zeilen(), db_zeile() und db_wert() mit ?-Platzhaltern statt String-Verkettung. Ziel-Schema ist das der Instanz kunden in utf8mb4; die Altdaten sind teils doppelt kodiert und werden per Migration repariert. Dieser Stand darf nur zusammen mit der migrierten Datenbank live gehen.

Regeln für den Umbau:

  • Ergebnisse sind Strings wie früher bei mysqli_query(): db_zeilen(), db_zeile(), db_wert() und db_zuordnung() wandeln die nativen int/float-Werte der Prepared Statements um. Wer db_abfrage() selbst ausliest, bekommt native Typen.
  • Formularwerte über formularText(), formularZahl() (Komma als Dezimaltrenner, leer wird 0) und formularGanzzahl() aus incs/saveNchange.php holen der strikte Modus lehnt '' in Zahlenspalten ab.
  • Änderungsprotokoll: saveLog() bekommt den Text aus db_sql_text(). Die Tabelle log ist reine Dokumentation, nichts daraus wird ausgeführt.
  • Mehrschrittiges Speichern in eine Transaktion, damit bei Fehlern keine halben Datensätze bleiben — aber über beginneSpeichern() / beendeSpeichern() aus incs/saveNchange.php, nicht über mysqli_begin_transaction() direkt. Die Speicherfunktionen rufen sich gegenseitig auf (savePart()saveAdress()updateKunde()saveAdress(), loeschePartner()delRechnung()), MariaDB kennt aber keine geschachtelten Transaktionen; ein Zähler sorgt dafür, dass nur der äußerste Aufruf öffnet und schließt.
  • Tabellen- und Spaltennamen lassen sich nicht binden; nur feste Werte aus dem Code einsetzen, LIMIT als (int).
  • Suchabfragen (create*Qry() in incs/listfuncs.php) liefern array("all", "limited", "params"). Aufrufer nutzen db_abfrage($dbconn, $q["limited"], $q["params"]) und zaehleTreffer(). Die Suchbegriffe sind Platzhalterwerte, % und _ darin bleiben absichtlich Jokerzeichen — so ist die Suche dokumentiert. In der Autovervollständigung (ajax/auto/) werden sie dagegen über db_like_praefix() entwertet.
  • Werte aus der Datenbank sind kein HTML. GROUP_CONCAT trennt mit einem Zeilenumbruch; zusammengebaut und escaped wird erst in zeilenHtml() bzw. makenameline()/makeadressline()/maketelline().

Umgebaut: Login/Passkeys, Benutzereinstellungen, Änderungsprotokoll, Rechnungsmodul (incs/rechnung.php, sites/rechnungadd.php, sites/umschlagshow.php, ajax/autosave.php, ajax/preview.php, Rechnungssuche), Kunden und Lieferanten (incs/saveNchange.php samt sites/kunden*.php, sites/liefer*.php, Kunden-, Lieferanten-, Adress- und Kommissionssuche, ajax/auto/), Termine (incs/termin.php samt sites/termin*.php, Terminliste, Terminzettel). Offen: Projekte, Zeiten, Benutzerverwaltung, der Rest von incs/makepdf.php, update/.

Prozedurales mysqli. $dbconn wird zusätzlich in $_SESSION["dbconn"] abgelegt, damit incs/mysql_ecape_wrapper.php eine globale Funktion mysql_escape_string() als Shim über diese Verbindung bereitstellen kann — ein Überbleibsel der mysql_*-Ära, das in altem Code noch aufgerufen wird.

SQL wird durchgehend per String-Verkettung gebaut. Escaping ist Handarbeit über mysqli_real_escape_string($dbconn, …) und muss bei jeder Änderung mitgedacht werden.

Termine

Ein Termin über mehrere Tage ist eine Reihe einzelner Zeilen in termin, die alle dieselbe main_id tragen — die id der ersten Zeile (des „Hauptsatzes“). Der Kalender zeigt die Zeilen einzeln, die Terminliste fasst sie über main_id zusammen.

Beim Speichern bleibt der Hauptsatz immer stehen und wird aktualisiert; nur die Folgetage werden gelöscht und neu angelegt. Der Termin behält dadurch seine id, egal ob aus einem Tag mehrere werden oder umgekehrt — Verweise auf die id (Drucken, Links, Protokoll) bleiben gültig. terminHauptsatz() löst dafür jede Zeilen-id auf ihre Reihe auf, damit auch der Einstieg über eine Nebenzeile funktioniert.

Urlaub und Krankheit sind Termine ohne Kundenadresse. Ihre Typ-ids sind fest verdrahtet — TERMTYP_URLAUB = 0 und TERMTYP_KRANKHEIT = 5 in incs/termin.php; incs/users.php rechnet den Urlaubsanspruch über typ = 0 aus und templates/addtermin.tpl blendet danach die Felder um. termtyp muss also eine Zeile mit der id 0 enthalten; beim Kopieren einer Datenbank braucht es dafür NO_AUTO_VALUE_ON_ZERO, sonst macht MariaDB eine 1 daraus.

Ganztägige Termine haben die Dauer 24:00:00 — die Terminliste unterscheidet die Registerkarten daran.

Fallstricke

  • smarty/ enthält Smarty 4.5.6 — der schlichte Verzeichnisname sagt nichts über die Version.
  • PHPMailer liegt in zwei Versionen parallel und beide sind aktiv: index.php lädt incs/PHPMailer_v6 für den Rechnungsversand, sites/projectadd.php dagegen incs/PHPMailer_v5.1. Keine der beiden ist entfernbar, ohne den jeweiligen Aufrufer umzustellen.
  • PDF-Schriften nur über PDF_SCHRIFT_SERIF / PDF_SCHRIFT_SANS (oben in incs/makepdf.php, FreeSerif/FreeSans). Die eingebauten PDF-Schriften Times/Helvetica können nur westeuropäische Zeichen; aus ř, ł, č würde ein „?“.
  • incs/font/ gehört zu pChart, nicht zu TCPDF. Die TTFs dort werden von stats.php für die Diagramme geladen; TCPDF bringt seine Schriften in incs/tcpdf/fonts/ selbst mit.
  • index.php setzt error_reporting(E_ERROR | E_PARSE). Warnungen und Notices sind unterdrückt, und der Code verlässt sich darauf — nicht ohne Not hochdrehen, sonst überflutet es die Ausgabe.
  • templates_c/, ajax/templates_c/ und temp/ müssen für den Webserver schreibbar sein. Sie sind git-ignoriert und nur über .gitkeep im Repo vorhanden.
  • uploads_*/{notes,signs} enthalten personenbezogene Kundendaten (Rechnungen, Scans, Unterschriften) und sind bewusst git-ignoriert. Nie committen.
  • Zeilenenden werden über .gitattributes auf LF normalisiert.

Authentifizierung

Klassischer Login plus WebAuthn/FIDO2. Server-Seite: authServer.php mit incs/WebAuthn (Bibliothek von Lukas Buchs) und incs/authHelper.php; Client-Seite js/auth.js. Schlüsselverwaltung über addKey.php und sites/userkeys.php.

  • Passwort-Login nur aus dem lokalen Netz, von außen nur per Passkey. „Lokal“ entscheidet isLocal() in incs/authHelper.php anhand von $lokale_netze (CIDR-Liste) aus der externen Config. Anfragen mit Proxy-Headern (X-Forwarded-For, Forwarded …) gelten nie als lokal. Webserver und MariaDB laufen in Docker auf dem NAS: Das Docker-Netz gehört nicht in die Liste, sonst gilt jede über den Docker- oder Reverse-Proxy eingehende Anfrage als lokal.
  • Passkeys anlegen geht nur über eine Einmal-Freigabe: Ein Superadmin erzeugt in addKey.php einen addKey, sites/userkeys.php löst ihn ein und legt $_SESSION["passkey_freigabe"] (Benutzer-id, 5 Minuten) ab. authServer.php akzeptiert getCreateArgs/processCreate nur mit dieser Freigabe und nimmt die Benutzer-id von dort, nie aus der URL.

Historie

Am 2026-08-28 wurden tote Pfade entfernt: smarty3/ (ungenutztes Smarty 3.1.6), incs/tcpdf_old/, incs/fpdf.php samt FPDF-Schriftmetriken und incs/font/makefont/, sites/makepdf_OLD.php, incs/users_old.php und oldindex.php. Auf keines davon verwies noch Code.

Am 2026-09-22 kam templates/calendar_month.tpl dazu (Monatsansicht des Kalenders): kein Code band es ein, und unter PHP 8 lief es in einen Fehler.

Das Repository wurde am 2026-08-28 neu initialisiert. Die mitgelieferte Historie von 2014 war irreparabel beschädigt und wurde verworfen — der erste Commit ist der Ausgangsstand, nicht der Projektbeginn.