Initial Kaffeekasse SaaS restart
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
APP_NAME="Kaffeekasse SaaS"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=0
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
APP_TIMEZONE=Europe/Berlin
|
||||||
|
APP_KEY=
|
||||||
|
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=kaffeeliste
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=
|
||||||
|
|
||||||
|
MAIL_FROM=noreply@example.com
|
||||||
|
RFID_SHARED_SECRET=change-me
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/vendor/
|
||||||
|
/.env
|
||||||
|
/build/
|
||||||
|
/storage/cache/*
|
||||||
|
/storage/logs/*
|
||||||
|
/storage/uploads/*
|
||||||
|
/storage/cache/.gitkeep
|
||||||
|
/storage/logs/.gitkeep
|
||||||
|
/storage/uploads/.gitkeep
|
||||||
|
/storage/installed.lock
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
/.DS_Store
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Deployment auf Netcup Webspace
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
- Code unter einem Release-Pfad wie `/apps/kaffeekasse/current`
|
||||||
|
- Document Root in Plesk auf `/apps/kaffeekasse/current/public`
|
||||||
|
- `.env` ausserhalb des Webroots oder direkt im Projektverzeichnis ohne
|
||||||
|
Webzugriff
|
||||||
|
- Migrationen ueber `bin/migrate.php`
|
||||||
|
- zyklische Aufgaben ueber `bin/cron.php`
|
||||||
|
|
||||||
|
## Erstinstallation
|
||||||
|
|
||||||
|
1. Neue Domain oder Subdomain im Netcup WCP/Plesk anlegen.
|
||||||
|
2. Document Root auf das Verzeichnis `public/` des Releases zeigen lassen.
|
||||||
|
3. MySQL-Datenbank und separaten User anlegen.
|
||||||
|
4. Projekt per FTPES oder SFTP hochladen.
|
||||||
|
5. Installer unter `/install` aufrufen oder `.env` manuell setzen.
|
||||||
|
6. Alternativ oder zusaetzlich:
|
||||||
|
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php`
|
||||||
|
7. Plattform-Admin einloggen und ersten Mandanten registrieren.
|
||||||
|
|
||||||
|
## Scheduled Tasks
|
||||||
|
|
||||||
|
- Alle 5 Minuten:
|
||||||
|
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/cron.php`
|
||||||
|
- Optional Healthcheck:
|
||||||
|
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/healthcheck.php`
|
||||||
|
|
||||||
|
## Release-Prozess
|
||||||
|
|
||||||
|
1. `scripts/build-release.sh` erzeugt ein auslieferbares Paket.
|
||||||
|
2. Paket hochladen und entpacken.
|
||||||
|
3. `.env` und persistente `storage/`-Pfade sichern.
|
||||||
|
4. `bin/migrate.php` ausfuehren.
|
||||||
|
5. Healthcheck und Login pruefen.
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
- `public/` bleibt der einzige Webroot.
|
||||||
|
- `.env`, Dumps, Backups und `.git` duerfen nicht oeffentlich erreichbar sein.
|
||||||
|
- Fuer produktive Domains HTTPS erzwingen.
|
||||||
|
- Session-Cookies sind auf `HttpOnly`, `SameSite=Lax` und `Secure` ausgelegt.
|
||||||
|
- Tenant-Daten werden serverseitig ueber Session und Memberships zugeordnet.
|
||||||
|
|
||||||
|
## Git-Remote vorbereiten
|
||||||
|
|
||||||
|
Beispiel fuer spaeteres Pushen auf `git.ctb-it.de`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin ssh://git@git.ctb-it.de/namespace/kaffeekasse-saas.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Die genaue URL haengt von eurem Namespace und dem Zielsystem ab.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Kaffeekasse SaaS Neustart
|
||||||
|
|
||||||
|
Frischer Neustart fuer eine mandantenfaehige Kaffeeliste mit:
|
||||||
|
|
||||||
|
- klassischer Papierliste inklusive Nacherfassung
|
||||||
|
- digitaler Selbstbuchung fuer Browser, Tablet oder Kiosk
|
||||||
|
- vorbereitetem RFID-Pfad ueber Device-, Tag- und Event-Modelle
|
||||||
|
- Netcup-tauglichem PHP/MySQL-Betrieb ohne Root-Zwang
|
||||||
|
- produktionsnaher Vermarktungs- und Betriebsdokumentation
|
||||||
|
|
||||||
|
## Projektziel
|
||||||
|
|
||||||
|
Dieses Repository bildet eine pragmatische SaaS-Basis fuer mehrere Kunden oder
|
||||||
|
Mandanten. Jeder Tenant arbeitet unter `t/{slug}` in einer getrennten
|
||||||
|
Organisation, waehrend Produktlogik, Betrieb und Vermarktung zentral bleiben.
|
||||||
|
|
||||||
|
## Kernentscheidungen
|
||||||
|
|
||||||
|
- `PHP 8.x + MySQL` ohne schwere Runtime-Voraussetzungen
|
||||||
|
- `public/` als einziger Webroot
|
||||||
|
- `tenant_id` auf allen tenantbezogenen Fachtabellen
|
||||||
|
- ein gemeinsames Ledger fuer `digital`, `paper` und spaeter `rfid`
|
||||||
|
- Webspace-freundliche Cron- und CLI-Hooks statt Daemons
|
||||||
|
|
||||||
|
## Hauptfunktionen
|
||||||
|
|
||||||
|
- Plattform-Installation per Browser
|
||||||
|
- Self-Service-Registrierung fuer neue Mandanten
|
||||||
|
- Plattform-Admin mit Tenant-Uebersicht
|
||||||
|
- Tenant-Login mit Rollen `owner`, `admin`, `member`
|
||||||
|
- Mitglieder, Produkte und Preisgrundlagen
|
||||||
|
- digitale Buchungen und Einzahlungen
|
||||||
|
- Papierlisten als Entwurf, Zeilen und Ledger-Posting
|
||||||
|
- RFID-Geraete, Karten und Event-Inbox
|
||||||
|
|
||||||
|
## Schnellstart lokal
|
||||||
|
|
||||||
|
1. `.env.example` nach `.env` kopieren oder den Installer unter `/install` nutzen.
|
||||||
|
2. `database/schema.sql` per `bin/migrate.php` gegen MySQL ausfuehren.
|
||||||
|
3. Webroot auf `public/` zeigen lassen.
|
||||||
|
4. Plattform-Admin anlegen und anschliessend Mandanten registrieren.
|
||||||
|
|
||||||
|
## Wichtige Dateien
|
||||||
|
|
||||||
|
- [public/index.php](/config/workspace/kaffeeliste-neustart/public/index.php)
|
||||||
|
- [database/schema.sql](/config/workspace/kaffeeliste-neustart/database/schema.sql)
|
||||||
|
- [DEPLOY.md](/config/workspace/kaffeeliste-neustart/DEPLOY.md)
|
||||||
|
- [RUNBOOK.md](/config/workspace/kaffeeliste-neustart/RUNBOOK.md)
|
||||||
|
- [docs/product-strategy.md](/config/workspace/kaffeeliste-neustart/docs/product-strategy.md)
|
||||||
|
- [docs/marketing-ops.md](/config/workspace/kaffeeliste-neustart/docs/marketing-ops.md)
|
||||||
|
- [docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md)
|
||||||
|
- [docs/rfid-roadmap.md](/config/workspace/kaffeeliste-neustart/docs/rfid-roadmap.md)
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Runbook
|
||||||
|
|
||||||
|
## Regelbetrieb
|
||||||
|
|
||||||
|
- Installer nach dem ersten Setup nicht erneut oeffnen.
|
||||||
|
- Healthcheck vor und nach Releases pruefen.
|
||||||
|
- Cron-Lauf auf erfolgreiche Ausgabe und Lock-Freigabe kontrollieren.
|
||||||
|
- Backup-Plan fuer DB, `.env` und Uploads fest einplanen.
|
||||||
|
|
||||||
|
## Monitoring-Minimum
|
||||||
|
|
||||||
|
- Login-Fehler und Access-Denied-Ereignisse im Audit beobachten
|
||||||
|
- offene RFID-Events aus `bin/cron.php` beobachten
|
||||||
|
- Wachstum von `consumption_events`, `ledger_entries` und `audit_events` im Blick
|
||||||
|
behalten
|
||||||
|
|
||||||
|
## Backup-Strategie
|
||||||
|
|
||||||
|
- taegliches Datenbank-Backup
|
||||||
|
- Offsite-Kopie von `.env` und `storage/uploads`
|
||||||
|
- Vor jedem Release ein frisches DB-Backup
|
||||||
|
|
||||||
|
## Wiederherstellung
|
||||||
|
|
||||||
|
1. Letztes stabiles Release erneut deployen.
|
||||||
|
2. Datenbank-Backup einspielen.
|
||||||
|
3. `.env` und Uploads wiederherstellen.
|
||||||
|
4. Healthcheck ausfuehren.
|
||||||
|
5. Admin-Login, Tenant-Login und eine Testbuchung pruefen.
|
||||||
|
|
||||||
|
## Offene Ausbaupfade
|
||||||
|
|
||||||
|
- Mail-Verifikation und Einladungs-Workflow
|
||||||
|
- MFA fuer Owner/Admin
|
||||||
|
- Rate-Limits fuer Login und Registrierung
|
||||||
|
- automatisierte Export- und Reminder-Jobs
|
||||||
|
- regelbasierte Uebernahme von RFID-Events in echte Buchungen
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Auth;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Core\Session;
|
||||||
|
use App\Core\View;
|
||||||
|
use App\Services\AuditService;
|
||||||
|
use App\Services\SetupService;
|
||||||
|
use App\Services\TenantRegistrationService;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PlatformController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $rootPath,
|
||||||
|
private readonly array $config,
|
||||||
|
private readonly Database $database,
|
||||||
|
private readonly View $view,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly Csrf $csrf
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function home(): void
|
||||||
|
{
|
||||||
|
$setup = new SetupService($this->rootPath);
|
||||||
|
$stats = null;
|
||||||
|
|
||||||
|
if ($setup->isInstalled() && $this->database->isConfigured()) {
|
||||||
|
try {
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$stats = [
|
||||||
|
'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(),
|
||||||
|
'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(),
|
||||||
|
'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(),
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$stats = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->render('home/index', [
|
||||||
|
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
|
||||||
|
'setupComplete' => $setup->isInstalled(),
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function installForm(): void
|
||||||
|
{
|
||||||
|
$setup = new SetupService($this->rootPath);
|
||||||
|
|
||||||
|
if ($setup->isInstalled()) {
|
||||||
|
Response::redirect('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->render('install/index', [
|
||||||
|
'title' => 'Installation',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function installSubmit(): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
$setup = new SetupService($this->rootPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$setup->install($_POST);
|
||||||
|
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Jetzt kannst du dich als Plattform-Admin anmelden.');
|
||||||
|
Response::redirect('/admin/login');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
Response::redirect('/install');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerForm(): void
|
||||||
|
{
|
||||||
|
if (!(new SetupService($this->rootPath))->isInstalled()) {
|
||||||
|
Response::redirect('/install');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->render('home/register', [
|
||||||
|
'title' => 'Mandant registrieren',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSubmit(): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$audit = new AuditService($pdo);
|
||||||
|
$service = new TenantRegistrationService($pdo, $audit);
|
||||||
|
$slug = $service->register($_POST);
|
||||||
|
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
|
||||||
|
Response::redirect('/t/' . rawurlencode($slug) . '/login');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
Response::redirect('/register');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminLoginForm(): void
|
||||||
|
{
|
||||||
|
$this->view->render('admin/login', [
|
||||||
|
'title' => 'Plattform-Login',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminLoginSubmit(): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
$audit = new AuditService($pdo);
|
||||||
|
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||||
|
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
|
||||||
|
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->flash('success', 'Willkommen im Plattformbereich.');
|
||||||
|
Response::redirect('/admin');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
Response::redirect('/admin/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminDashboard(): void
|
||||||
|
{
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
|
||||||
|
if (!$auth->checkPlatformAdmin()) {
|
||||||
|
$this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.');
|
||||||
|
Response::redirect('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = $pdo->query(
|
||||||
|
'SELECT t.*,
|
||||||
|
COUNT(DISTINCT m.id) AS member_count,
|
||||||
|
COUNT(DISTINCT p.id) AS product_count
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN members m ON m.tenant_id = t.id
|
||||||
|
LEFT JOIN products p ON p.tenant_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.created_at DESC'
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$this->view->render('admin/index', [
|
||||||
|
'title' => 'Plattform-Dashboard',
|
||||||
|
'tenants' => $tenants,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminLogout(): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
$auth->logout();
|
||||||
|
$this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.');
|
||||||
|
Response::redirect('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCsrf(): void
|
||||||
|
{
|
||||||
|
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||||
|
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Auth;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Core\Session;
|
||||||
|
use App\Core\TenantContext;
|
||||||
|
use App\Core\View;
|
||||||
|
use App\Services\AuditService;
|
||||||
|
use App\Services\LedgerService;
|
||||||
|
use App\Services\RfidService;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class TenantController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Database $database,
|
||||||
|
private readonly View $view,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly Csrf $csrf
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loginForm(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant($tenantSlug);
|
||||||
|
|
||||||
|
$this->view->render('tenant/login', [
|
||||||
|
'title' => 'Tenant-Login',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loginSubmit(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
$tenant = $this->tenant($tenantSlug);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
$audit = new AuditService($pdo);
|
||||||
|
|
||||||
|
$success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||||
|
$audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [
|
||||||
|
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
$auth->logout();
|
||||||
|
$this->session->flash('success', 'Du wurdest abgemeldet.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dashboard(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
|
$summaryStatement = $pdo->prepare(
|
||||||
|
'SELECT
|
||||||
|
COUNT(DISTINCT members.id) AS member_count,
|
||||||
|
COUNT(DISTINCT products.id) AS product_count,
|
||||||
|
COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count,
|
||||||
|
COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents
|
||||||
|
FROM tenants
|
||||||
|
LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active"
|
||||||
|
LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1
|
||||||
|
LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id
|
||||||
|
LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id
|
||||||
|
WHERE tenants.id = :tenant_id
|
||||||
|
GROUP BY tenants.id'
|
||||||
|
);
|
||||||
|
$summaryStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
$summary = $summaryStatement->fetch() ?: [
|
||||||
|
'member_count' => 0,
|
||||||
|
'product_count' => 0,
|
||||||
|
'paper_sheet_count' => 0,
|
||||||
|
'open_balance_cents' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$eventsStatement = $pdo->prepare(
|
||||||
|
'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
|
||||||
|
FROM consumption_events c
|
||||||
|
INNER JOIN members m ON m.id = c.member_id
|
||||||
|
INNER JOIN products p ON p.id = c.product_id
|
||||||
|
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||||
|
WHERE c.tenant_id = :tenant_id
|
||||||
|
ORDER BY c.recorded_at DESC
|
||||||
|
LIMIT 8'
|
||||||
|
);
|
||||||
|
$eventsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$balancesStatement = $pdo->prepare(
|
||||||
|
'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
|
||||||
|
FROM members m
|
||||||
|
LEFT JOIN ledger_lines ll
|
||||||
|
ON ll.member_id = m.id
|
||||||
|
AND ll.tenant_id = m.tenant_id
|
||||||
|
AND ll.account_code = "member_balance"
|
||||||
|
WHERE m.tenant_id = :tenant_id
|
||||||
|
GROUP BY m.id
|
||||||
|
ORDER BY balance_cents DESC, m.display_name ASC
|
||||||
|
LIMIT 8'
|
||||||
|
);
|
||||||
|
$balancesStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/dashboard', [
|
||||||
|
'title' => 'Dashboard',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'summary' => $summary,
|
||||||
|
'latestEvents' => $eventsStatement->fetchAll(),
|
||||||
|
'memberBalances' => $balancesStatement->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function members(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$displayName = trim((string) ($_POST['display_name'] ?? ''));
|
||||||
|
$email = trim((string) ($_POST['email'] ?? ''));
|
||||||
|
$accessPin = trim((string) ($_POST['access_pin'] ?? ''));
|
||||||
|
$loginPassword = (string) ($_POST['login_password'] ?? '');
|
||||||
|
|
||||||
|
if ($displayName === '') {
|
||||||
|
throw new RuntimeException('Bitte einen Anzeigenamen angeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = null;
|
||||||
|
|
||||||
|
if ($email !== '') {
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($loginPassword === '' || mb_strlen($loginPassword) < 12) {
|
||||||
|
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userInsert = $pdo->prepare(
|
||||||
|
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||||
|
VALUES (:full_name, :email, :password_hash, "user")'
|
||||||
|
);
|
||||||
|
$userInsert->execute([
|
||||||
|
'full_name' => $displayName,
|
||||||
|
'email' => mb_strtolower($email),
|
||||||
|
'password_hash' => secure_password_hash($loginPassword),
|
||||||
|
]);
|
||||||
|
$userId = (int) $pdo->lastInsertId();
|
||||||
|
|
||||||
|
$membershipInsert = $pdo->prepare(
|
||||||
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||||
|
VALUES (:tenant_id, :user_id, "member", "active")'
|
||||||
|
);
|
||||||
|
$membershipInsert->execute([
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'user_id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$memberInsert = $pdo->prepare(
|
||||||
|
'INSERT INTO members (tenant_id, user_id, display_name, email, access_pin, status)
|
||||||
|
VALUES (:tenant_id, :user_id, :display_name, :email, :access_pin, "active")'
|
||||||
|
);
|
||||||
|
$memberInsert->execute([
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'user_id' => $userId,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'email' => $email !== '' ? mb_strtolower($email) : null,
|
||||||
|
'access_pin' => $accessPin !== '' ? $accessPin : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success');
|
||||||
|
$this->session->flash('success', 'Das Mitglied wurde gespeichert.');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'members'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$membersStatement = $pdo->prepare(
|
||||||
|
'SELECT m.*, u.email AS login_email
|
||||||
|
FROM members m
|
||||||
|
LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.tenant_id = :tenant_id
|
||||||
|
ORDER BY m.display_name ASC'
|
||||||
|
);
|
||||||
|
$membersStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/members', [
|
||||||
|
'title' => 'Mitglieder',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'members' => $membersStatement->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function products(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$name = trim((string) ($_POST['name'] ?? ''));
|
||||||
|
$priceEur = (float) ($_POST['price_eur'] ?? 0);
|
||||||
|
|
||||||
|
if ($name === '' || $priceEur <= 0) {
|
||||||
|
throw new RuntimeException('Bitte Produktname und Preis angeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$productInsert = $pdo->prepare(
|
||||||
|
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||||
|
VALUES (:tenant_id, :name, :sku, 1)'
|
||||||
|
);
|
||||||
|
$productInsert->execute([
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'name' => $name,
|
||||||
|
'sku' => strtolower(str_replace(' ', '-', $name)),
|
||||||
|
]);
|
||||||
|
$productId = (int) $pdo->lastInsertId();
|
||||||
|
|
||||||
|
$priceInsert = $pdo->prepare(
|
||||||
|
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||||
|
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
||||||
|
);
|
||||||
|
$priceInsert->execute([
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'product_id' => $productId,
|
||||||
|
'price_cents' => (int) round($priceEur * 100),
|
||||||
|
'valid_from' => gmdate('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'product.created', 'product', $productId, 'success');
|
||||||
|
$this->session->flash('success', 'Das Produkt wurde angelegt.');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'products'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$productsStatement = $pdo->prepare(
|
||||||
|
'SELECT p.*, pp.price_cents
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN product_prices pp
|
||||||
|
ON pp.product_id = p.id
|
||||||
|
AND pp.tenant_id = p.tenant_id
|
||||||
|
AND pp.valid_until IS NULL
|
||||||
|
WHERE p.tenant_id = :tenant_id
|
||||||
|
ORDER BY p.name ASC'
|
||||||
|
);
|
||||||
|
$productsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/products', [
|
||||||
|
'title' => 'Produkte',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'products' => $productsStatement->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bookings(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||||
|
$service->createConsumption(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) ($_POST['member_id'] ?? 0),
|
||||||
|
(int) ($_POST['product_id'] ?? 0),
|
||||||
|
(string) ($_POST['source_code'] ?? 'digital_self'),
|
||||||
|
normalize_datetime_input((string) ($_POST['effective_at'] ?? '')),
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
(float) ($_POST['quantity'] ?? 1),
|
||||||
|
trim((string) ($_POST['notes'] ?? ''))
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Buchung wurde gespeichert.');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'bookings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = $this->membersForTenant((int) $tenant['id']);
|
||||||
|
$products = $this->productsForTenant((int) $tenant['id']);
|
||||||
|
|
||||||
|
$bookingsStatement = $pdo->prepare(
|
||||||
|
'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
|
||||||
|
FROM consumption_events c
|
||||||
|
INNER JOIN members m ON m.id = c.member_id
|
||||||
|
INNER JOIN products p ON p.id = c.product_id
|
||||||
|
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||||
|
WHERE c.tenant_id = :tenant_id
|
||||||
|
ORDER BY c.recorded_at DESC
|
||||||
|
LIMIT 30'
|
||||||
|
);
|
||||||
|
$bookingsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/bookings', [
|
||||||
|
'title' => 'Digitale Buchungen',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'members' => $members,
|
||||||
|
'products' => $products,
|
||||||
|
'bookings' => $bookingsStatement->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payments(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||||
|
$service->createPayment(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) ($_POST['member_id'] ?? 0),
|
||||||
|
(int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100),
|
||||||
|
normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')),
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
trim((string) ($_POST['notes'] ?? ''))
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Einzahlung wurde gebucht.');
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'payments'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = $this->membersForTenant((int) $tenant['id']);
|
||||||
|
$paymentsStatement = $pdo->prepare(
|
||||||
|
'SELECT le.*,
|
||||||
|
m.display_name AS member_name,
|
||||||
|
ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents
|
||||||
|
FROM ledger_entries le
|
||||||
|
LEFT JOIN members m ON m.id = le.member_id
|
||||||
|
LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id
|
||||||
|
WHERE le.tenant_id = :tenant_id
|
||||||
|
AND le.type = "payment"
|
||||||
|
GROUP BY le.id
|
||||||
|
ORDER BY le.occurred_at DESC
|
||||||
|
LIMIT 30'
|
||||||
|
);
|
||||||
|
$paymentsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/payments', [
|
||||||
|
'title' => 'Einzahlungen',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'members' => $members,
|
||||||
|
'payments' => $paymentsStatement->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paperSheets(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$action = (string) ($_POST['action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'create_sheet') {
|
||||||
|
$service->createPaperSheet(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
trim((string) ($_POST['title'] ?? '')),
|
||||||
|
trim((string) ($_POST['period_label'] ?? '')) ?: null,
|
||||||
|
trim((string) ($_POST['period_start'] ?? '')) ?: null,
|
||||||
|
trim((string) ($_POST['period_end'] ?? '')) ?: null,
|
||||||
|
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
||||||
|
(int) $authState['user_id']
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Papierliste wurde angelegt.');
|
||||||
|
} elseif ($action === 'add_line') {
|
||||||
|
$service->addPaperSheetLine(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) ($_POST['paper_sheet_id'] ?? 0),
|
||||||
|
(int) ($_POST['member_id'] ?? 0),
|
||||||
|
(int) ($_POST['product_id'] ?? 0),
|
||||||
|
(float) ($_POST['quantity'] ?? 1),
|
||||||
|
trim((string) ($_POST['effective_at'] ?? '')) !== '' ? normalize_datetime_input((string) $_POST['effective_at']) : null,
|
||||||
|
trim((string) ($_POST['note'] ?? '')) ?: null,
|
||||||
|
(int) $authState['user_id']
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Papierlisten-Position wurde gespeichert.');
|
||||||
|
} elseif ($action === 'post_sheet') {
|
||||||
|
$service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']);
|
||||||
|
$this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'paper-sheets'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = $this->membersForTenant((int) $tenant['id']);
|
||||||
|
$products = $this->productsForTenant((int) $tenant['id']);
|
||||||
|
$sheetsStatement = $pdo->prepare(
|
||||||
|
'SELECT ps.*, COUNT(psl.id) AS line_count
|
||||||
|
FROM paper_sheets ps
|
||||||
|
LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id
|
||||||
|
WHERE ps.tenant_id = :tenant_id
|
||||||
|
GROUP BY ps.id
|
||||||
|
ORDER BY ps.created_at DESC'
|
||||||
|
);
|
||||||
|
$sheetsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
$sheets = $sheetsStatement->fetchAll();
|
||||||
|
|
||||||
|
$linesStatement = $pdo->prepare(
|
||||||
|
'SELECT psl.*, m.display_name AS member_name, p.name AS product_name
|
||||||
|
FROM paper_sheet_lines psl
|
||||||
|
INNER JOIN members m ON m.id = psl.member_id
|
||||||
|
INNER JOIN products p ON p.id = psl.product_id
|
||||||
|
WHERE psl.tenant_id = :tenant_id
|
||||||
|
ORDER BY psl.created_at DESC'
|
||||||
|
);
|
||||||
|
$linesStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/paper-sheets', [
|
||||||
|
'title' => 'Papierlisten',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'members' => $members,
|
||||||
|
'products' => $products,
|
||||||
|
'sheets' => $sheets,
|
||||||
|
'sheetLines' => $linesStatement->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rfid(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$service = new RfidService($pdo, new AuditService($pdo));
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$action = (string) ($_POST['action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'create_device') {
|
||||||
|
$token = $service->createDevice(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
trim((string) ($_POST['name'] ?? '')),
|
||||||
|
trim((string) ($_POST['location_label'] ?? '')) ?: null,
|
||||||
|
(int) $authState['user_id']
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Das RFID-Geraet wurde vorbereitet. Token: ' . $token);
|
||||||
|
} elseif ($action === 'assign_tag') {
|
||||||
|
$service->assignTag(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) ($_POST['member_id'] ?? 0),
|
||||||
|
trim((string) ($_POST['uid'] ?? '')),
|
||||||
|
trim((string) ($_POST['label'] ?? '')) ?: null,
|
||||||
|
(int) $authState['user_id']
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die RFID-Karte wurde zugewiesen.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'rfid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = $this->membersForTenant((int) $tenant['id']);
|
||||||
|
$devices = $pdo->prepare('SELECT * FROM rfid_devices WHERE tenant_id = :tenant_id ORDER BY created_at DESC');
|
||||||
|
$devices->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
$tags = $pdo->prepare(
|
||||||
|
'SELECT t.*, m.display_name AS member_name
|
||||||
|
FROM rfid_tags t
|
||||||
|
LEFT JOIN members m ON m.id = t.member_id
|
||||||
|
WHERE t.tenant_id = :tenant_id
|
||||||
|
ORDER BY t.created_at DESC'
|
||||||
|
);
|
||||||
|
$tags->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
$events = $pdo->prepare(
|
||||||
|
'SELECT e.*, d.name AS device_name
|
||||||
|
FROM rfid_events e
|
||||||
|
LEFT JOIN rfid_devices d ON d.id = e.device_id
|
||||||
|
WHERE e.tenant_id = :tenant_id
|
||||||
|
ORDER BY e.received_at DESC
|
||||||
|
LIMIT 20'
|
||||||
|
);
|
||||||
|
$events->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
|
$this->view->render('tenant/rfid', [
|
||||||
|
'title' => 'RFID-Roadmap',
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'authState' => $authState,
|
||||||
|
'members' => $members,
|
||||||
|
'devices' => $devices->fetchAll(),
|
||||||
|
'tags' => $tags->fetchAll(),
|
||||||
|
'events' => $events->fetchAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rfidIngest(): void
|
||||||
|
{
|
||||||
|
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
||||||
|
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
http_response_code(422);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Invalid payload']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
(new RfidService($pdo, new AuditService($pdo)))->ingest($payload);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['status' => 'accepted']);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
http_response_code(422);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => $throwable->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenant(string $tenantSlug): array
|
||||||
|
{
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$tenant = (new TenantContext($pdo))->bySlug($tenantSlug);
|
||||||
|
|
||||||
|
if (!$tenant || $tenant['status'] === 'suspended') {
|
||||||
|
throw new RuntimeException('Der Mandant ist nicht verfuegbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantAccess(string $tenantSlug, array $roles = []): array
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant($tenantSlug);
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
|
||||||
|
if (!$auth->checkTenantAccess($tenantSlug)) {
|
||||||
|
$this->session->flash('error', 'Bitte zuerst anmelden.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($roles !== [] && !$auth->requireRole($roles)) {
|
||||||
|
$this->session->flash('error', 'Dafuer fehlen die passenden Rechte.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$tenant, $auth->user()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function membersForTenant(int $tenantId): array
|
||||||
|
{
|
||||||
|
$statement = $this->database->pdo()->prepare(
|
||||||
|
'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = "active" ORDER BY display_name ASC'
|
||||||
|
);
|
||||||
|
$statement->execute(['tenant_id' => $tenantId]);
|
||||||
|
|
||||||
|
return $statement->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productsForTenant(int $tenantId): array
|
||||||
|
{
|
||||||
|
$statement = $this->database->pdo()->prepare(
|
||||||
|
'SELECT p.id, p.name, pp.price_cents
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN product_prices pp
|
||||||
|
ON pp.product_id = p.id
|
||||||
|
AND pp.tenant_id = p.tenant_id
|
||||||
|
AND pp.valid_until IS NULL
|
||||||
|
WHERE p.tenant_id = :tenant_id
|
||||||
|
AND p.is_active = 1
|
||||||
|
ORDER BY p.name ASC'
|
||||||
|
);
|
||||||
|
$statement->execute(['tenant_id' => $tenantId]);
|
||||||
|
|
||||||
|
return $statement->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCsrf(): void
|
||||||
|
{
|
||||||
|
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||||
|
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class Auth
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly Session $session
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attemptPlatformLogin(string $email, string $password): bool
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['email' => mb_strtolower($email)]);
|
||||||
|
$user = $statement->fetch();
|
||||||
|
|
||||||
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($user['platform_role'] ?? 'user') !== 'platform_admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->regenerate();
|
||||||
|
$this->session->put('auth', [
|
||||||
|
'user_id' => (int) $user['id'],
|
||||||
|
'platform_role' => $user['platform_role'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT u.id,
|
||||||
|
u.password_hash,
|
||||||
|
m.tenant_id,
|
||||||
|
m.role,
|
||||||
|
t.slug AS tenant_slug
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
|
||||||
|
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
|
||||||
|
WHERE u.email = :email
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'slug' => $tenantSlug,
|
||||||
|
'email' => mb_strtolower($email),
|
||||||
|
]);
|
||||||
|
$membership = $statement->fetch();
|
||||||
|
|
||||||
|
if (!$membership || !password_verify($password, $membership['password_hash'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->regenerate();
|
||||||
|
$this->session->put('auth', [
|
||||||
|
'user_id' => (int) $membership['id'],
|
||||||
|
'tenant_id' => (int) $membership['tenant_id'],
|
||||||
|
'tenant_slug' => $membership['tenant_slug'],
|
||||||
|
'tenant_role' => $membership['role'],
|
||||||
|
'platform_role' => 'user',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): ?array
|
||||||
|
{
|
||||||
|
$auth = $this->session->get('auth');
|
||||||
|
|
||||||
|
return is_array($auth) ? $auth : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkPlatformAdmin(): bool
|
||||||
|
{
|
||||||
|
return ($this->user()['platform_role'] ?? null) === 'platform_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkTenantAccess(string $tenantSlug): bool
|
||||||
|
{
|
||||||
|
$auth = $this->user();
|
||||||
|
|
||||||
|
return is_array($auth)
|
||||||
|
&& isset($auth['tenant_slug'])
|
||||||
|
&& hash_equals($auth['tenant_slug'], $tenantSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireRole(array $allowedRoles): bool
|
||||||
|
{
|
||||||
|
$role = $this->user()['tenant_role'] ?? null;
|
||||||
|
|
||||||
|
return is_string($role) && in_array($role, $allowedRoles, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->session->invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Autoloader
|
||||||
|
{
|
||||||
|
public static function register(string $rootPath): void
|
||||||
|
{
|
||||||
|
spl_autoload_register(static function (string $class) use ($rootPath): void {
|
||||||
|
$prefix = 'App\\';
|
||||||
|
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativeClass = substr($class, strlen($prefix));
|
||||||
|
$file = $rootPath . '/app/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||||
|
|
||||||
|
if (is_file($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Csrf
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Session $session)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function token(): string
|
||||||
|
{
|
||||||
|
$token = $this->session->get('_csrf_token');
|
||||||
|
|
||||||
|
if (!is_string($token) || $token === '') {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$this->session->put('_csrf_token', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(?string $token): bool
|
||||||
|
{
|
||||||
|
$expected = $this->session->get('_csrf_token');
|
||||||
|
|
||||||
|
return is_string($expected) && is_string($token) && hash_equals($expected, $token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
final class Database
|
||||||
|
{
|
||||||
|
private ?PDO $pdo = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly array $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdo(): PDO
|
||||||
|
{
|
||||||
|
if ($this->pdo instanceof PDO) {
|
||||||
|
return $this->pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||||
|
$this->config['host'],
|
||||||
|
$this->config['port'],
|
||||||
|
$this->config['name'],
|
||||||
|
$this->config['charset']
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
throw new PDOException(
|
||||||
|
'Database connection failed: ' . $exception->getMessage(),
|
||||||
|
(int) $exception->getCode(),
|
||||||
|
$exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->config['name'] !== '' && $this->config['user'] !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Env
|
||||||
|
{
|
||||||
|
public static function load(string $file): void
|
||||||
|
{
|
||||||
|
if (!is_file($file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$trimmed = trim($line);
|
||||||
|
|
||||||
|
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$name, $value] = array_pad(explode('=', $trimmed, 2), 2, '');
|
||||||
|
$name = trim($name);
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value !== '' && ($value[0] === '"' || $value[0] === '\'')) {
|
||||||
|
$value = trim($value, "\"'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
$_SERVER[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Response
|
||||||
|
{
|
||||||
|
public static function redirect(string $url): never
|
||||||
|
{
|
||||||
|
header('Location: ' . $url);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(string $message = 'Seite nicht gefunden.'): void
|
||||||
|
{
|
||||||
|
http_response_code(404);
|
||||||
|
echo $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class Session
|
||||||
|
{
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_set_cookie_params([
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||||
|
'path' => '/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $_SESSION[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forget(string $key): void
|
||||||
|
{
|
||||||
|
unset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flash(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$_SESSION['_flash'][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pullFlash(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$value = $_SESSION['_flash'][$key] ?? $default;
|
||||||
|
unset($_SESSION['_flash'][$key]);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerate(): void
|
||||||
|
{
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidate(): void
|
||||||
|
{
|
||||||
|
$_SESSION = [];
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class TenantContext
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bySlug(string $slug): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT * FROM tenants WHERE slug = :slug LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['slug' => $slug]);
|
||||||
|
$tenant = $statement->fetch();
|
||||||
|
|
||||||
|
return $tenant ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
final class View
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $rootPath,
|
||||||
|
private readonly array $shared = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(string $template, array $data = []): void
|
||||||
|
{
|
||||||
|
$templateFile = $this->rootPath . '/resources/views/' . $template . '.php';
|
||||||
|
|
||||||
|
if (!is_file($templateFile)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shared = $this->shared;
|
||||||
|
|
||||||
|
extract($shared, EXTR_SKIP);
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
|
||||||
|
require $templateFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class AuditService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(
|
||||||
|
?int $tenantId,
|
||||||
|
?int $actorUserId,
|
||||||
|
string $action,
|
||||||
|
string $targetType,
|
||||||
|
?int $targetId,
|
||||||
|
string $result,
|
||||||
|
array $meta = []
|
||||||
|
): void {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO audit_events (
|
||||||
|
tenant_id,
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
result,
|
||||||
|
request_id,
|
||||||
|
ip_hash,
|
||||||
|
user_agent_hash,
|
||||||
|
meta_json
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:actor_user_id,
|
||||||
|
:action,
|
||||||
|
:target_type,
|
||||||
|
:target_id,
|
||||||
|
:result,
|
||||||
|
:request_id,
|
||||||
|
:ip_hash,
|
||||||
|
:user_agent_hash,
|
||||||
|
:meta_json
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18));
|
||||||
|
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'actor_user_id' => $actorUserId,
|
||||||
|
'action' => $action,
|
||||||
|
'target_type' => $targetType,
|
||||||
|
'target_id' => $targetId,
|
||||||
|
'result' => $result,
|
||||||
|
'request_id' => substr($requestId, 0, 36),
|
||||||
|
'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||||
|
'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||||
|
'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class LedgerService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly AuditService $audit
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createConsumption(
|
||||||
|
int $tenantId,
|
||||||
|
int $memberId,
|
||||||
|
int $productId,
|
||||||
|
string $sourceCode,
|
||||||
|
string $effectiveAt,
|
||||||
|
int $recordedByUserId,
|
||||||
|
float $quantity,
|
||||||
|
?string $notes = null,
|
||||||
|
?int $paperSheetLineId = null,
|
||||||
|
?string $sourceReference = null
|
||||||
|
): void {
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $this->findSource($tenantId, $sourceCode);
|
||||||
|
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
||||||
|
$totalCents = (int) round($priceCents * $quantity);
|
||||||
|
$description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId);
|
||||||
|
$recordedAt = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$eventInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO consumption_events (
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
product_id,
|
||||||
|
source_id,
|
||||||
|
paper_sheet_line_id,
|
||||||
|
quantity,
|
||||||
|
unit_price_cents,
|
||||||
|
total_cents,
|
||||||
|
effective_at,
|
||||||
|
recorded_at,
|
||||||
|
recorded_by_user_id,
|
||||||
|
source_reference,
|
||||||
|
notes
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:product_id,
|
||||||
|
:source_id,
|
||||||
|
:paper_sheet_line_id,
|
||||||
|
:quantity,
|
||||||
|
:unit_price_cents,
|
||||||
|
:total_cents,
|
||||||
|
:effective_at,
|
||||||
|
:recorded_at,
|
||||||
|
:recorded_by_user_id,
|
||||||
|
:source_reference,
|
||||||
|
:notes
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$eventInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'source_id' => $source['id'],
|
||||||
|
'paper_sheet_line_id' => $paperSheetLineId,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_price_cents' => $priceCents,
|
||||||
|
'total_cents' => $totalCents,
|
||||||
|
'effective_at' => $effectiveAt,
|
||||||
|
'recorded_at' => $recordedAt,
|
||||||
|
'recorded_by_user_id' => $recordedByUserId,
|
||||||
|
'source_reference' => $sourceReference,
|
||||||
|
'notes' => $notes,
|
||||||
|
]);
|
||||||
|
$eventId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$entryInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO ledger_entries (
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
source_id,
|
||||||
|
type,
|
||||||
|
reference_type,
|
||||||
|
reference_id,
|
||||||
|
description,
|
||||||
|
occurred_at,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:source_id,
|
||||||
|
"consumption",
|
||||||
|
"consumption_event",
|
||||||
|
:reference_id,
|
||||||
|
:description,
|
||||||
|
:occurred_at,
|
||||||
|
:created_by_user_id
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$entryInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'source_id' => $source['id'],
|
||||||
|
'reference_id' => $eventId,
|
||||||
|
'description' => $description,
|
||||||
|
'occurred_at' => $effectiveAt,
|
||||||
|
'created_by_user_id' => $recordedByUserId,
|
||||||
|
]);
|
||||||
|
$entryId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$lineInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO ledger_lines (
|
||||||
|
ledger_entry_id,
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
account_code,
|
||||||
|
amount_cents
|
||||||
|
) VALUES (
|
||||||
|
:ledger_entry_id,
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:account_code,
|
||||||
|
:amount_cents
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$lineInsert->execute([
|
||||||
|
'ledger_entry_id' => $entryId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'account_code' => 'member_balance',
|
||||||
|
'amount_cents' => $totalCents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lineInsert->execute([
|
||||||
|
'ledger_entry_id' => $entryId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => null,
|
||||||
|
'account_code' => 'coffee_revenue',
|
||||||
|
'amount_cents' => -$totalCents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'source' => $sourceCode,
|
||||||
|
'total_cents' => $totalCents,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPayment(
|
||||||
|
int $tenantId,
|
||||||
|
int $memberId,
|
||||||
|
int $amountCents,
|
||||||
|
string $occurredAt,
|
||||||
|
int $recordedByUserId,
|
||||||
|
?string $notes = null
|
||||||
|
): void {
|
||||||
|
if ($amountCents <= 0) {
|
||||||
|
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $this->findSource($tenantId, 'admin_backoffice');
|
||||||
|
|
||||||
|
$entryInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO ledger_entries (
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
source_id,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
occurred_at,
|
||||||
|
created_by_user_id,
|
||||||
|
reference_type
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:source_id,
|
||||||
|
"payment",
|
||||||
|
:description,
|
||||||
|
:occurred_at,
|
||||||
|
:created_by_user_id,
|
||||||
|
"payment"
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$entryInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'source_id' => $source['id'],
|
||||||
|
'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung',
|
||||||
|
'occurred_at' => $occurredAt,
|
||||||
|
'created_by_user_id' => $recordedByUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$entryId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$lineInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO ledger_lines (
|
||||||
|
ledger_entry_id,
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
account_code,
|
||||||
|
amount_cents
|
||||||
|
) VALUES (
|
||||||
|
:ledger_entry_id,
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:account_code,
|
||||||
|
:amount_cents
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$lineInsert->execute([
|
||||||
|
'ledger_entry_id' => $entryId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => null,
|
||||||
|
'account_code' => 'cashbox',
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lineInsert->execute([
|
||||||
|
'ledger_entry_id' => $entryId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'account_code' => 'member_balance',
|
||||||
|
'amount_cents' => -$amountCents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPaperSheet(
|
||||||
|
int $tenantId,
|
||||||
|
string $title,
|
||||||
|
?string $periodLabel,
|
||||||
|
?string $periodStart,
|
||||||
|
?string $periodEnd,
|
||||||
|
?string $notes,
|
||||||
|
int $createdByUserId
|
||||||
|
): int {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO paper_sheets (
|
||||||
|
tenant_id,
|
||||||
|
title,
|
||||||
|
period_label,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
notes,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:title,
|
||||||
|
:period_label,
|
||||||
|
:period_start,
|
||||||
|
:period_end,
|
||||||
|
:notes,
|
||||||
|
:created_by_user_id
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'title' => $title,
|
||||||
|
'period_label' => $periodLabel,
|
||||||
|
'period_start' => $periodStart ?: null,
|
||||||
|
'period_end' => $periodEnd ?: null,
|
||||||
|
'notes' => $notes,
|
||||||
|
'created_by_user_id' => $createdByUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sheetId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success');
|
||||||
|
|
||||||
|
return $sheetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addPaperSheetLine(
|
||||||
|
int $tenantId,
|
||||||
|
int $sheetId,
|
||||||
|
int $memberId,
|
||||||
|
int $productId,
|
||||||
|
float $quantity,
|
||||||
|
?string $effectiveAt,
|
||||||
|
?string $note,
|
||||||
|
int $actorUserId
|
||||||
|
): void {
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet = $this->pdo->prepare(
|
||||||
|
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||||
|
);
|
||||||
|
$sheet->execute([
|
||||||
|
'id' => $sheetId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
$paperSheet = $sheet->fetch();
|
||||||
|
|
||||||
|
if (!$paperSheet) {
|
||||||
|
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paperSheet['status'] !== 'draft') {
|
||||||
|
throw new RuntimeException('Nur Entwuerfe koennen erweitert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO paper_sheet_lines (
|
||||||
|
paper_sheet_id,
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
product_id,
|
||||||
|
quantity,
|
||||||
|
effective_at,
|
||||||
|
note
|
||||||
|
) VALUES (
|
||||||
|
:paper_sheet_id,
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:product_id,
|
||||||
|
:quantity,
|
||||||
|
:effective_at,
|
||||||
|
:note
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'paper_sheet_id' => $sheetId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'effective_at' => $effectiveAt ?: null,
|
||||||
|
'note' => $note,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lineId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [
|
||||||
|
'paper_sheet_id' => $sheetId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
||||||
|
{
|
||||||
|
$sheetStatement = $this->pdo->prepare(
|
||||||
|
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||||
|
);
|
||||||
|
$sheetStatement->execute([
|
||||||
|
'id' => $sheetId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
$sheet = $sheetStatement->fetch();
|
||||||
|
|
||||||
|
if (!$sheet) {
|
||||||
|
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sheet['status'] !== 'draft') {
|
||||||
|
throw new RuntimeException('Die Papierliste wurde bereits verbucht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$linesStatement = $this->pdo->prepare(
|
||||||
|
'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC'
|
||||||
|
);
|
||||||
|
$linesStatement->execute([
|
||||||
|
'paper_sheet_id' => $sheetId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
$lines = $linesStatement->fetchAll();
|
||||||
|
|
||||||
|
if ($lines === []) {
|
||||||
|
throw new RuntimeException('Die Papierliste enthaelt noch keine Positionen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$this->createConsumption(
|
||||||
|
$tenantId,
|
||||||
|
(int) $line['member_id'],
|
||||||
|
(int) $line['product_id'],
|
||||||
|
'paper_sheet',
|
||||||
|
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
|
||||||
|
$actorUserId,
|
||||||
|
(float) $line['quantity'],
|
||||||
|
$line['note'] ?: 'Papierliste',
|
||||||
|
(int) $line['id'],
|
||||||
|
'sheet:' . $sheetId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = $this->pdo->prepare(
|
||||||
|
'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id'
|
||||||
|
);
|
||||||
|
$update->execute([
|
||||||
|
'posted_at' => gmdate('Y-m-d H:i:s'),
|
||||||
|
'id' => $sheetId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT price_cents
|
||||||
|
FROM product_prices
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND product_id = :product_id
|
||||||
|
AND valid_from <= :effective_at
|
||||||
|
AND (valid_until IS NULL OR valid_until > :effective_at)
|
||||||
|
ORDER BY valid_from DESC
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'effective_at' => $effectiveAt,
|
||||||
|
]);
|
||||||
|
$price = $statement->fetchColumn();
|
||||||
|
|
||||||
|
if ($price === false) {
|
||||||
|
throw new RuntimeException('Fuer das Produkt ist kein aktiver Preis hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $price;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findSource(int $tenantId, string $code): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'code' => $code,
|
||||||
|
]);
|
||||||
|
$source = $statement->fetch();
|
||||||
|
|
||||||
|
if (!$source) {
|
||||||
|
throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class RfidService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly AuditService $audit
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
|
||||||
|
{
|
||||||
|
if (trim($name) === '') {
|
||||||
|
throw new RuntimeException('Bitte einen Geraetenamen angeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(20));
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
|
||||||
|
VALUES (:tenant_id, :name, :location_label, :device_token, "planned")'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'name' => trim($name),
|
||||||
|
'location_label' => $location,
|
||||||
|
'device_token' => $token,
|
||||||
|
]);
|
||||||
|
$deviceId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success');
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void
|
||||||
|
{
|
||||||
|
if (trim($uid) === '') {
|
||||||
|
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
|
||||||
|
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'uid_hash' => hash('sha256', trim($uid)),
|
||||||
|
'label' => $label,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ingest(array $payload): void
|
||||||
|
{
|
||||||
|
$deviceToken = trim((string) ($payload['device_token'] ?? ''));
|
||||||
|
$uid = trim((string) ($payload['uid'] ?? ''));
|
||||||
|
$externalEventId = trim((string) ($payload['event_id'] ?? ''));
|
||||||
|
|
||||||
|
if ($deviceToken === '' || $uid === '') {
|
||||||
|
throw new RuntimeException('device_token und uid sind Pflichtfelder.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceStatement = $this->pdo->prepare(
|
||||||
|
'SELECT d.id, d.tenant_id, d.status
|
||||||
|
FROM rfid_devices d
|
||||||
|
WHERE d.device_token = :device_token
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$deviceStatement->execute(['device_token' => $deviceToken]);
|
||||||
|
$device = $deviceStatement->fetch();
|
||||||
|
|
||||||
|
if (!$device) {
|
||||||
|
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = 'received';
|
||||||
|
$tagStatement = $this->pdo->prepare(
|
||||||
|
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
||||||
|
);
|
||||||
|
$tagStatement->execute([
|
||||||
|
'tenant_id' => $device['tenant_id'],
|
||||||
|
'uid_hash' => hash('sha256', $uid),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tagStatement->fetch()) {
|
||||||
|
$status = 'linked';
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO rfid_events (
|
||||||
|
tenant_id,
|
||||||
|
device_id,
|
||||||
|
external_event_id,
|
||||||
|
uid_hash,
|
||||||
|
payload_json,
|
||||||
|
status,
|
||||||
|
received_at
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:device_id,
|
||||||
|
:external_event_id,
|
||||||
|
:uid_hash,
|
||||||
|
:payload_json,
|
||||||
|
:status,
|
||||||
|
:received_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $device['tenant_id'],
|
||||||
|
'device_id' => $device['id'],
|
||||||
|
'external_event_id' => $externalEventId !== '' ? $externalEventId : null,
|
||||||
|
'uid_hash' => hash('sha256', $uid),
|
||||||
|
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
'status' => $status,
|
||||||
|
'received_at' => gmdate('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [
|
||||||
|
'status' => $status,
|
||||||
|
'device_id' => (int) $device['id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class SetupService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $rootPath)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInstalled(): bool
|
||||||
|
{
|
||||||
|
return is_file($this->rootPath . '/storage/installed.lock');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function install(array $input): void
|
||||||
|
{
|
||||||
|
$required = [
|
||||||
|
'app_url',
|
||||||
|
'db_host',
|
||||||
|
'db_port',
|
||||||
|
'db_name',
|
||||||
|
'db_user',
|
||||||
|
'admin_name',
|
||||||
|
'admin_email',
|
||||||
|
'admin_password',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($required as $field) {
|
||||||
|
if (trim((string) ($input[$field] ?? '')) === '') {
|
||||||
|
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new RuntimeException('Die Admin-E-Mail ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($input['admin_password']) < 12) {
|
||||||
|
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'host' => trim((string) $input['db_host']),
|
||||||
|
'port' => (int) $input['db_port'],
|
||||||
|
'name' => trim((string) $input['db_name']),
|
||||||
|
'user' => trim((string) $input['db_user']),
|
||||||
|
'pass' => (string) ($input['db_pass'] ?? ''),
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
];
|
||||||
|
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||||
|
$config['host'],
|
||||||
|
$config['port'],
|
||||||
|
$config['name'],
|
||||||
|
$config['charset']
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
|
||||||
|
|
||||||
|
if (!is_string($schema) || $schema === '') {
|
||||||
|
throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec($schema);
|
||||||
|
|
||||||
|
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
|
||||||
|
$existing = $statement->fetchColumn();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
$insert = $pdo->prepare(
|
||||||
|
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||||
|
VALUES (:full_name, :email, :password_hash, "platform_admin")'
|
||||||
|
);
|
||||||
|
$insert->execute([
|
||||||
|
'full_name' => trim((string) $input['admin_name']),
|
||||||
|
'email' => mb_strtolower((string) $input['admin_email']),
|
||||||
|
'password_hash' => secure_password_hash((string) $input['admin_password']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$envContent = $this->buildEnvFile($input);
|
||||||
|
|
||||||
|
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
|
||||||
|
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockContent = json_encode([
|
||||||
|
'installed_at' => gmdate(DATE_ATOM),
|
||||||
|
'app_url' => trim((string) $input['app_url']),
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildEnvFile(array $input): string
|
||||||
|
{
|
||||||
|
$appKey = bin2hex(random_bytes(32));
|
||||||
|
$rfidSecret = bin2hex(random_bytes(24));
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
'APP_NAME="Kaffeekasse SaaS"',
|
||||||
|
'APP_ENV=production',
|
||||||
|
'APP_DEBUG=0',
|
||||||
|
'APP_URL=' . trim((string) $input['app_url']),
|
||||||
|
'APP_TIMEZONE=Europe/Berlin',
|
||||||
|
'APP_KEY=' . $appKey,
|
||||||
|
'',
|
||||||
|
'DB_HOST=' . trim((string) $input['db_host']),
|
||||||
|
'DB_PORT=' . (int) $input['db_port'],
|
||||||
|
'DB_NAME=' . trim((string) $input['db_name']),
|
||||||
|
'DB_USER=' . trim((string) $input['db_user']),
|
||||||
|
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||||
|
'',
|
||||||
|
'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')),
|
||||||
|
'RFID_SHARED_SECRET=' . $rfidSecret,
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class TenantRegistrationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly AuditService $audit
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(array $input): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($input['tenant_name'] ?? ''));
|
||||||
|
$slug = $this->slugify((string) ($input['tenant_slug'] ?? $name));
|
||||||
|
$ownerName = trim((string) ($input['owner_name'] ?? ''));
|
||||||
|
$ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? '')));
|
||||||
|
$ownerPassword = (string) ($input['owner_password'] ?? '');
|
||||||
|
$plan = (string) ($input['plan'] ?? 'starter');
|
||||||
|
$defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100));
|
||||||
|
|
||||||
|
if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') {
|
||||||
|
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($ownerPassword) < 12) {
|
||||||
|
throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedPlans = ['starter', 'team', 'business'];
|
||||||
|
|
||||||
|
if (!in_array($plan, $allowedPlans, true)) {
|
||||||
|
$plan = 'starter';
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1');
|
||||||
|
$existingTenant->execute(['slug' => $slug]);
|
||||||
|
|
||||||
|
if ($existingTenant->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$existingUser->execute(['email' => $ownerEmail]);
|
||||||
|
|
||||||
|
if ($existingUser->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Fuer weitere Tenants bitte spaeter Einladungen nutzen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||||
|
VALUES (:full_name, :email, :password_hash, "user")'
|
||||||
|
);
|
||||||
|
$userInsert->execute([
|
||||||
|
'full_name' => $ownerName,
|
||||||
|
'email' => $ownerEmail,
|
||||||
|
'password_hash' => secure_password_hash($ownerPassword),
|
||||||
|
]);
|
||||||
|
$userId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$tenantInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO tenants (name, slug, plan, status, created_at)
|
||||||
|
VALUES (:name, :slug, :plan, "active", :created_at)'
|
||||||
|
);
|
||||||
|
$tenantInsert->execute([
|
||||||
|
'name' => $name,
|
||||||
|
'slug' => $slug,
|
||||||
|
'plan' => $plan,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
$tenantId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$membershipInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||||
|
VALUES (:tenant_id, :user_id, "owner", "active")'
|
||||||
|
);
|
||||||
|
$membershipInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settingsInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO tenant_settings (
|
||||||
|
tenant_id,
|
||||||
|
currency_code,
|
||||||
|
default_product_price_cents,
|
||||||
|
allow_self_service,
|
||||||
|
allow_paper_lists,
|
||||||
|
allow_rfid
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
"EUR",
|
||||||
|
:default_product_price_cents,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$settingsInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'default_product_price_cents' => $defaultPriceCents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$memberInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||||
|
VALUES (:tenant_id, :user_id, :display_name, :email, "active")'
|
||||||
|
);
|
||||||
|
$memberInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'display_name' => $ownerName,
|
||||||
|
'email' => $ownerEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sourceInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
|
||||||
|
VALUES (:tenant_id, :code, :name, :channel, 1)'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
['digital_self', 'Digitale Selbstbuchung', 'digital'],
|
||||||
|
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'],
|
||||||
|
['admin_backoffice', 'Backoffice-Buchung', 'admin'],
|
||||||
|
['rfid_reader', 'RFID-Geraet', 'rfid'],
|
||||||
|
] as [$code, $sourceName, $channel]) {
|
||||||
|
$sourceInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $sourceName,
|
||||||
|
'channel' => $channel,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
['Kaffee', $defaultPriceCents],
|
||||||
|
['Espresso', $defaultPriceCents],
|
||||||
|
['Tee', max(80, $defaultPriceCents - 20)],
|
||||||
|
] as [$productName, $priceCents]) {
|
||||||
|
$productInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||||
|
VALUES (:tenant_id, :name, :sku, 1)'
|
||||||
|
);
|
||||||
|
$productInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'name' => $productName,
|
||||||
|
'sku' => strtolower(str_replace(' ', '-', $productName)),
|
||||||
|
]);
|
||||||
|
$productId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$priceInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||||
|
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
||||||
|
);
|
||||||
|
$priceInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'price_cents' => $priceCents,
|
||||||
|
'valid_from' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [
|
||||||
|
'plan' => $plan,
|
||||||
|
'slug' => $slug,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(string $value): string
|
||||||
|
{
|
||||||
|
$value = mb_strtolower($value);
|
||||||
|
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
|
||||||
|
$value = trim($value, '-');
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Session;
|
||||||
|
|
||||||
|
function e(?string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function current_path(): string
|
||||||
|
{
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
|
||||||
|
return is_string($uri) ? $uri : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_url(string $path): string
|
||||||
|
{
|
||||||
|
return '/assets/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function old(string $key, mixed $default = ''): mixed
|
||||||
|
{
|
||||||
|
return $_SESSION['_old'][$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember_old_input(array $input): void
|
||||||
|
{
|
||||||
|
foreach (['password', 'admin_password', 'login_password', 'owner_password', 'db_pass'] as $sensitiveKey) {
|
||||||
|
if (array_key_exists($sensitiveKey, $input)) {
|
||||||
|
unset($input[$sensitiveKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['_old'] = $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear_old_input(): void
|
||||||
|
{
|
||||||
|
unset($_SESSION['_old']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrf_field(Csrf $csrf): string
|
||||||
|
{
|
||||||
|
return '<input type="hidden" name="_token" value="' . e($csrf->token()) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(Session $session, string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $session->pullFlash($key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenant_url(string $tenantSlug, string $path = ''): string
|
||||||
|
{
|
||||||
|
$suffix = $path === '' ? '' : '/' . ltrim($path, '/');
|
||||||
|
|
||||||
|
return '/t/' . rawurlencode($tenantSlug) . $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function money_from_cents(int $amountCents): string
|
||||||
|
{
|
||||||
|
return number_format($amountCents / 100, 2, ',', '.') . ' EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
function secure_password_hash(string $password): string
|
||||||
|
{
|
||||||
|
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
|
||||||
|
|
||||||
|
return password_hash($password, $algo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_datetime_input(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return gmdate('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = str_replace('T', ' ', $value);
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
|
||||||
|
return $normalized . ':00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request_origin_matches_host(): bool
|
||||||
|
{
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
|
||||||
|
if ($host === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) {
|
||||||
|
if (!empty($_SERVER[$header])) {
|
||||||
|
$originHost = parse_url((string) $_SERVER[$header], PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (is_string($originHost) && $originHost !== '' && !hash_equals($host, $originHost)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Autoloader;
|
||||||
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Env;
|
||||||
|
use App\Core\Session;
|
||||||
|
use App\Core\View;
|
||||||
|
|
||||||
|
$rootPath = dirname(__DIR__);
|
||||||
|
|
||||||
|
require_once $rootPath . '/app/Support/helpers.php';
|
||||||
|
require_once $rootPath . '/app/Core/Autoloader.php';
|
||||||
|
|
||||||
|
Autoloader::register($rootPath);
|
||||||
|
Env::load($rootPath . '/.env');
|
||||||
|
|
||||||
|
$config = require $rootPath . '/config/app.php';
|
||||||
|
|
||||||
|
date_default_timezone_set($config['timezone']);
|
||||||
|
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
ini_set('session.use_only_cookies', '1');
|
||||||
|
ini_set('session.use_trans_sid', '0');
|
||||||
|
session_name('kaffeekasse_session');
|
||||||
|
|
||||||
|
$session = new Session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
$csrf = new Csrf($session);
|
||||||
|
$database = new Database($config['db']);
|
||||||
|
$view = new View($rootPath, [
|
||||||
|
'config' => $config,
|
||||||
|
'session' => $session,
|
||||||
|
'csrf' => $csrf,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rootPath' => $rootPath,
|
||||||
|
'config' => $config,
|
||||||
|
'session' => $session,
|
||||||
|
'csrf' => $csrf,
|
||||||
|
'database' => $database,
|
||||||
|
'view' => $view,
|
||||||
|
];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$rootPath = dirname(__DIR__);
|
||||||
|
$lockFile = $rootPath . '/storage/cron.lock';
|
||||||
|
|
||||||
|
if (is_file($lockFile) && (time() - filemtime($lockFile)) < 600) {
|
||||||
|
fwrite(STDOUT, "Cron already running.\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($lockFile, (string) time(), LOCK_EX);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$app = require $rootPath . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
if (!$app['database']->isConfigured()) {
|
||||||
|
throw new RuntimeException('Database config missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $app['database']->pdo();
|
||||||
|
|
||||||
|
$pendingRfid = (int) $pdo->query(
|
||||||
|
'SELECT COUNT(*) FROM rfid_events WHERE status = "received"'
|
||||||
|
)->fetchColumn();
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'timestamp' => gmdate(DATE_ATOM),
|
||||||
|
'pending_rfid_events' => $pendingRfid,
|
||||||
|
'notes' => [
|
||||||
|
'In diesem MVP dient Cron als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
fwrite(STDERR, $throwable->getMessage() . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
} finally {
|
||||||
|
if (is_file($lockFile)) {
|
||||||
|
unlink($lockFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$app = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
$status = [
|
||||||
|
'app_env' => $app['config']['env'],
|
||||||
|
'database_configured' => $app['database']->isConfigured(),
|
||||||
|
'installed' => is_file(dirname(__DIR__) . '/storage/installed.lock'),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($status['database_configured']) {
|
||||||
|
$count = $app['database']->pdo()->query('SELECT COUNT(*) FROM tenants')->fetchColumn();
|
||||||
|
$status['database_reachable'] = true;
|
||||||
|
$status['tenant_count'] = (int) $count;
|
||||||
|
} else {
|
||||||
|
$status['database_reachable'] = false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$status['database_reachable'] = false;
|
||||||
|
$status['error'] = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$app = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
if (!$app['database']->isConfigured()) {
|
||||||
|
fwrite(STDERR, "Database config missing.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = file_get_contents(dirname(__DIR__) . '/database/schema.sql');
|
||||||
|
|
||||||
|
if (!is_string($schema) || $schema === '') {
|
||||||
|
fwrite(STDERR, "Schema missing.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$app['database']->pdo()->exec($schema);
|
||||||
|
fwrite(STDOUT, "Schema applied successfully.\n");
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $_ENV['APP_NAME'] ?? 'Kaffeekasse SaaS',
|
||||||
|
'env' => $_ENV['APP_ENV'] ?? 'production',
|
||||||
|
'debug' => (($_ENV['APP_DEBUG'] ?? '0') === '1'),
|
||||||
|
'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
|
||||||
|
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin',
|
||||||
|
'app_key' => $_ENV['APP_KEY'] ?? '',
|
||||||
|
'db' => [
|
||||||
|
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||||
|
'port' => (int) ($_ENV['DB_PORT'] ?? 3306),
|
||||||
|
'name' => $_ENV['DB_NAME'] ?? '',
|
||||||
|
'user' => $_ENV['DB_USER'] ?? '',
|
||||||
|
'pass' => $_ENV['DB_PASS'] ?? '',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
],
|
||||||
|
'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '',
|
||||||
|
];
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
full_name VARCHAR(160) NOT NULL,
|
||||||
|
email VARCHAR(190) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
platform_role ENUM('platform_admin', 'user') NOT NULL DEFAULT 'user',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(190) NOT NULL,
|
||||||
|
slug VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
plan ENUM('starter', 'team', 'business') NOT NULL DEFAULT 'starter',
|
||||||
|
status ENUM('trial', 'active', 'suspended') NOT NULL DEFAULT 'trial',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_domains (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
host VARCHAR(190) NOT NULL UNIQUE,
|
||||||
|
is_primary TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_tenant_domains_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_memberships (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
role ENUM('owner', 'admin', 'member', 'device') NOT NULL DEFAULT 'member',
|
||||||
|
status ENUM('active', 'invited', 'disabled') NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_tenant_user (tenant_id, user_id),
|
||||||
|
CONSTRAINT fk_memberships_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_memberships_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
default_product_price_cents INT NOT NULL DEFAULT 120,
|
||||||
|
allow_self_service TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
allow_paper_lists TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
allow_rfid TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_tenant_settings (tenant_id),
|
||||||
|
CONSTRAINT fk_tenant_settings_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NULL,
|
||||||
|
display_name VARCHAR(160) NOT NULL,
|
||||||
|
email VARCHAR(190) NULL,
|
||||||
|
access_pin VARCHAR(20) NULL,
|
||||||
|
status ENUM('active', 'archived') NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_member_email (tenant_id, email),
|
||||||
|
CONSTRAINT fk_members_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_members_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
sku VARCHAR(60) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_products_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_prices (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
price_cents INT NOT NULL,
|
||||||
|
valid_from DATETIME NOT NULL,
|
||||||
|
valid_until DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_product_prices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_product_prices_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS capture_sources (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
code VARCHAR(60) NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
channel ENUM('paper', 'digital', 'admin', 'rfid') NOT NULL,
|
||||||
|
is_system TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_capture_source_code (tenant_id, code),
|
||||||
|
CONSTRAINT fk_capture_sources_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paper_sheets (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
title VARCHAR(190) NOT NULL,
|
||||||
|
period_label VARCHAR(120) NULL,
|
||||||
|
period_start DATE NULL,
|
||||||
|
period_end DATE NULL,
|
||||||
|
status ENUM('draft', 'posted') NOT NULL DEFAULT 'draft',
|
||||||
|
notes TEXT NULL,
|
||||||
|
created_by_user_id BIGINT UNSIGNED NULL,
|
||||||
|
posted_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_paper_sheets_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_paper_sheets_user FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paper_sheet_lines (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
paper_sheet_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
member_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
quantity DECIMAL(8,2) NOT NULL DEFAULT 1.00,
|
||||||
|
effective_at DATETIME NULL,
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_paper_sheet_lines_sheet FOREIGN KEY (paper_sheet_id) REFERENCES paper_sheets (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_paper_sheet_lines_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_paper_sheet_lines_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_paper_sheet_lines_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS consumption_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
member_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
source_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
paper_sheet_line_id BIGINT UNSIGNED NULL,
|
||||||
|
quantity DECIMAL(8,2) NOT NULL DEFAULT 1.00,
|
||||||
|
unit_price_cents INT NOT NULL,
|
||||||
|
total_cents INT NOT NULL,
|
||||||
|
effective_at DATETIME NOT NULL,
|
||||||
|
recorded_at DATETIME NOT NULL,
|
||||||
|
recorded_by_user_id BIGINT UNSIGNED NULL,
|
||||||
|
source_reference VARCHAR(120) NULL,
|
||||||
|
notes TEXT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_consumption_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_consumption_events_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_consumption_events_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_consumption_events_source FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_consumption_events_sheet_line FOREIGN KEY (paper_sheet_line_id) REFERENCES paper_sheet_lines (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_consumption_events_recorded_by FOREIGN KEY (recorded_by_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
member_id BIGINT UNSIGNED NULL,
|
||||||
|
source_id BIGINT UNSIGNED NULL,
|
||||||
|
type ENUM('consumption', 'payment', 'correction', 'credit', 'opening_balance') NOT NULL,
|
||||||
|
reference_type VARCHAR(60) NULL,
|
||||||
|
reference_id BIGINT UNSIGNED NULL,
|
||||||
|
description VARCHAR(255) NOT NULL,
|
||||||
|
occurred_at DATETIME NOT NULL,
|
||||||
|
created_by_user_id BIGINT UNSIGNED NULL,
|
||||||
|
reversal_of_entry_id BIGINT UNSIGNED NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ledger_entries_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ledger_entries_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_ledger_entries_source FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_ledger_entries_user FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_ledger_entries_reversal FOREIGN KEY (reversal_of_entry_id) REFERENCES ledger_entries (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_lines (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ledger_entry_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
member_id BIGINT UNSIGNED NULL,
|
||||||
|
account_code ENUM('member_balance', 'cashbox', 'coffee_revenue') NOT NULL,
|
||||||
|
amount_cents INT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ledger_lines_entry FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ledger_lines_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ledger_lines_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rfid_devices (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
location_label VARCHAR(160) NULL,
|
||||||
|
device_token VARCHAR(80) NOT NULL UNIQUE,
|
||||||
|
status ENUM('planned', 'active', 'disabled') NOT NULL DEFAULT 'planned',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rfid_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rfid_tags (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
member_id BIGINT UNSIGNED NULL,
|
||||||
|
uid_hash CHAR(64) NOT NULL,
|
||||||
|
label VARCHAR(120) NULL,
|
||||||
|
status ENUM('active', 'lost', 'retired') NOT NULL DEFAULT 'active',
|
||||||
|
last_seen_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_rfid_tag (tenant_id, uid_hash),
|
||||||
|
CONSTRAINT fk_rfid_tags_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rfid_tags_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rfid_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
device_id BIGINT UNSIGNED NULL,
|
||||||
|
external_event_id VARCHAR(120) NULL,
|
||||||
|
uid_hash CHAR(64) NOT NULL,
|
||||||
|
payload_json JSON NULL,
|
||||||
|
status ENUM('received', 'linked', 'ignored', 'error') NOT NULL DEFAULT 'received',
|
||||||
|
received_at DATETIME NOT NULL,
|
||||||
|
processed_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_rfid_external_event (tenant_id, external_event_id),
|
||||||
|
CONSTRAINT fk_rfid_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rfid_events_device FOREIGN KEY (device_id) REFERENCES rfid_devices (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NULL,
|
||||||
|
actor_user_id BIGINT UNSIGNED NULL,
|
||||||
|
action VARCHAR(120) NOT NULL,
|
||||||
|
target_type VARCHAR(80) NOT NULL,
|
||||||
|
target_id BIGINT UNSIGNED NULL,
|
||||||
|
result ENUM('success', 'failure') NOT NULL,
|
||||||
|
request_id CHAR(36) NOT NULL,
|
||||||
|
ip_hash CHAR(64) NULL,
|
||||||
|
user_agent_hash CHAR(64) NULL,
|
||||||
|
meta_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_audit_tenant_created (tenant_id, created_at),
|
||||||
|
CONSTRAINT fk_audit_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_audit_events_user FOREIGN KEY (actor_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Marketing-Operations
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Der Produktauftritt soll nicht nur schoen aussehen, sondern einen messbaren
|
||||||
|
Pfad von `Interesse` zu `aktivem Tenant` schaffen.
|
||||||
|
|
||||||
|
## Funnel-Stufen
|
||||||
|
|
||||||
|
1. `Landing Visit`
|
||||||
|
2. `CTA Klick auf Registrierung`
|
||||||
|
3. `Tenant registriert`
|
||||||
|
4. `Erster Login`
|
||||||
|
5. `Erste Buchung`
|
||||||
|
6. `Erste Papierliste oder erstes RFID-Setup`
|
||||||
|
|
||||||
|
## Tracking-Ereignisse
|
||||||
|
|
||||||
|
- `landing_view`
|
||||||
|
- `pricing_view`
|
||||||
|
- `register_started`
|
||||||
|
- `tenant_registered`
|
||||||
|
- `tenant_login_success`
|
||||||
|
- `first_consumption_recorded`
|
||||||
|
- `paper_sheet_created`
|
||||||
|
- `rfid_device_created`
|
||||||
|
|
||||||
|
## Tooling-Empfehlung
|
||||||
|
|
||||||
|
- datenschutzfreundliches Web-Analytics statt schwerem Adtech-Setup
|
||||||
|
- Formular-Events serverseitig oder ueber schlankes Frontend-Tracking mitschreiben
|
||||||
|
- CRM-Anbindung zunaechst ueber CSV-Export oder Webhook-Fassade
|
||||||
|
- spaeter: automatisch segmentierte Onboarding-Mails pro Paket
|
||||||
|
|
||||||
|
## Operative Fragen fuer den Go-Live
|
||||||
|
|
||||||
|
- Welche CTA-Version konvertiert fuer Vereine besser: `Digitale Strichliste` oder
|
||||||
|
`Kaffeekasse digitalisieren`?
|
||||||
|
- Wie viele registrierte Tenants legen binnen 24 Stunden Mitglieder und Produkte
|
||||||
|
an?
|
||||||
|
- Wo brechen Interessenten ab: auf der Landingpage, im Signup oder erst beim
|
||||||
|
Login?
|
||||||
|
|
||||||
|
## Direkt im Produkt nutzbar
|
||||||
|
|
||||||
|
Die Landingpage im Repo ist bereits auf diese Funnel-Logik abgestimmt:
|
||||||
|
|
||||||
|
- klares Problem-Narrativ
|
||||||
|
- Paketdarstellung
|
||||||
|
- direkter CTA zur Registrierung
|
||||||
|
- technischer Vertrauensaufbau ueber Netcup-, Papier- und RFID-Kompatibilitaet
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Produkt- und Vermarktungsstrategie
|
||||||
|
|
||||||
|
## Zielgruppen
|
||||||
|
|
||||||
|
- kleine und mittlere Teams mit gemeinsamer Kaffeekasse
|
||||||
|
- Vereine mit klassischer Strichliste
|
||||||
|
- Coworking- und Mehrraum-Standorte mit spaeterer RFID-Option
|
||||||
|
|
||||||
|
## Kernbotschaft
|
||||||
|
|
||||||
|
`Eine Kaffeekasse fuer analog und digital.`
|
||||||
|
Die Anwendung ersetzt Papier nicht abrupt, sondern verbindet Papierliste,
|
||||||
|
digitale Selbstbedienung und spaetere Geraeteanbindung in einem nachvollziehbaren
|
||||||
|
Buchungssystem.
|
||||||
|
|
||||||
|
## Positionierung
|
||||||
|
|
||||||
|
- weniger Tool-Fragmentierung als Eigenbau aus Excel, Zetteln und Einzelapps
|
||||||
|
- verstaendlichere Einfuehrung als reine Hardware- oder RFID-Produkte
|
||||||
|
- realistischer Betrieb auf klassischem Webhosting statt VPS-Pflicht
|
||||||
|
|
||||||
|
## Paketlogik
|
||||||
|
|
||||||
|
- `Starter`: digitale Buchungen, Mitglieder, Produkte, Einzahlungen
|
||||||
|
- `Team`: zusaetzlich Papierlisten-Workflow und staerkeres Backoffice
|
||||||
|
- `Business`: RFID-Roadmap, Betriebshooks, White-Label-/Integrationsvorstufe
|
||||||
|
|
||||||
|
## Marketing-Funnel
|
||||||
|
|
||||||
|
1. Landingpage erklaert den analogen und digitalen Use Case in einem Satz.
|
||||||
|
2. CTA fuehrt direkt in Mandanten-Registrierung oder Demo-Setup.
|
||||||
|
3. Der Self-Service legt sofort einen nutzbaren Tenant mit Default-Produkten an.
|
||||||
|
4. Plattform-Admin kann spaeter Vertrieb, Support und Paketwechsel steuern.
|
||||||
|
|
||||||
|
## Messaging-Elemente
|
||||||
|
|
||||||
|
- `Digitale Strichliste fuer Teams und Vereine`
|
||||||
|
- `Papierliste bleibt moeglich`
|
||||||
|
- `RFID spaeter anschliessen, ohne neu zu bauen`
|
||||||
|
- `Nachvollziehbare Salden und Buchungsverlaeufe`
|
||||||
|
|
||||||
|
## Vertriebsnahe KPIs
|
||||||
|
|
||||||
|
- Registrierungen pro Paket
|
||||||
|
- Anteil aktivierter Tenants
|
||||||
|
- erste Buchung innerhalb von 24 Stunden
|
||||||
|
- Anteil Tenants mit aktiver Papierlisten-Nutzung
|
||||||
|
- Nachfrage nach RFID-/Standortintegration
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Produktions-Blueprint
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- modularer Monolith in PHP
|
||||||
|
- gemeinsame MySQL-Datenbank mit `tenant_id`
|
||||||
|
- Frontcontroller unter `public/index.php`
|
||||||
|
- Services fuer Setup, Registrierung, Ledger und RFID
|
||||||
|
|
||||||
|
## Sicherheitsgrundlagen
|
||||||
|
|
||||||
|
- serverseitige PHP-Sessions
|
||||||
|
- CSRF-Schutz fuer alle schreibenden Formulare
|
||||||
|
- `password_hash()` mit moderner Hash-Funktion
|
||||||
|
- Audit-Log fuer Login, Registrierung, Buchungen und RFID-Ereignisse
|
||||||
|
- path-basierte Tenant-Isolation ueber Memberships
|
||||||
|
|
||||||
|
## Betriebsmodell
|
||||||
|
|
||||||
|
- Installation per Browser oder `bin/migrate.php`
|
||||||
|
- Cron-basierte technische Haken ueber `bin/cron.php`
|
||||||
|
- Healthcheck als JSON ueber `bin/healthcheck.php`
|
||||||
|
- Release-Pakete ueber `scripts/build-release.sh`
|
||||||
|
|
||||||
|
## Was vor echtem Go-Live noch folgen sollte
|
||||||
|
|
||||||
|
- Mail-Verifikation und Passwort-Reset-Flow
|
||||||
|
- MFA fuer Admin- und Owner-Rollen
|
||||||
|
- Login- und Registrierungs-Rate-Limits
|
||||||
|
- strukturiertes Logging in Datei oder externem Ziel
|
||||||
|
- abgesicherter Support-Zugriff fuer Plattform-Admins
|
||||||
|
|
||||||
|
## Git- und CI-Gedanke
|
||||||
|
|
||||||
|
Das Repo ist absichtlich neutral gehalten, damit spaeter auf `git.ctb-it.de`
|
||||||
|
entweder GitLab- oder Gitea-CI angedockt werden kann. Die eigentliche
|
||||||
|
Deploy-Logik steckt in Skripten und Doku, nicht in einem proprietaeren CI-Format.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# RFID-Roadmap
|
||||||
|
|
||||||
|
## Heute schon vorhanden
|
||||||
|
|
||||||
|
- `rfid_devices` fuer Standortgeraete
|
||||||
|
- `rfid_tags` fuer Karten/Chips pro Mitglied
|
||||||
|
- `rfid_events` als technische Inbox
|
||||||
|
- `/api/rfid/intake` fuer rohe Events
|
||||||
|
|
||||||
|
## Warum das sinnvoll ist
|
||||||
|
|
||||||
|
Die spaetere Hardware kann kommen, ohne dass sich das Kernmodell fuer Salden,
|
||||||
|
Konsum und Einzahlungen aendert. RFID ist nur ein weiterer Erfassungsweg in
|
||||||
|
dieselbe Buchungslogik hinein.
|
||||||
|
|
||||||
|
## Nächste Ausbaustufen
|
||||||
|
|
||||||
|
1. Geraet aktivieren und Token-Rotation einfuehren
|
||||||
|
2. Dublettenschutz ueber `event_id` und Zeitfenster haerten
|
||||||
|
3. Mapping-Regeln `Tag -> Mitglied -> Standardprodukt`
|
||||||
|
4. optionale Freigabe- oder Double-Tap-Regeln
|
||||||
|
5. Uebernahme von `rfid_events` in echte `consumption_events`
|
||||||
|
|
||||||
|
## Produktionshinweis
|
||||||
|
|
||||||
|
Wenn spaeter mehr als HTTPS-Webhooks noetig werden, sollte ein kleiner externer
|
||||||
|
Relay-Service zwischen Lesegerät und Webspace geschaltet werden. Das eigentliche
|
||||||
|
SaaS-Ledger kann trotzdem auf Netcup-Webspace bleiben.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6efe6;
|
||||||
|
--bg-accent: #efe0cd;
|
||||||
|
--surface: rgba(255, 252, 248, 0.92);
|
||||||
|
--surface-strong: #fffaf2;
|
||||||
|
--text: #1f140f;
|
||||||
|
--muted: #6b544a;
|
||||||
|
--border: rgba(86, 48, 26, 0.14);
|
||||||
|
--primary: #8f4518;
|
||||||
|
--primary-strong: #6f2f0e;
|
||||||
|
--secondary: #124f47;
|
||||||
|
--success: #e7f7ef;
|
||||||
|
--danger: #ffe3df;
|
||||||
|
--shadow: 0 20px 60px rgba(86, 48, 26, 0.12);
|
||||||
|
--radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(143, 69, 24, 0.18), transparent 26rem),
|
||||||
|
radial-gradient(circle at top right, rgba(18, 79, 71, 0.14), transparent 24rem),
|
||||||
|
linear-gradient(160deg, var(--bg) 0%, #fcf7f0 48%, var(--bg-accent) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 3rem 3rem;
|
||||||
|
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.4), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1180px, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
background: rgba(246, 239, 230, 0.72);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner,
|
||||||
|
.footer-grid,
|
||||||
|
.button-row,
|
||||||
|
.metric-row,
|
||||||
|
.top-nav,
|
||||||
|
.inline-head,
|
||||||
|
.two-column,
|
||||||
|
.grid-three,
|
||||||
|
.content-grid,
|
||||||
|
.plan-grid,
|
||||||
|
.metric-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong,
|
||||||
|
.hero h1,
|
||||||
|
.page-head h1,
|
||||||
|
.section-head h2 {
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand small,
|
||||||
|
.muted,
|
||||||
|
.stack-list small,
|
||||||
|
.feature-list,
|
||||||
|
.metric-row span,
|
||||||
|
.top-nav a,
|
||||||
|
.site-footer p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: linear-gradient(135deg, var(--primary), #c07a3d);
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
justify-content: end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav a.active,
|
||||||
|
.top-nav a:hover {
|
||||||
|
background: rgba(18, 79, 71, 0.08);
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-shell {
|
||||||
|
padding: 2rem 0 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.two-column,
|
||||||
|
.content-grid,
|
||||||
|
.plan-grid,
|
||||||
|
.metric-cards {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.hero-card,
|
||||||
|
.card,
|
||||||
|
.form-card,
|
||||||
|
.table-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
padding: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
padding: 2rem;
|
||||||
|
align-self: stretch;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.84), rgba(255,250,242,0.94)),
|
||||||
|
linear-gradient(135deg, rgba(18,79,71,0.12), transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1,
|
||||||
|
.page-head h1 {
|
||||||
|
font-size: clamp(2.2rem, 4vw, 4.6rem);
|
||||||
|
line-height: 0.96;
|
||||||
|
margin: 0.6rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row,
|
||||||
|
.metric-row {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.95rem 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
color: var(--secondary);
|
||||||
|
border-color: rgba(18, 79, 71, 0.24);
|
||||||
|
background: rgba(18, 79, 71, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1.4rem 0 0;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list,
|
||||||
|
.stack-list,
|
||||||
|
.step-list {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel,
|
||||||
|
.metric-cards,
|
||||||
|
.content-grid,
|
||||||
|
.plan-grid,
|
||||||
|
.grid-three {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel div,
|
||||||
|
.metric-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255,255,255,0.74);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong,
|
||||||
|
.stats-panel strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid,
|
||||||
|
.plan-grid,
|
||||||
|
.grid-three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.form-card,
|
||||||
|
.table-card,
|
||||||
|
.timeline {
|
||||||
|
padding: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted {
|
||||||
|
border-color: rgba(143, 69, 24, 0.38);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 251, 244, 0.98), rgba(250, 236, 219, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-head {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card,
|
||||||
|
.table-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slim {
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(86, 48, 26, 0.18);
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.95rem 0.8rem;
|
||||||
|
border-bottom: 1px solid rgba(86, 48, 26, 0.08);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
border-bottom: 1px solid rgba(86, 48, 26, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-success {
|
||||||
|
background: var(--success);
|
||||||
|
border: 1px solid rgba(18, 79, 71, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-error {
|
||||||
|
background: var(--danger);
|
||||||
|
border: 1px solid rgba(143, 69, 24, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #201814;
|
||||||
|
color: #fef0df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(255,255,255,0.64);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.hero,
|
||||||
|
.two-column,
|
||||||
|
.content-grid,
|
||||||
|
.plan-grid,
|
||||||
|
.metric-cards,
|
||||||
|
.grid-three,
|
||||||
|
.footer-grid,
|
||||||
|
.form-grid,
|
||||||
|
.inline-head {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav,
|
||||||
|
.button-row,
|
||||||
|
.metric-row {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Controllers\PlatformController;
|
||||||
|
use App\Controllers\TenantController;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
$app = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
$platform = new PlatformController(
|
||||||
|
$app['rootPath'],
|
||||||
|
$app['config'],
|
||||||
|
$app['database'],
|
||||||
|
$app['view'],
|
||||||
|
$app['session'],
|
||||||
|
$app['csrf']
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenantController = new TenantController(
|
||||||
|
$app['database'],
|
||||||
|
$app['view'],
|
||||||
|
$app['session'],
|
||||||
|
$app['csrf']
|
||||||
|
);
|
||||||
|
|
||||||
|
$path = current_path();
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($path === '/' && $method === 'GET') {
|
||||||
|
$platform->home();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/install') {
|
||||||
|
$method === 'POST' ? $platform->installSubmit() : $platform->installForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/register') {
|
||||||
|
$method === 'POST' ? $platform->registerSubmit() : $platform->registerForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/admin/login') {
|
||||||
|
$method === 'POST' ? $platform->adminLoginSubmit() : $platform->adminLoginForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/admin' && $method === 'GET') {
|
||||||
|
$platform->adminDashboard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/admin/logout' && $method === 'POST') {
|
||||||
|
$platform->adminLogout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/api/rfid/intake' && $method === 'POST') {
|
||||||
|
$tenantController->rfidIngest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/t/([^/]+)(?:/(.*))?$#', $path, $matches)) {
|
||||||
|
$tenantSlug = rawurldecode($matches[1]);
|
||||||
|
$subPath = trim((string) ($matches[2] ?? ''), '/');
|
||||||
|
|
||||||
|
if ($subPath === '' && $method === 'GET') {
|
||||||
|
$tenantController->dashboard($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'login') {
|
||||||
|
$method === 'POST' ? $tenantController->loginSubmit($tenantSlug) : $tenantController->loginForm($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'logout' && $method === 'POST') {
|
||||||
|
$tenantController->logout($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'members') {
|
||||||
|
$tenantController->members($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'products') {
|
||||||
|
$tenantController->products($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'bookings') {
|
||||||
|
$tenantController->bookings($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'payments') {
|
||||||
|
$tenantController->payments($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'paper-sheets') {
|
||||||
|
$tenantController->paperSheets($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'rfid') {
|
||||||
|
$tenantController->rfid($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::notFound();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
http_response_code(500);
|
||||||
|
error_log($throwable->__toString());
|
||||||
|
|
||||||
|
echo '<h1>Fehler</h1>';
|
||||||
|
echo '<p>' . e($app['config']['debug'] ? $throwable->getMessage() : 'Die Anfrage konnte gerade nicht verarbeitet werden.') . '</p>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
$file = __DIR__ . ($path === '/' ? '/index.php' : (string) $path);
|
||||||
|
|
||||||
|
if ($path !== '/' && is_file($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/index.php';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head inline-head">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Betrieb</span>
|
||||||
|
<h1>Plattform-Dashboard</h1>
|
||||||
|
<p>Ueberblick ueber Mandanten, Paketstand und direkte Einstiege in die Kundenumgebungen.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<button class="button button-secondary" type="submit">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mandant</th>
|
||||||
|
<th>Paket</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Mitglieder</th>
|
||||||
|
<th>Produkte</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tenants as $tenant): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong><?= e($tenant['name']) ?></strong><br>
|
||||||
|
<small>/t/<?= e($tenant['slug']) ?></small>
|
||||||
|
</td>
|
||||||
|
<td><?= e(ucfirst($tenant['plan'])) ?></td>
|
||||||
|
<td><?= e($tenant['status']) ?></td>
|
||||||
|
<td><?= e((string) $tenant['member_count']) ?></td>
|
||||||
|
<td><?= e((string) $tenant['product_count']) ?></td>
|
||||||
|
<td><a class="text-link" href="<?= e(tenant_url($tenant['slug'])) ?>">Tenant oeffnen</a></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Plattform</span>
|
||||||
|
<h1>Admin-Login</h1>
|
||||||
|
<p>Fuer Betrieb, Mandantenuebersicht, Support und spaetere Vermarktungssteuerung.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="card form-card slim" method="post" action="/admin/login">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<span class="eyebrow">Von der Papierliste zur produktiven SaaS</span>
|
||||||
|
<h1>Kaffeeliste, digitale Strichliste und RFID-Roadmap in einem System.</h1>
|
||||||
|
<p class="lead">Die Anwendung verbindet klassische Kaffeekasse, digitale Selbstbuchung und spaetere Geraeteintegration. Mandanten registrieren sich selbst, verwalten Mitglieder, Produkte, Einzahlungen und Papierlisten in einem PHP/MySQL-Stack, der auf Netcup-Webspace laeuft.</p>
|
||||||
|
<div class="button-row">
|
||||||
|
<?php if ($setupComplete): ?>
|
||||||
|
<a class="button button-primary" href="/register">Mandant starten</a>
|
||||||
|
<a class="button button-secondary" href="/admin/login">Plattform betreten</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="button button-primary" href="/install">Installation starten</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<ul class="metric-row">
|
||||||
|
<li><strong>1</strong><span>gemeinsames Ledger fuer digital, Papier und RFID</span></li>
|
||||||
|
<li><strong>3</strong><span>Pakete fuer Vermarktung und Rollout</span></li>
|
||||||
|
<li><strong>100%</strong><span>Webspace-kompatibler PHP/MySQL-Betrieb</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<aside class="hero-card">
|
||||||
|
<h2>Produktive Ausrichtung</h2>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Mandanten-Self-Service mit Owner-Onboarding</li>
|
||||||
|
<li>Digitale Buchungen fuer Tablet, Browser und Kiosk</li>
|
||||||
|
<li>Papierlisten als Entwurf, Nacherfassung und Ledger-Posting</li>
|
||||||
|
<li>Einzahlungen, Salden und Audit-Trail pro Mandant</li>
|
||||||
|
<li>RFID-Devices, Karten und Event-Inbox fuer die spaetere Automatisierung</li>
|
||||||
|
</ul>
|
||||||
|
<?php if (is_array($stats)): ?>
|
||||||
|
<div class="stats-panel">
|
||||||
|
<div><strong><?= e((string) $stats['tenants']) ?></strong><span>Mandanten in der Datenbank</span></div>
|
||||||
|
<div><strong><?= e((string) $stats['members']) ?></strong><span>Mitglieder</span></div>
|
||||||
|
<div><strong><?= e((string) $stats['events']) ?></strong><span>Buchungsvorfaelle</span></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<article class="card">
|
||||||
|
<span class="eyebrow">Produkt</span>
|
||||||
|
<h2>Eine Buchungslogik, mehrere Erfassungswege</h2>
|
||||||
|
<p>Jede Kaffee- oder Tee-Buchung landet im selben Ledger. Ob jemand digital klickt, ob ein Admin eine Papierliste erfasst oder ob spaeter ein RFID-Leser Events einspeist: Salden, Historie und Auswertungen bleiben konsistent.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="eyebrow">Marketing</span>
|
||||||
|
<h2>Klare Positionierung fuer Teams, Vereine und Coworking</h2>
|
||||||
|
<p>Die Startseite spricht gezielt Organisationen an, die zwischen analoger Kaffeekasse und digitaler Selbstbedienung stehen. Das senkt Erklaerungsaufwand und macht den Upgrade-Pfad zu RFID nachvollziehbar.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<span class="eyebrow">Betrieb</span>
|
||||||
|
<h2>Netcup-tauglich statt hyperskalierter Overkill</h2>
|
||||||
|
<p>Pfadbasierte Mandanten, gemeinsamer MySQL-Kern, Cron statt Worker und ein sauberer Webroot auf `public/` sind genau auf klassisches Webhosting zugeschnitten.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="plans">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">Pakete</span>
|
||||||
|
<h2>Preismodelle fuer die Vermarktung</h2>
|
||||||
|
</div>
|
||||||
|
<div class="plan-grid">
|
||||||
|
<article class="plan-card">
|
||||||
|
<h3>Starter</h3>
|
||||||
|
<p>Fuer kleine Teams und Vereine mit digitaler Buchung und einfacher Kaffeekasse.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Mitglieder, Produkte, digitale Buchungen</li>
|
||||||
|
<li>Einzahlungen und Salden</li>
|
||||||
|
<li>Self-Service-Registrierung</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="plan-card highlighted">
|
||||||
|
<h3>Team</h3>
|
||||||
|
<p>Fuer Organisationen, die Papierlisten und digitale Prozesse parallel fahren.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Papierlisten-Workflow mit Nacherfassung</li>
|
||||||
|
<li>Plattform-Dashboard und Audit-Trail</li>
|
||||||
|
<li>Vorbereitung fuer Standortgeraete</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="plan-card">
|
||||||
|
<h3>Business</h3>
|
||||||
|
<p>Fuer Standorte mit RFID-Roadmap, Betriebsanforderungen und spaeterem White-Labeling.</p>
|
||||||
|
<ul>
|
||||||
|
<li>RFID-Devices, Karten und Intake-API</li>
|
||||||
|
<li>Release-, Backup- und Deployment-Rahmen</li>
|
||||||
|
<li>Vorlage fuer spaetere Integrationen</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="timeline card">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="eyebrow">Roadmap</span>
|
||||||
|
<h2>Sauberer Pfad in die Produktion</h2>
|
||||||
|
</div>
|
||||||
|
<ol class="step-list">
|
||||||
|
<li>Plattform installieren und ersten Admin anlegen.</li>
|
||||||
|
<li>Mandant registrieren, Owner-Onboarding abschliessen und Produkte vorbelegen.</li>
|
||||||
|
<li>Digitale Buchungen und Papierlisten parallel im Team einsetzen.</li>
|
||||||
|
<li>Mit RFID-Geraeten spaeter ueber die Event-Inbox anbinden, ohne das Kernledger umzubauen.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Self-Service</span>
|
||||||
|
<h1>Mandant registrieren</h1>
|
||||||
|
<p>Der Owner legt den ersten Standort an und bekommt automatisch die Grundbausteine fuer digitale Buchung, Papierliste und spaetere RFID-Anbindung.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<form class="card form-card" method="post" action="/register">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>Organisationsname</span>
|
||||||
|
<input type="text" name="tenant_name" value="<?= e((string) old('tenant_name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Kurzname / URL-Slug</span>
|
||||||
|
<input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="mein-team">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Paket</span>
|
||||||
|
<select name="plan">
|
||||||
|
<option value="starter">Starter</option>
|
||||||
|
<option value="team" selected>Team</option>
|
||||||
|
<option value="business">Business</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Standardpreis pro Kaffee in EUR</span>
|
||||||
|
<input type="number" name="default_price_eur" min="0.50" step="0.05" value="<?= e((string) old('default_price_eur', '1.20')) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Owner-Name</span>
|
||||||
|
<input type="text" name="owner_name" value="<?= e((string) old('owner_name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Owner-E-Mail</span>
|
||||||
|
<input type="email" name="owner_email" value="<?= e((string) old('owner_email')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Owner-Passwort</span>
|
||||||
|
<input type="password" name="owner_password" minlength="12" required>
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Mandant anlegen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="card">
|
||||||
|
<span class="eyebrow">Was automatisch entsteht</span>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Tenant mit Owner-Rolle</li>
|
||||||
|
<li>Systemquellen fuer digital, Papier, Backoffice und RFID</li>
|
||||||
|
<li>Standardprodukte Kaffee, Espresso und Tee</li>
|
||||||
|
<li>Voreinstellung fuer Self-Service und Papierlisten</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Damit ist die Vermarktung sauber: Interessenten landen nicht in einem Technikgeruest, sondern in einem sofort nutzbaren Grundaufbau.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Setup</span>
|
||||||
|
<h1>Plattform installieren</h1>
|
||||||
|
<p>Der Installer schreibt `.env`, legt das MySQL-Schema an und erstellt den ersten Plattform-Admin. Danach ist das Self-Service-Onboarding fuer Mandanten freigeschaltet.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="card form-card" method="post" action="/install">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>App-URL</span>
|
||||||
|
<input type="url" name="app_url" value="<?= e((string) old('app_url', $config['url'])) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>DB-Host</span>
|
||||||
|
<input type="text" name="db_host" value="<?= e((string) old('db_host', '127.0.0.1')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>DB-Port</span>
|
||||||
|
<input type="number" name="db_port" value="<?= e((string) old('db_port', '3306')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>DB-Name</span>
|
||||||
|
<input type="text" name="db_name" value="<?= e((string) old('db_name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>DB-User</span>
|
||||||
|
<input type="text" name="db_user" value="<?= e((string) old('db_user')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>DB-Passwort</span>
|
||||||
|
<input type="password" name="db_pass" value="<?= e((string) old('db_pass')) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Mail-Absender</span>
|
||||||
|
<input type="email" name="mail_from" value="<?= e((string) old('mail_from', 'noreply@example.com')) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Admin-Name</span>
|
||||||
|
<input type="text" name="admin_name" value="<?= e((string) old('admin_name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Admin-E-Mail</span>
|
||||||
|
<input type="email" name="admin_email" value="<?= e((string) old('admin_email')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Admin-Passwort</span>
|
||||||
|
<input type="password" name="admin_password" minlength="12" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-primary" type="submit">Installation abschliessen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="shell footer-grid">
|
||||||
|
<div>
|
||||||
|
<strong>Kaffeekasse SaaS</strong>
|
||||||
|
<p>Fuer Teams, Vereine und Standorte, die klassische Listen und digitale Selbstbuchung in einem System zusammenbringen wollen.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Produktionsreif gedacht</span>
|
||||||
|
<p>Path-basiertes Multi-Tenant-Setup, PHP/MySQL fuer Netcup-Webspace, Ledger-basierte Buchungen und vorbereitete RFID-Inbox.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php clear_old_input(); ?>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
$flashSuccess = flash($session, 'success');
|
||||||
|
$flashError = flash($session, 'error');
|
||||||
|
$authState = $authState ?? $session->get('auth');
|
||||||
|
$tenant = $tenant ?? null;
|
||||||
|
$currentPath = current_path();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= e($title ?? $config['name']) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= e(asset_url('app.css')) ?>">
|
||||||
|
</head>
|
||||||
|
<body class="<?= e($pageClass ?? 'app-shell') ?>">
|
||||||
|
<div class="page-backdrop"></div>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="shell header-inner">
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<span class="brand-mark">KK</span>
|
||||||
|
<span>
|
||||||
|
<strong><?= e($config['name']) ?></strong>
|
||||||
|
<small>Mandantenfaehige Kaffeekasse</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav class="top-nav">
|
||||||
|
<?php if (is_array($tenant) && isset($tenant['slug'])): ?>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'])) ?>" class="<?= $currentPath === tenant_url($tenant['slug']) ? 'active' : '' ?>">Dashboard</a>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'], 'members')) ?>" class="<?= str_contains($currentPath, '/members') ? 'active' : '' ?>">Mitglieder</a>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'], 'products')) ?>" class="<?= str_contains($currentPath, '/products') ? 'active' : '' ?>">Produkte</a>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>" class="<?= str_contains($currentPath, '/bookings') ? 'active' : '' ?>">Digital</a>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'], 'payments')) ?>" class="<?= str_contains($currentPath, '/payments') ? 'active' : '' ?>">Einzahlungen</a>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>" class="<?= str_contains($currentPath, '/paper-sheets') ? 'active' : '' ?>">Papier</a>
|
||||||
|
<a href="<?= e(tenant_url($tenant['slug'], 'rfid')) ?>" class="<?= str_contains($currentPath, '/rfid') ? 'active' : '' ?>">RFID</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="/">Start</a>
|
||||||
|
<a href="/register">Registrieren</a>
|
||||||
|
<a href="/admin/login">Plattform</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="shell main-shell">
|
||||||
|
<?php if ($flashSuccess): ?>
|
||||||
|
<div class="flash flash-success"><?= e((string) $flashSuccess) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($flashError): ?>
|
||||||
|
<div class="flash flash-error"><?= e((string) $flashError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Selbstbedienung</span>
|
||||||
|
<h1>Digitale Buchungen</h1>
|
||||||
|
<p>Dies ist der produktive Kern fuer Browser, Tablet und spaetere Kiosk-Workflows.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>Mitglied</span>
|
||||||
|
<select name="member_id" required>
|
||||||
|
<?php foreach ($members as $member): ?>
|
||||||
|
<option value="<?= e((string) $member['id']) ?>"><?= e($member['display_name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Produkt</span>
|
||||||
|
<select name="product_id" required>
|
||||||
|
<?php foreach ($products as $product): ?>
|
||||||
|
<option value="<?= e((string) $product['id']) ?>"><?= e($product['name']) ?> (<?= e(money_from_cents((int) $product['price_cents'])) ?>)</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Quelle</span>
|
||||||
|
<select name="source_code">
|
||||||
|
<option value="digital_self">Digitale Selbstbuchung</option>
|
||||||
|
<option value="admin_backoffice">Backoffice-Buchung</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Menge</span>
|
||||||
|
<input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Verbrauchszeitpunkt</span>
|
||||||
|
<input type="datetime-local" name="effective_at" value="<?= e((string) old('effective_at', gmdate('Y-m-d\TH:i'))) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notiz</span>
|
||||||
|
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Buchung speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Mitglied</th>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Quelle</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($bookings as $booking): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($booking['recorded_at']) ?></td>
|
||||||
|
<td><?= e($booking['member_name']) ?></td>
|
||||||
|
<td><?= e($booking['product_name']) ?></td>
|
||||||
|
<td><?= e($booking['source_name']) ?></td>
|
||||||
|
<td><?= e(money_from_cents((int) $booking['total_cents'])) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head inline-head">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow"><?= e(ucfirst($tenant['plan'])) ?> Paket</span>
|
||||||
|
<h1><?= e($tenant['name']) ?></h1>
|
||||||
|
<p>Rolle: <?= e((string) ($authState['tenant_role'] ?? 'member')) ?>. Hier laufen digitale Buchungen, Papierlisten und Betriebszahlen zusammen.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="<?= e(tenant_url($tenant['slug'], 'logout')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<button class="button button-secondary" type="submit">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metric-cards">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Mitglieder</span>
|
||||||
|
<strong><?= e((string) $summary['member_count']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Produkte</span>
|
||||||
|
<strong><?= e((string) $summary['product_count']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Papierlisten</span>
|
||||||
|
<strong><?= e((string) $summary['paper_sheet_count']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Offene Salden</span>
|
||||||
|
<strong><?= e(money_from_cents((int) $summary['open_balance_cents'])) ?></strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Letzte Buchungen</span>
|
||||||
|
<ul class="stack-list">
|
||||||
|
<?php foreach ($latestEvents as $event): ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= e($event['member_name']) ?></strong>
|
||||||
|
<span><?= e($event['product_name']) ?> via <?= e($event['source_name']) ?></span>
|
||||||
|
<small><?= e(money_from_cents((int) $event['total_cents'])) ?> am <?= e($event['recorded_at']) ?></small>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Hohe Salden</span>
|
||||||
|
<ul class="stack-list">
|
||||||
|
<?php foreach ($memberBalances as $balance): ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= e($balance['display_name']) ?></strong>
|
||||||
|
<small><?= e(money_from_cents((int) $balance['balance_cents'])) ?></small>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Mandant</span>
|
||||||
|
<h1><?= e($tenant['name']) ?></h1>
|
||||||
|
<p>Digitale Strichliste, Papierlisten-Nacherfassung und spaetere RFID-Anbindung fuer diesen Standort.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="card form-card slim" method="post" action="<?= e(tenant_url($tenant['slug'], 'login')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Stammdaten</span>
|
||||||
|
<h1>Mitglieder verwalten</h1>
|
||||||
|
<p>Mitglieder koennen rein fachlich gefuehrt werden oder optional einen Login fuer die digitale Buchung erhalten.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'members')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>Anzeigename</span>
|
||||||
|
<input type="text" name="display_name" value="<?= e((string) old('display_name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>E-Mail fuer Login</span>
|
||||||
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Login-Passwort</span>
|
||||||
|
<input type="password" name="login_password" minlength="12">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PIN fuer Kiosk / Tablet</span>
|
||||||
|
<input type="text" name="access_pin" value="<?= e((string) old('access_pin')) ?>">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Mitglied speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>PIN</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($members as $member): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($member['display_name']) ?></td>
|
||||||
|
<td><?= e((string) ($member['login_email'] ?: $member['email'])) ?></td>
|
||||||
|
<td><?= e((string) ($member['access_pin'] ?? '')) ?></td>
|
||||||
|
<td><?= e($member['status']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Klassische Liste</span>
|
||||||
|
<h1>Papierlisten und Nacherfassung</h1>
|
||||||
|
<p>Die klassische Kaffeeliste bleibt erhalten, wird aber als geordneter Beleg mit sauberem Posting ins Ledger behandelt.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid-three">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="action" value="create_sheet">
|
||||||
|
<label>
|
||||||
|
<span>Titel</span>
|
||||||
|
<input type="text" name="title" value="<?= e((string) old('title', 'Wochenliste Kaffee')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Periodenlabel</span>
|
||||||
|
<input type="text" name="period_label" value="<?= e((string) old('period_label', 'KW ' . gmdate('W'))) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Startdatum</span>
|
||||||
|
<input type="date" name="period_start" value="<?= e((string) old('period_start', gmdate('Y-m-01'))) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Enddatum</span>
|
||||||
|
<input type="date" name="period_end" value="<?= e((string) old('period_end', gmdate('Y-m-t'))) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notiz</span>
|
||||||
|
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Papierliste anlegen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="action" value="add_line">
|
||||||
|
<label>
|
||||||
|
<span>Papierliste</span>
|
||||||
|
<select name="paper_sheet_id" required>
|
||||||
|
<?php foreach ($sheets as $sheet): ?>
|
||||||
|
<?php if ($sheet['status'] === 'draft'): ?>
|
||||||
|
<option value="<?= e((string) $sheet['id']) ?>"><?= e($sheet['title']) ?> (Entwurf)</option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Mitglied</span>
|
||||||
|
<select name="member_id" required>
|
||||||
|
<?php foreach ($members as $member): ?>
|
||||||
|
<option value="<?= e((string) $member['id']) ?>"><?= e($member['display_name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Produkt</span>
|
||||||
|
<select name="product_id" required>
|
||||||
|
<?php foreach ($products as $product): ?>
|
||||||
|
<option value="<?= e((string) $product['id']) ?>"><?= e($product['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Menge</span>
|
||||||
|
<input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Verbrauchszeitpunkt</span>
|
||||||
|
<input type="datetime-local" name="effective_at" value="<?= e((string) old('effective_at', gmdate('Y-m-d\TH:i'))) ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notiz</span>
|
||||||
|
<input type="text" name="note" value="<?= e((string) old('note')) ?>">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Position erfassen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Posting</span>
|
||||||
|
<p class="muted">Wenn ein Entwurf vollstaendig ist, wird er mit einem Klick in dieselbe Ledger-Logik uebernommen wie digitale Buchungen.</p>
|
||||||
|
<?php foreach ($sheets as $sheet): ?>
|
||||||
|
<?php if ($sheet['status'] === 'draft'): ?>
|
||||||
|
<form class="inline-form" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="action" value="post_sheet">
|
||||||
|
<input type="hidden" name="paper_sheet_id" value="<?= e((string) $sheet['id']) ?>">
|
||||||
|
<button class="button button-secondary" type="submit"><?= e($sheet['title']) ?> verbuchen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Zeilen</th>
|
||||||
|
<th>Zeitraum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($sheets as $sheet): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($sheet['title']) ?></td>
|
||||||
|
<td><?= e($sheet['status']) ?></td>
|
||||||
|
<td><?= e((string) $sheet['line_count']) ?></td>
|
||||||
|
<td><?= e((string) $sheet['period_label']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Papierliste</th>
|
||||||
|
<th>Mitglied</th>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Menge</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($sheetLines as $line): ?>
|
||||||
|
<tr>
|
||||||
|
<td>#<?= e((string) $line['paper_sheet_id']) ?></td>
|
||||||
|
<td><?= e($line['member_name']) ?></td>
|
||||||
|
<td><?= e($line['product_name']) ?></td>
|
||||||
|
<td><?= e((string) $line['quantity']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Finanzen</span>
|
||||||
|
<h1>Einzahlungen verbuchen</h1>
|
||||||
|
<p>Einzahlungen laufen ins selbe Ledger und reduzieren den offenen Saldo des Mitglieds nachvollziehbar.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'payments')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>Mitglied</span>
|
||||||
|
<select name="member_id" required>
|
||||||
|
<?php foreach ($members as $member): ?>
|
||||||
|
<option value="<?= e((string) $member['id']) ?>"><?= e($member['display_name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Betrag in EUR</span>
|
||||||
|
<input type="number" name="amount_eur" min="0.50" step="0.50" value="<?= e((string) old('amount_eur', '5.00')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Zeitpunkt</span>
|
||||||
|
<input type="datetime-local" name="occurred_at" value="<?= e((string) old('occurred_at', gmdate('Y-m-d\TH:i'))) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notiz</span>
|
||||||
|
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>" placeholder="Barzahlung, Monatsbeitrag, ...">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Einzahlung speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Mitglied</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($payments as $payment): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($payment['occurred_at']) ?></td>
|
||||||
|
<td><?= e((string) $payment['member_name']) ?></td>
|
||||||
|
<td><?= e($payment['description']) ?></td>
|
||||||
|
<td><?= e(money_from_cents((int) $payment['amount_cents'])) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Sortiment</span>
|
||||||
|
<h1>Produkte und Preise</h1>
|
||||||
|
<p>Die Preislogik bleibt versionierbar. Jede Buchung friert den zum Zeitpunkt gueltigen Preis ein.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'products')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<label>
|
||||||
|
<span>Produktname</span>
|
||||||
|
<input type="text" name="name" value="<?= e((string) old('name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Preis in EUR</span>
|
||||||
|
<input type="number" name="price_eur" min="0.10" step="0.05" value="<?= e((string) old('price_eur', '1.20')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Produkt anlegen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>Preis</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($products as $product): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($product['name']) ?></td>
|
||||||
|
<td><?= e((string) $product['sku']) ?></td>
|
||||||
|
<td><?= e(money_from_cents((int) ($product['price_cents'] ?? 0))) ?></td>
|
||||||
|
<td><?= $product['is_active'] ? 'aktiv' : 'inaktiv' ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Integration</span>
|
||||||
|
<h1>RFID-Roadmap und Event-Inbox</h1>
|
||||||
|
<p>Das MVP speichert Geraete, Karten und rohe Events bereits produktionsnah, ohne die spaetere Hardwareentscheidung vorwegzunehmen.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid-three">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'rfid')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="action" value="create_device">
|
||||||
|
<label>
|
||||||
|
<span>Geraetename</span>
|
||||||
|
<input type="text" name="name" value="<?= e((string) old('name')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Standort</span>
|
||||||
|
<input type="text" name="location_label" value="<?= e((string) old('location_label')) ?>">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">RFID-Geraet vorbereiten</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'rfid')) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="action" value="assign_tag">
|
||||||
|
<label>
|
||||||
|
<span>Mitglied</span>
|
||||||
|
<select name="member_id" required>
|
||||||
|
<?php foreach ($members as $member): ?>
|
||||||
|
<option value="<?= e((string) $member['id']) ?>"><?= e($member['display_name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Karten-UID</span>
|
||||||
|
<input type="text" name="uid" value="<?= e((string) old('uid')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<input type="text" name="label" value="<?= e((string) old('label')) ?>">
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Karte zuweisen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">API</span>
|
||||||
|
<p class="muted">POST `/api/rfid/intake` mit JSON-Body:</p>
|
||||||
|
<pre class="code-block">{
|
||||||
|
"device_token": "...",
|
||||||
|
"uid": "04AABBCCDD",
|
||||||
|
"event_id": "reader-0001",
|
||||||
|
"ts": "2026-06-15T09:30:00Z"
|
||||||
|
}</pre>
|
||||||
|
<p class="muted">Events werden tenant-scharf gespeichert und spaeter in Buchungsregeln ueberfuehrt.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Geraet</th>
|
||||||
|
<th>Standort</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Token</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($devices as $device): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($device['name']) ?></td>
|
||||||
|
<td><?= e((string) $device['location_label']) ?></td>
|
||||||
|
<td><?= e($device['status']) ?></td>
|
||||||
|
<td><code><?= e(substr($device['device_token'], 0, 16)) ?>...</code></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Karte</th>
|
||||||
|
<th>Mitglied</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Zuletzt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tags as $tag): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e((string) ($tag['label'] ?: 'ohne Label')) ?></td>
|
||||||
|
<td><?= e((string) $tag['member_name']) ?></td>
|
||||||
|
<td><?= e($tag['status']) ?></td>
|
||||||
|
<td><?= e((string) ($tag['last_seen_at'] ?? 'noch nie')) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Geraet</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Event-ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($events as $event): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($event['received_at']) ?></td>
|
||||||
|
<td><?= e((string) $event['device_name']) ?></td>
|
||||||
|
<td><?= e($event['status']) ?></td>
|
||||||
|
<td><?= e((string) $event['external_event_id']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BUILD_DIR="$ROOT_DIR/build"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
TARGET="$BUILD_DIR/kaffeekasse-saas-$STAMP.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
tar \
|
||||||
|
--exclude=".git" \
|
||||||
|
--exclude=".env" \
|
||||||
|
--exclude="build" \
|
||||||
|
--exclude="storage/cache/*" \
|
||||||
|
--exclude="storage/logs/*" \
|
||||||
|
--exclude="storage/uploads/*" \
|
||||||
|
-czf "$TARGET" \
|
||||||
|
-C "$ROOT_DIR" .
|
||||||
|
|
||||||
|
echo "Release package created: $TARGET"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
Dieses Repo ist fuer Netcup-Deploys per FTPES/SFTP vorbereitet.
|
||||||
|
|
||||||
|
Empfohlener Ablauf:
|
||||||
|
1. build-release.sh lokal oder in CI ausfuehren
|
||||||
|
2. Release nach /apps/kaffeekasse/current hochladen
|
||||||
|
3. Document Root auf /apps/kaffeekasse/current/public setzen
|
||||||
|
4. Anschliessend per Scheduled Task oder Shell starten:
|
||||||
|
/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php
|
||||||
|
|
||||||
|
Das eigentliche Upload-Kommando haengt von eurem CI-System und den Zugangsdaten ab
|
||||||
|
und bleibt deshalb bewusst ausserhalb des Repos.
|
||||||
|
EOF
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1781536386
|
||||||
Reference in New Issue
Block a user