Bisher wurde eine mehrtaegige Terminreihe beim Speichern komplett geloescht und neu angelegt, der Termin bekam also jedes Mal eine neue id. Jetzt bleibt der Hauptsatz stehen und wird aktualisiert; nur die Folgetage werden neu angelegt. Damit bleiben Verweise auf die id gueltig (Drucken, Links, Aenderungsprotokoll), und Anlegen und Aendern laufen ueber denselben Weg. terminHauptsatz() loest jede Zeilen-id auf ihre Reihe auf, so dass auch der Einstieg ueber eine Nebenzeile oder ueber Altdaten ohne main_id funktioniert; loescheTerminreihe() und setzeTerminErledigt() nutzen das ebenfalls. templates/calendar_month.tpl entfernt: kein Code band die Monatsansicht ein, und unter PHP 8 lief sie in einen Fehler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
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 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:
- Konstanten, Session, Konfiguration, DB-Verbindung
- Schema-Migration (siehe unten)
head.tpl- bei gültiger Session:
checkRightsNInclude()ausincs/funcs.php— einswitchüber$_GET["action"], das Rechte prüft und den Pfad insites/zurückgibt, der dann inkludiert wird 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()unddb_zuordnung()wandeln die nativen int/float-Werte der Prepared Statements um. Werdb_abfrage()selbst ausliest, bekommt native Typen. - Formularwerte über
formularText(),formularZahl()(Komma als Dezimaltrenner, leer wird 0) undformularGanzzahl()ausincs/saveNchange.phpholen – der strikte Modus lehnt''in Zahlenspalten ab. - Änderungsprotokoll:
saveLog()bekommt den Text ausdb_sql_text(). Die Tabellelogist reine Dokumentation, nichts daraus wird ausgeführt. - Mehrschrittiges Speichern in eine Transaktion, damit bei Fehlern keine halben Datensätze bleiben — aber über
beginneSpeichern()/beendeSpeichern()ausincs/saveNchange.php, nicht übermysqli_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,
LIMITals(int). - Suchabfragen (
create*Qry()inincs/listfuncs.php) liefernarray("all", "limited", "params"). Aufrufer nutzendb_abfrage($dbconn, $q["limited"], $q["params"])undzaehleTreffer(). Die Suchbegriffe sind Platzhalterwerte,%und_darin bleiben absichtlich Jokerzeichen — so ist die Suche dokumentiert. In der Autovervollständigung (ajax/auto/) werden sie dagegen überdb_like_praefix()entwertet. - Werte aus der Datenbank sind kein HTML.
GROUP_CONCATtrennt mit einem Zeilenumbruch; zusammengebaut und escaped wird erst inzeilenHtml()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.phplädtincs/PHPMailer_v6für den Rechnungsversand,sites/projectadd.phpdagegenincs/PHPMailer_v5.1. Keine der beiden ist entfernbar, ohne den jeweiligen Aufrufer umzustellen. - PDF-Schriften nur über
PDF_SCHRIFT_SERIF/PDF_SCHRIFT_SANS(oben inincs/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 vonstats.phpfür die Diagramme geladen; TCPDF bringt seine Schriften inincs/tcpdf/fonts/selbst mit.index.phpsetzterror_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/undtemp/müssen für den Webserver schreibbar sein. Sie sind git-ignoriert und nur über.gitkeepim Repo vorhanden.uploads_*/{notes,signs}enthalten personenbezogene Kundendaten (Rechnungen, Scans, Unterschriften) und sind bewusst git-ignoriert. Nie committen.- Zeilenenden werden über
.gitattributesauf 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()inincs/authHelper.phpanhand 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.phpeinenaddKey,sites/userkeys.phplöst ihn ein und legt$_SESSION["passkey_freigabe"](Benutzer-id, 5 Minuten) ab.authServer.phpakzeptiertgetCreateArgs/processCreatenur 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.