| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/clients/client1/seaside2.pacim.de/web/ |
Upload File : |
# PaCIM SaaS
Professionelle lokale SaaS Web App für Kreuzfahrtparkplatz Check-in, Buchungen und Rechnungsmanagement.
## Installation
1. Stelle sicher, dass MySQL/MariaDB lokal installiert und erreichbar ist.
2. Projektverzeichnis: `/home/nico/projects/apps/pacim/pacim-saas`
3. PHP 8.x erforderlich.
4. Keine Composer-Abhängigkeiten notwendig (PHPMailer/PhpSpreadsheet/Dompdf werden automatisch erkannt, wenn installiert).
## MySQL Zugangsdaten
- Host: `localhost`
- Benutzer: `root`
- Passwort: `root`
- Datenbank: `pacim_saas`
## Lokale Ausführung
Starten (importiert Schema, Demo-Daten und alle Migrationen in `database/migrations/`):
```bash
bash scripts/start.sh
```
Die App ist dann verfügbar unter:
```
http://localhost:8092
```
Stoppen:
```bash
bash scripts/stop.sh
```
## Standard-Logins
Alle Demo-Benutzer haben das Passwort **`admin`**:
| Rolle | E-Mail | Sichtbar |
|---------------|---------------------------|----------|
| Administrator | `admin@pacim.local` | Alles, inkl. Benutzer & Einstellungen |
| Manager | `manager@pacim.local` | Buchungen, Events, Check-in, Reports, Kunden, Produkte, Zahlungen, Rechnungen, Tagesabschluss |
| Check-in | `checkin@pacim.local` | Tagesliste, Check-in/-out, Mobile Check-in |
| Buchhaltung | `accounting@pacim.local` | Rechnungen, Zahlungen, Tagesabschluss, Reports, Kunden |
> Passwort sofort ändern bevor produktiv eingesetzt wird. Hashes werden mit `password_hash(..., PASSWORD_BCRYPT)` gespeichert.
## Rollen
Definiert in `app/core/Permission.php`:
- **admin** – Vollzugriff, einziger Account mit Zugriff auf `users` und `settings`.
- **manager** – Operativer SaaS-Manager. Alles außer Benutzer- und Systemeinstellungen.
- **checkin** – Parkplatz-Crew. Nur Tagesliste und Check-in/-out, inkl. mobiler Ansicht.
- **accounting** – Buchhaltung. Nur Rechnungen, Zahlungen, Tagesabschluss und Reports.
Die Sidebar zeigt nur die Menüpunkte an, die für die jeweilige Rolle erlaubt sind. Zugriff auf gesperrte Routen führt zu einer 403-Seite.
## SMTP / E-Mail einrichten
In **Einstellungen → SMTP / E-Mail** als Admin konfigurieren:
- `SMTP Host`, `SMTP Port`, `SMTP User`, `SMTP Passwort`
- Verschlüsselung: `tls`, `ssl` oder `none`
- Absender-Mail und -Name
Wenn `PHPMailer` (Klasse `\PHPMailer\PHPMailer\PHPMailer`) im PHP-Include-Pfad gefunden wird und `SMTP Host` gesetzt ist, wird PHPMailer benutzt. Andernfalls greift der eingebaute `mail()`-Fallback. Wenn `mail()` nicht verfügbar ist, wird jede Mail in `storage/mail-log/` archiviert (mit HTML-Vorschau und Tageslog).
Testmail über den Button **„Testmail senden"** in den Einstellungen verschicken.
E-Mail-Versand-Aktionen sind:
- `MailService::sendBookingConfirmation()` (Buttons: Buchungsdetail-Seite)
- `MailService::sendInvoice()` (Buttons: Rechnungsdetail-Seite)
- `MailService::sendTestMail()` (Button: Settings)
## Buchungen importieren — DB-Strukturübersicht
Diese Übersicht beschreibt nur die Tabellen, die für **Buchungs-Imports** relevant sind.
Spalten, die der Import nicht selbst setzen muss (`created_at`, `updated_at`, etc.), sind ausgelassen.
### Reihenfolge der Inserts
```
customers → events → bookings → booking_products
↘ payments
↘ invoices → invoice_items
```
Eine Buchung kann nur existieren, wenn **Kunde** und **Event** schon angelegt sind.
Beträge auf Buchungs- *und* Produkt-Ebene werden vom Import berechnet — die Web-App rechnet nicht nach.
### `customers`
| Spalte | Typ | Pflicht | Hinweis |
|---|---|---|---|
| `first_name` | VARCHAR(100) | ✔ | |
| `last_name` | VARCHAR(100) | ✔ | |
| `company` | VARCHAR(191) | | NULL = Privatkunde |
| `street` / `zip` / `city` / `phone` | – | | optional; bei Importen aus Fremdsystemen oft leer |
| `email` | VARCHAR(191) | | **UNIQUE** (NULL erlaubt) — Konflikt = bestehenden Kunden wiederverwenden |
| `source` | VARCHAR(50) | | Quelle: `'AIDA'`, `'Parken-am-Schiff.de'`, … — steuert Anzeige-Filter & Tagesabrechnung |
| `status` | VARCHAR(50) | | Default `'active'` |
| `legacy_source_id` | INT | | Original-ID aus dem Quellsystem (für Re-Imports / Updates) |
**Beachten:** `email` ist eindeutig. Bei Konflikt nach `legacy_source_id` matchen und updaten, statt neuen Kunden anzulegen.
### `events`
| Spalte | Typ | Pflicht | Hinweis |
|---|---|---|---|
| `title` | VARCHAR(191) | ✔ | |
| `ship_name` | VARCHAR(191) | | wird auch für AIDA-Prebooking-Matching genutzt |
| `cruise` | VARCHAR(32) | | Cruise-ID aus der AIDA-Vorbucherliste (wird beim Import automatisch gesetzt) |
| `organizer_id` | INT | | FK → `organizers` |
| `terminal` | VARCHAR(100) | ✔ | |
| `starts_at` / `ends_at` | DATETIME | ✔ | Reisedauer-Klammer |
| `arrival_from` / `arrival_to` | DATETIME | ✔ | Anreisefenster (für die Anreiseliste/Slot-Vergabe) |
| `capacity_indoor` / `_outdoor` / `_valet` | INT | | Default 0 |
| `status` | VARCHAR(50) | | Default `'scheduled'` |
| `legacy_source_id` | INT | | siehe Customer |
**Beachten:** Vor dem Buchungs-Insert muss ein passendes Event existieren (`starts_at`/`ends_at` + `ship_name`/`title` decken den Reisezeitraum).
### `bookings`
| Spalte | Typ | Pflicht | Hinweis |
|---|---|---|---|
| `booking_no` | VARCHAR(20) | ✔ | **UNIQUE** — typisch `PAS12856` (eigene) oder `K-7417` (AIDA). Generierung über Sequence/Counter. |
| `customer_id` | INT | ✔ | FK → `customers.id` |
| `event_id` | INT | ✔ | FK → `events.id` |
| `travel_from` / `travel_to` | VARCHAR(191) | | **als String `YYYY-MM-DD`** speichern (siehe Hinweis unten) |
| `arrival_time` | DATETIME | | Soll-Anreisezeit am Schalter |
| `license_plate` | VARCHAR(50) | | Kennzeichen wie eingegeben (z.B. `HH-AB 1234`) |
| `normalized_license_plate` | VARCHAR(50) | | **Pflicht beim Import**: `UPPER` + alle Sonderzeichen/Leerzeichen raus (`HHAB1234`). Wird für Live-Suche und Dubletten-Erkennung benutzt. |
| `guest_count` | TINYINT | | Default 1 |
| `status` | VARCHAR(50) | | Default `'confirmed'`. Werte: `confirmed`, `cancelled` |
| `payment_status` | VARCHAR(50) | | Default `'open'`. Werte: `open`, `partial`, `paid`, `failed` |
| `checkin_status` | VARCHAR(50) | | Default `'pending'`. Werte: `pending`, `checked_in`, `checked_out`, `cancelled` |
| `total_net` / `total_vat` / `total_gross` | DECIMAL(10,2) | ✔ | **Beträge der Buchung — selbst berechnen**, müssen mit den Summen aus `booking_products` übereinstimmen |
| `discount_label` | VARCHAR(100) | | optional |
| `discount_amount` | DECIMAL(10,2) | | Default 0.00 |
| `notes` | TEXT | | |
| `legacy_booking_no` | VARCHAR(64) | | originale Buchungsnummer aus dem Quellsystem |
| `legacy_source_id` | INT | | originale ID — für Re-Imports |
**Beachten:**
- `travel_from`/`travel_to` ist bewusst `VARCHAR` und enthält das ISO-Datum (`YYYY-MM-DD`). Die Anreise-/Abreiseliste filtert per `REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'` — Strings ohne dieses Format werden **nicht** in den Tageslisten angezeigt.
- `normalized_license_plate` muss konsistent erzeugt werden (Großbuchstaben, ohne Trennzeichen). Vorlage: [`Normalize::licensePlate()`](app/core/Normalize.php).
- Beim Storno wird `status = 'cancelled'` gesetzt — die Buchung verschwindet automatisch aus allen aktiven Listen.
### `booking_products`
| Spalte | Pflicht | Hinweis |
|---|---|---|
| `booking_id` | ✔ | FK → `bookings.id` |
| `product_id` | ✔ | FK → `products.id` |
| `quantity` | | Default 1 |
| `price_net` / `vat_rate` / `price_gross` | ✔ | Preisstand zum Buchungszeitpunkt — wird nicht aus `products` gezogen, sondern aus dem Import übernommen |
| `total_gross` | ✔ | = `price_gross * quantity` |
**Beachten:** Mindestens eine Position pro Buchung; die Summen müssen mit `bookings.total_*` übereinstimmen.
### `invoices` + `invoice_items` (optional bei Import)
Wenn beim Import bereits eine Rechnung existiert, parallel zur Buchung anlegen:
- `invoices.invoice_no` ist **UNIQUE** (Format z.B. `PAS12856`, `AIDA-17162161`).
- `invoices.booking_id` + `customer_id` als FK.
- `invoice_date` (DATE) und `status` (`draft` / `sent` / `paid`) setzen.
- `invoice_items`: eine Zeile pro Produkt, identische Preisaufschlüsselung wie in `booking_products`.
### `payments` (optional bei Import)
Wenn die Buchung im Quellsystem bereits bezahlt war:
- `booking_id` (✔), `invoice_id` (optional), `amount` (✔), `payment_method` (✔ — `cash` / `card` / `bank_transfer` / `other`, siehe `payment_methods`-Tabelle), `paid_at` (✔).
- Nach Insert: `bookings.payment_status` auf `'paid'` setzen, wenn `SUM(payments.amount) >= bookings.total_gross`.
### Wichtig insgesamt
1. **Beträge selbst rechnen** — die App vertraut auf konsistente Summen zwischen `booking_products` und `bookings`.
2. **`normalized_license_plate` immer setzen** — sonst Live-Suche und Excel-Export defekt.
3. **`travel_from`/`travel_to` als `YYYY-MM-DD`-String** — nicht als DATE-Cast und nicht mit Zeitanteil.
4. **`legacy_source_id` füllen**, wenn die Quelle eine eigene ID hat — ermöglicht spätere Re-Imports/Updates ohne Dubletten.
5. **Status-Defaults respektieren** — `confirmed/open/pending` ist der Normalfall, `cancelled` für Stornos. Nicht andere Werte erfinden, sonst greifen die Bedingungen in Anreise-/Abreiseliste, Dashboard und Tagesabrechnung nicht.
6. **`customer.source`** entscheidet über Sichtbarkeit unter „Einstellungen → System → Darstellung" und steuert die AIDA-spezifischen Sheets im Tagesabrechnungs-Excel — beim Import konsistent setzen (`'AIDA'`, `'Parken-am-Schiff.de'`, etc.).
## AIDA-Buchungs-Importer
AIDA liefert pro Anlauf zwei XLSX-Listen — eine ca. zwei Tage vorher, eine Korrektur etwa einen Tag vor der Anreise.
Die Korrekturliste kann **geänderte**, **neue**, **fehlende** und **umsortierte** Zeilen enthalten.
Die Excel-Zeilennummer ist deshalb **nie** Identitätsmerkmal.
### Workflow
```
data/aida/*.xlsx
│
▼
AidaBookingImporter::processInbox()
│
├─ files.file_md5 ← Datei-Dedupe gegen die universelle Datei-Registry
├─ Cruise-ID Lookup → events.cruise
├─ external_uid (stabil) + content_hash (Änderungs-Erkennung)
├─ Customer Upsert (email → name+phone Fallback)
├─ Booking Upsert (UNIQUE (source, external_uid))
├─ booking_products Replace (immer komplett ersetzt)
└─ Storno-Pass: alle AIDA-Buchungen DIESES Events,
deren external_uid nicht in der Liste war
│
▼
data/trash/YYYY-MM-DD/<datei>
```
### Aufruf
```bash
php scripts/import-aida-bookings.php
```
### DB-Erweiterungen (Migration 027)
`bookings`:
- `source` `VARCHAR(50)` — z.B. `'AIDA'`
- `external_uid` `VARCHAR(64)` — SHA1 über fachliche Schlüsselfelder, **identitätsstabil über Updates**
- `content_hash` `VARCHAR(64)` — SHA1 über veränderliche Felder, erkennt Diffs ohne Spaltenvergleich
- `import_run_id` `INT` — Referenz auf den Lauf
- `UNIQUE (source, external_uid)` — der harte Identitäts-Constraint
Neue Tabelle `import_runs` mit `summary` als `JSON` für Audit/Debug.
### Matching-Logik
**`external_uid` — Wiedererkennung** (drei Fallback-Strategien, in dieser Reihenfolge):
| Vorhandene Felder | Hash-Input |
|---|---|
| Booking-Nr + Kabine | `AIDA \| cruise \| booking_no \| cabin \| product_code_base` |
| Booking-Nr + Kennzeichen (Kabine leer) | `AIDA \| cruise \| booking_no \| normalized_plate \| product_code_base` |
| Booking-Nr ohne Kabine/Plate | `AIDA \| cruise \| booking_no \| last_name \| first_name \| product_code_base` |
| Booking-Nr fehlt | **kein UID, Zeile übersprungen** |
`product_code_base` = erste 4 Zeichen aus `Product Code` (siehe Mapping unten). Damit zählt eine
Änderung von „PW500G" → „PW500H" nicht als anderes Produkt — der Stellplatz-Typ bleibt gleich.
**`content_hash` — Diff-Erkennung** kombiniert: Vor-/Nachname, Telefon, E-Mail, Kennzeichen
(roh + normalisiert), Gast-Anzahl, Check-In-Zeit, Verkaufspreis, Produktcode + -name, Kabine,
Departure/Arrival/Duration. Bleibt der Hash gleich → kein Update, nur `import_run_id` wird gesetzt
(damit der Storno-Pass die Zeile als „noch dabei" sieht).
### Produkt-Mapping
Nur die ersten 4 Zeichen des `Product Code` zählen:
| Code-Basis | Produkte |
|---|---|
| `PW50` | Hallenstellplatz (ID 1) |
| `PW85` | Halle (ID 1) + Valet (ID 8) |
| `PW40` | Außenstellplatz (ID 2) |
| `PW75` | Außen (ID 2) + Valet (ID 8) |
Unbekannte Code-Basis → Zeile übersprungen + Fehler gesammelt. Bei Multi-Produkt-Mappings
(PW85/PW75) wird der volle Preis auf die Hauptposition gebucht, das Valet bekommt 0 €.
### Customer-Matching
1. **`email`** vorhanden → exakter Match.
2. Sonst: **`source='AIDA'` + Vor-/Nachname + Telefon** (nur wenn Telefon vorhanden — sonst zu unscharf).
3. Sonst: **neuen Kunden anlegen**.
Beim Update wird `email` nur überschrieben, wenn der existierende Datensatz noch keine hat;
sonst riskieren wir UNIQUE-Konflikte. Bei Race-Conditions (anderer Kunde belegt die E-Mail) wird
auf den bestehenden Kunden „umgeschwenkt".
### Storno-Pass (nicht mehr in der Liste enthaltene Buchungen)
Nach dem Import einer Cruise:
```sql
UPDATE bookings SET
status = 'cancelled',
checkin_status = 'cancelled',
notes = notes || '\nAutomatisch storniert, da nicht mehr in AIDA Update Liste enthalten.'
WHERE source = 'AIDA'
AND event_id = :event_id
AND status <> 'cancelled'
AND external_uid NOT IN (:current_uids);
```
**Sicherheitsnetze**:
- Storno läuft **nur** wenn mindestens eine gültige `external_uid` aus der aktuellen Liste vorliegt — leere/fehlerhafte Datei stoppt den Pass.
- Storno läuft **nur** für das gerade importierte Event (`event_id`), nicht global.
- Cruise ohne passendes Event → komplette Cruise wird übersprungen, **kein** Storno-Pass.
- Eine spätere Liste, die eine bereits stornierte Buchung wieder enthält, **revived** sie automatisch (`status` zurück auf `confirmed`, `checkin_status` auf `pending`).
- Transaktion pro Datei: bei Throwable Rollback aller Inserts/Updates/Stornos.
### Abgefangene Edge Cases
| Situation | Reaktion |
|---|---|
| Datei nicht im AIDA-Booking-Format (z.B. Prediction-List) | `status = not_aida_bookings`, Datei wird NICHT in Trash verschoben |
| MD5 schon in `files` | `status = duplicate`, Datei wird in Trash verschoben |
| Cruise nicht in `events.cruise` | Cruise-Gruppe übersprungen, Fehler gesammelt, kein Storno |
| `Booking No` fehlt | Zeile übersprungen, Fehler gesammelt |
| Unbekannter `Product Code` | Zeile übersprungen, Fehler gesammelt |
| Cabin & Plate leer | Fallback: Hash mit Last/First Name |
| Mehrere Cruises in einer Datei | Pro Cruise eigener `import_run` + eigener Storno-Pass |
| Wiederbelebung eines Stornos | Beim erneuten Auftauchen: `status='confirmed'`, `checkin_status='pending'` |
| E-Mail-UNIQUE-Race | `INSERT` catcht den Fehler, holt bestehende Kunden-ID |
### Felder im Schreib-Pfad
- `bookings.travel_from` / `travel_to` werden als ISO-Strings (`YYYY-MM-DD`) geschrieben (aus den Excel-Seriennummern konvertiert).
- `bookings.arrival_time` aus `Check-In Time` (Excel-Seriennummer inkl. Tagesbruchteil) als `DATETIME`.
- `bookings.normalized_license_plate` = `UPPER` + ohne Sonderzeichen/Leerzeichen.
- `bookings.total_net` / `total_vat` / `total_gross` werden aus `Sales Price` mit `VAT = 19 %` berechnet; die Summen in `booking_products` werden im selben Schritt konsistent geschrieben.
- `bookings.payment_status` = `'paid'` (AIDA-Buchungen sind bereits abgerechnet), `status` = `'confirmed'`, `checkin_status` = `'pending'`.
- `bookings.legacy_booking_no` = originale AIDA-`Booking No`.
- Interne `bookings.booking_no` wird als `AIDA-<cruise>-<cabin|booking_no>` generiert (max. 20 Zeichen, UNIQUE).
## Storage / Upload-Ordner
| Ordner | Inhalt |
|---------------------------------|-------------------------------------------------|
| `storage/booking-confirmations` | HTML/PDF-Buchungsbestätigungen |
| `storage/invoices` | HTML/PDF-Rechnungen |
| `storage/license-plates` | A4-Schilder pro Tag |
| `storage/daily-closings` | Tagesabschluss-Dokumente |
| `storage/mail-log` | Mail-Log und HTML-Vorschauen (Fallback-Modus) |
| `public/uploads/logos` | Hochgeladene Firmenlogos |
Logo-Upload in **Einstellungen → Logo**: PNG oder JPG, max. 5 MB. Validierung erfolgt server-seitig per `mime_content_type` und Größe.
## Audit-Log
Alle relevanten Aktionen werden in `history_log` protokolliert:
- Login, Logout, fehlgeschlagene Logins
- Buchung erstellt
- Zahlung erfasst
- Rechnung erzeugt + per Mail versendet
- Check-in / Check-out
- Tagesabschluss gespeichert
- Benutzer angelegt/bearbeitet/gelöscht
- Einstellungen geändert
Eintrag enthält: `user_id`, `entity_type`, `entity_id`, `action`, `message`, `old_values` (JSON), `new_values` (JSON), `ip_address`, `user_agent`, `created_at`.
Das Dashboard zeigt die letzten Aktivitäten in einem Feed.
## Sicherheit
- Login mit `password_hash` / `password_verify` (bcrypt, Cost 10)
- Session-Regeneration bei Login + Logout
- Inaktivitäts-Timeout (`Auth::SESSION_TIMEOUT` = 2 Stunden)
- Brute-Force-Zähler in `users.failed_login_count`
- CSRF-Token für alle Status-ändernden POSTs (`csrfField()` / `csrfCheck()`)
- Rollen-Middleware im Router (`Permission::can()`)
- Prepared Statements überall (PDO)
- HTML-Escaping via `safeText()`
- Upload-Validierung (Typ + Größe)
- 403/404/500-Fehlerseiten
## Backup-Hinweise
Wichtige Daten:
```bash
# Datenbank
mysqldump -u root -proot pacim_saas > backup-pacim-$(date +%F).sql
# Storage (Dokumente, Logos, Audit-relevante Dateien)
tar czf storage-$(date +%F).tar.gz storage/ public/uploads/
```
Für eine vollständige Wiederherstellung beides einspielen und `bash scripts/start.sh` ausführen.
## Nginx Beispielkonfiguration
```nginx
server {
listen 8092;
server_name localhost;
root /home/nico/projects/apps/pacim/pacim-saas/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico)$ {
expires 30d;
access_log off;
}
}
```
## Deployment auf produktive Umgebung
1. **PHP**: mindestens PHP 8.1 mit Extensions `pdo_mysql`, `mbstring`, `intl`, `gd` (für PDFs nur falls Dompdf benutzt wird).
2. **DocumentRoot** auf `public/` setzen – auf keinen Fall auf das Projekt-Root. Beispiel Apache:
```apache
<VirtualHost *:80>
ServerName cruise.example.com
DocumentRoot /var/www/pacim-saas/public
<Directory /var/www/pacim-saas/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
Nginx-Beispiel siehe unten.
3. **MySQL-Credentials** in [app/config/database.php](app/config/database.php) anpassen – niemals `root`/`root` produktiv.
4. **Schreibrechte** auf `storage/`, `storage/logs/`, `public/uploads/` für den Webserver-User (z. B. `www-data`):
```bash
chown -R www-data:www-data storage public/uploads
chmod -R 0775 storage public/uploads
```
5. **Default-Passwörter** für alle 4 Demo-User sofort über `/?page=users` ändern bzw. Demo-User löschen.
6. **Sessions**: `session.cookie_secure=1`, `session.cookie_samesite=Lax`, `session.cookie_httponly=1` (php.ini oder per `ini_set` im Bootstrap).
7. **HTTPS** zwingend – ohne TLS sind Login-Daten lesbar.
8. **Migrationen automatisch** ausführen: `start.sh` lädt alle Dateien in `database/migrations/` der Reihe nach (idempotent).
9. **Backup**: täglich `bash scripts/backup.sh --with-storage` per Cron:
```cron
15 3 * * * cd /var/www/pacim-saas && bash scripts/backup.sh --with-storage >> /var/log/pacim-backup.log 2>&1
```
10. **Logs**: `storage/logs/app.log` und `storage/mail-log/` regelmäßig rotieren (logrotate).
11. **PHPMailer** für SMTP einbinden, sobald produktiv: per Composer global oder als Bundle in `app/services/lib/`. Die App erkennt es automatisch.
12. **Produktiv-Sicherheit**: `ErrorHandler` zeigt Detail-Stack im 500-View. In Produktion `display_errors=0` in php.ini und Detail-Block entfernen oder per Env-Flag schalten.
## Projektstruktur
- `public/` – Webroot mit Einstiegspunkt und Assets
- `app/core/` – Auth, Permission, AuditLog, PDF, Exporter, Documents, Helpers
- `app/models/` – Repository-Klassen (PDO, ohne ORM)
- `app/services/` – z.B. MailService
- `app/views/` – Bootstrap-Views: dashboard, customers, bookings, payments, invoices, arrivals, daily-closing, reports, users, settings, auth, errors, pdf, mobile
- `database/schema.sql` + `database/seed.sql` – Basisschema und Demo-Daten
- `database/migrations/` – fortlaufende SQL-Migrationen (idempotent)
- `storage/` – generierte PDF/HTML-Dokumente
- `scripts/` – Start/Stop für lokale Entwicklung