403Webshell
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/seaside2.pacim.de/web/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/seaside2.pacim.de/web/README.md
# 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

Youez - 2016 - github.com/yon3zu
LinuXploit