vcard4reseller/docs/KONZEPT.md
Thomas Peterson 46a75f859b Konzept: Zeiterfassung (§14) + überarbeitetes Rechte-Konzept (§2)
- §14 Zeiterfassung (Kommen/Gehen): TimeEntry append-only/revisionssicher,
  Stempeln per App/Kiosk/Web (NFC/PIN), Compliance (ArbZG/DSGVO/BetrVG)
- §2 neu: mehrere Logins pro Ebene, Rechtegruppen, delegierte Rechtevergabe
  (Rolle <= eigene Ebene + nur eigener Mandanten-Teilbaum), RoleAssignmentVoter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:19:23 +02:00

500 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# vcard4reseller — Konzept & Datenmodell
> White-Label-Plattform für digitale Visitenkarten (PrintShopCreator).
> Reseller (Druckereien/Agenturen) bieten ihren Firmenkunden ein zentrales
> Identity-Management an: ein Mitarbeiterprofil als *Single Source of Truth*,
> das in alle digitalen und gedruckten Ausgabekanäle synchronisiert wird.
**Status:** Konzeptphase — noch kein Code.
**Stack-Entscheidung:** Symfony (API Platform, JSON-API) + eigenständige Vue 3 SPA (Vite), MariaDB. Architektur **Variante A** (SPA + getrennte API).
---
## 1. Ziele & Leitprinzipien
1. **Single Source of Truth:** Eine Änderung am Mitarbeiterprofil propagiert in *alle* Kanäle (vCard, Wallet, Landingpage, NFC, QR, Druckdaten, E-Mail-Signatur).
2. **Mandantenfähigkeit (Multi-Tenancy):** Strikte Datentrennung pro Reseller und pro Firmenkunde.
3. **White-Label:** Jeder Reseller / Firmenkunde tritt unter eigener Domain & eigenem Branding auf.
4. **Wiederkehrender Umsatz:** Reseller zahlt Plattformgebühr (Paket), Firmenkunde zahlt Reseller (eigene Preisgestaltung).
5. **Öffentliche Profilseiten** müssen schnell & SEO-fähig sein → serverseitig gerendert (Twig), getrennt von den Dashboards (Vue-SPA).
---
## 2. Rollen & Berechtigungen
| Rolle | Symfony-Role | Scope | Kann |
|-------|--------------|-------|------|
| Plattform-Admin | `ROLE_PLATFORM_ADMIN` | global | Reseller anlegen/verwalten, Plattform-Pakete, Abrechnung gegenüber Resellern, Demo-Leads |
| Reseller-Admin | `ROLE_RESELLER_ADMIN` | 1 Reseller | Firmenkunden + Domains + Branding anlegen, Preise definieren, Druckaufträge, Reseller-Mitarbeiter |
| Firmen-Admin | `ROLE_COMPANY_ADMIN` | 1 Company | Standorte, Mitarbeiter/Profile, Templates der Firma, NFC/QR ausrollen |
| Mitarbeiter | `ROLE_EMPLOYEE` | 1 Profil | eigenes Profil pflegen (sofern Firma das erlaubt) |
| Öffentlicher Besucher | — | — | Profilseite ansehen, vCard/Wallet herunterladen, NFC/QR scannen |
**Hierarchie der Mandanten:** `Plattform → Reseller → Company → Location → Employee/Profil`
### Mehrere Logins pro Ebene
Jede Ebene kann **mehrere Benutzer** haben (mehrere Plattform-, Reseller-, Firmen-Admins).
Technisch: `User` ist die Login-Identität (1 Person = 1 Login), `n` User pro
Reseller bzw. Company. Ein `User` ist optional mit einem `Employee` (Profil) verknüpft —
so wird aus einem Mitarbeiter mit Profil per **Rechtegruppen-Zuweisung** zusätzlich
ein eingeloggter Admin.
### Rechtegruppen (zuweisbare Rollen)
Die Rollen werden im UI als wählbare **Rechtegruppe** angeboten — beim Anlegen/Bearbeiten
eines Benutzers bzw. direkt in der Mitarbeiterliste („kein Login" | „Mitarbeiter" |
„Firmen-Admin" | …). Start = die vier festen Rollen; später optional **granulare
Berechtigungs-Gruppen** (eigene Rechte-Sets pro Mandant).
### Delegierte Rechtevergabe (Kernregel)
Jede Ebene vergibt Rechte **nur an oder unterhalb der eigenen Ebene** und **nur im
eigenen Mandanten-Teilbaum** — niemals nach oben, nie über Mandantengrenzen hinweg:
| Akteur | darf Rechtegruppe vergeben | Geltungsbereich |
|--------|----------------------------|-----------------|
| Plattform-Admin | Plattform-Admin, Reseller-Admin (+ darunter) | alle Mandanten |
| Reseller-Admin | Reseller-Admin (weitere im eigenen Reseller), Firmen-Admin, Mitarbeiter | nur eigener Reseller + dessen Firmen |
| Firmen-Admin | Firmen-Admin (weitere der eigenen Firma), Mitarbeiter | nur eigene Firma |
| Mitarbeiter | — | — |
D. h.: *wir* vergeben Reseller-Rechte, Reseller vergeben Firmen-Admin-Rechte, Firmen-Admins
vergeben Mitarbeiter-/weitere Firmen-Admin-Rechte. Same-Level-Vergabe (weitere Admins der
eigenen Ebene) ist erlaubt → ermöglicht mehrere Logins pro Ebene.
Durchsetzung über:
- **Doctrine-Filter / API-Platform-Query-Extension** (automatisches Scoping nach
`reseller_id` / `company_id` je nach eingeloggtem Kontext)
- **Security Voters** (`CompanyVoter`, `EmployeeVoter`) für Aktion×Objekt
- **`RoleAssignmentVoter`/-Service**: prüft bei jeder Rollen-/Rechtegruppen-Vergabe,
dass die Zielrolle ≤ der höchsten Rolle des Akteurs ist **und** der Ziel-Benutzer im
Mandanten-Teilbaum des Akteurs liegt (Schutz vor Privilege-Escalation)
- API-Platform `security`-Attribute pro Resource & Operation
---
## 3. Architektur
```
┌──────────────────────────┐ ┌──────────────────────────────────┐
│ Vue 3 SPA (Vite) │ │ Öffentliche Profilseiten │
│ - Reseller-Dashboard │ │ (Symfony + Twig, SSR, SEO) │
│ - Firmen-Dashboard │ │ /{slug}, /c/{company}/{slug} │
│ - Mitarbeiter-Self-Svc │ │ vCard-Download, Wallet, QR-Ziel │
└───────────┬──────────────┘ └─────────────────┬────────────────┘
│ JSON (JWT/Session) │
▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Symfony Backend │
│ ├─ API Platform (REST/JSON, OpenAPI) │
│ ├─ Security (JWT/LexikJWT od. Session), Voters, Doctrine-Filter │
│ ├─ Domain Services (ProfileSync, ChannelGenerator) │
│ ├─ Messenger (async): Wallet-Pass, Druckdaten-PDF, QR/NFC, E-Mail │
│ └─ Doctrine ORM │
└───────────┬───────────────────────┬───────────────────┬──────────────┘
▼ ▼ ▼
MariaDB Object-Storage externe Dienste
(Mandantendaten) (Assets, PDFs, Pässe) (Apple/Google Wallet,
Payment/Stripe, Mail)
```
**Generierungs-Pipeline (ChannelGenerator):** Bei jeder Profiländerung wird ein
`ProfileUpdated`-Event ausgelöst → Messenger-Handler regenerieren die abgeleiteten
Artefakte (vCard-String, Wallet-Pass, QR/Kurz-URL, Druck-PDF, E-Mail-Signatur-HTML)
und legen sie versioniert ab. Öffentliche Endpunkte liefern immer den aktuellen Stand.
---
## 4. Datenmodell (Entitäten)
### Kernentitäten
- **User** — Auth-Identität (1 Login). Felder: `email`, `password`, `roles[]` (= Rechtegruppe), `status`, `lastLogin`. Verknüpft optional mit `reseller_id`, `company_id`, `employee_id`. **Mehrere User pro Reseller/Company** möglich; Rollenvergabe ist delegiert & scope-geprüft (siehe §2).
- **Reseller** — oberster Mandant. Felder: `name`, `slug`, `primaryDomain`, Branding-Defaults, `status`, `platformPlan_id`. Hat Limits aus dem Plattform-Paket.
- **PlatformPlan** — Reseller-Pakete (Starter/Professional/Business). Felder: `name`, `pricePerMonth`, `maxProfiles`, `maxCompanies`, `features[]`.
- **ResellerSubscription** — Abo des Resellers bei der Plattform. Felder: `plan_id`, `status`, `startedAt`, `renewsAt`, `paymentRef` (Stripe).
- **Company** (Firmenkunde) — gehört zu Reseller. Felder: `name`, `slug`, Branding (Logo, Farben, Fonts), `defaultLocation_id`, `status`, `selfEditEnabled` (erlaubt Mitarbeiter-Self-Service generell). Domains ausgelagert in `Domain`.
- **Domain** — gehört zu Company. Felder: `hostname`, `type` (`subdomain`|`custom`), `status` (`pending`|`verified`|`failed`), `verificationCheckedAt`, `tlsStatus`. Custom-Domains zeigen per A-Record auf unsere IP (siehe §11).
- **Location** (Standort) — gehört zu Company. Felder: Adresse, Geo, Telefon, Öffnungszeiten, Standort-spezifisches Branding-Override.
- **Employee / Profile** — gehört zu Company, optional Location. **Single Source of Truth.** Felder: Name, Titel, Position, Abteilung, Telefon(e), E-Mail, Foto, Bio, `slug` (öffentlich), `status`, `user_id` (optional, für Self-Service), `selfEditAllowed` (bool — Firmen-Admin gibt diesen Mitarbeiter frei), `editableFields` (optional: welche Felder der Mitarbeiter ändern darf).
- **ContactLink** — Social-/Web-Links eines Profils (Typ + URL + Reihenfolge). 1:n zu Employee.
### Branding & Vorlagen
- **Template** — Vorlage, scoped auf Reseller *oder* Company. Felder: `type` (`print_card` | `email_signature` | `landing_page` | `wallet_pass`), `name`, `config` (JSON), `assets`.
- **Asset** — hochgeladene Datei (Logo, Foto, Hintergrund). Felder: `path`, `mimeType`, `owner` (polymorph: reseller/company/employee).
### Ausgabekanäle (generierte Artefakte)
- **NfcTag** — physischer Tag. Felder: `uid`, `shortUrl`, `employee_id` (nullable bis Zuweisung), `status` (`unassigned`/`active`/`disabled`), `lastScanAt`.
- **QrCode** — generierter QR (statisch oder dynamische Redirect-URL). Felder: `target`, `imageAsset`, `employee_id`.
- **WalletPass** — Referenz auf Apple/Google-Pass. Felder: `provider` (`apple`|`google`), `serial`, `authToken` (Apple Web-Service-Auth), `passUrl`, `employee_id`, `lastGeneratedAt`. Siehe §12.
- **WalletDevice** — registriertes Apple-Gerät für Push-Updates (Apple-spezifisch). Felder: `deviceLibraryId`, `pushToken`, `serial` (→ WalletPass), `registeredAt`. Google braucht das nicht (Server-Push über die API).
- **GeneratedArtifact** (optional, generisch) — Cache abgeleiteter Outputs: `type` (vcard/print_pdf/signature_html), `employee_id`, `payload`/`fileRef`, `generatedAt`, `version`.
### Vertrieb & Abrechnung
- **Invoice** — Rechnung **Plattform→Reseller**. Felder: `reseller_id`, `amount`, `period`, `status`, `pdfRef`. (Reseller→Company-Rechnungen vorerst nicht Teil der Plattform, siehe §8.)
- ~~**CompanySubscription / PricePlan**~~ — *zurückgestellt:* Reseller↔Company-Abrechnung läuft außerhalb der Plattform.
- **PrintOrder** — Druckauftrag. Felder: `company_id`, `status` (`new`/`in_production`/`shipped`), `items[]` (je Employee), `printDataRef`.
### Marketing / Sonstiges
- **DemoRequest** — Lead vom Marketing-Formular (`/demo`). Felder: Name, Firma, E-Mail, Telefon, Nachricht, `status`.
- **AuditLog** — Nachvollziehbarkeit kritischer Aktionen (wer/was/wann).
---
## 5. ER-Diagramm
```mermaid
erDiagram
PlatformPlan ||--o{ Reseller : "definiert Limits"
Reseller ||--|| ResellerSubscription : hat
Reseller ||--o{ Company : verwaltet
Reseller ||--o{ User : "Reseller-Staff"
Reseller ||--o{ Template : "Defaults"
Company ||--o{ Location : hat
Company ||--o{ Domain : "Sub-/Custom-Domain"
Company ||--o{ Employee : beschäftigt
Company ||--o{ User : "Firmen-Admins"
Company ||--o{ Template : "eigene"
Location ||--o{ Employee : "zugeordnet"
Employee ||--o{ ContactLink : hat
Employee ||--o| NfcTag : verknüpft
Employee ||--o{ QrCode : hat
Employee ||--o| WalletPass : hat
WalletPass ||--o{ WalletDevice : "Apple-Push"
Employee ||--o{ GeneratedArtifact : erzeugt
Employee ||--o| User : "Self-Service"
Company ||--o{ PrintOrder : bestellt
Reseller ||--o{ Invoice : "Plattform→Reseller"
```
---
## 6. API-Struktur (API Platform, grob)
Alle unter `/api`, JWT-geschützt, mandantengescoped. Beispiele:
| Resource | Operationen | Zugriff |
|----------|-------------|---------|
| `/api/resellers` | CRUD | Platform-Admin |
| `/api/companies` | CRUD | Reseller-Admin (eigene), Platform-Admin |
| `/api/locations` | CRUD | Company-/Reseller-Admin |
| `/api/employees` | CRUD | Company-Admin; GET self für Employee |
| `/api/employees/{id}/regenerate` | POST | löst Kanal-Neugenerierung aus |
| `/api/templates` | CRUD | Reseller-/Company-Admin |
| `/api/nfc-tags` | CRUD + assign | Company-Admin |
| `/api/print-orders` | CRUD | Company-Admin / Reseller |
| `/api/demo-requests` | POST (öffentlich), GET (Admin) | Marketing |
**Öffentliche, nicht-API Endpunkte (Symfony/Twig, SSR):**
- `GET /{slug}` oder `https://{companyDomain}/{employeeSlug}` → Landingpage
- `GET /{slug}/vcard.vcf` → vCard-Download
- `GET /{slug}/wallet` → Wallet-Pass
- `GET /t/{nfcShortCode}` → NFC/QR-Redirect auf aktuelles Profil
---
## 7. Sicherheit & Mandantentrennung
- **Auth:** LexikJWTAuthenticationBundle (Stateless API) oder Session für SPA-Komfort. Refresh-Tokens.
- **Scoping:** Doctrine-`Filter`, der je nach eingeloggtem User automatisch `reseller_id`/`company_id` in alle Queries injiziert. Kein Datenleck zwischen Mandanten.
- **Voters:** `CompanyVoter`, `EmployeeVoter`, `TemplateVoter` für Aktion×Objekt-Prüfung.
- **Domain-Auflösung:** Middleware ermittelt aus Host-Header (Custom-Domain/Subdomain) den Mandantenkontext für öffentliche Seiten.
- **Datenschutz:** DSGVO — Mitarbeiterdaten, Lösch-/Export-Funktion, AuditLog. Standort EU.
---
## 8. Billing-Modell
**Entscheidung:** Vorerst rechnet **nur die Plattform gegenüber dem Reseller** ab. Wie der Reseller seinerseits mit den Firmenkunden abrechnet, regelt er selbst — das ist *nicht* Teil der Plattform (Stand jetzt). Eine spätere optionale Reseller→Company-Fakturierung bleibt als Erweiterung vorgemerkt.
1. **Plattform → Reseller (umgesetzt):** Reseller wählt `PlatformPlan` (Starter 99 € / Professional 249 € / Business 599 €). Stripe-Abo, Limits (`maxProfiles`, `maxCompanies`) werden durchgesetzt.
2. **Reseller → Company (außerhalb der Plattform):** Reseller vereinbart Preise selbst. Plattform bietet höchstens einen **Umsatzrechner/Reporting** zur Orientierung, aber keine Rechnungsstellung. → Entität `CompanySubscription`/`Invoice` (Reseller→Company) wird **zurückgestellt** auf Phase „später, falls gewünscht".
3. **Umsatzrechner** (aus Marketing): Profile × Preis Plattformgebühr = Marge (rein informativ).
---
## 9. Roadmap / Phasen
**Phase 0 — Setup** (nach Konzept-Freigabe)
- Symfony-Projekt + API Platform + Doctrine + MariaDB
- Vue 3 SPA (Vite) Grundgerüst, Auth-Flow
- CI, Docker-Dev-Umgebung
**Phase 1 — Kern-Domäne & Auth**
- Entitäten: User, Reseller, Company, Location, Employee, ContactLink
- Rollen, Voters, Doctrine-Mandantenfilter
- Reseller-/Firmen-Dashboards (CRUD)
**Phase 2 — Öffentliche Profile**
- SSR-Landingpage, vCard-Download, QR-Code, Kurz-URL-Redirect
- Branding/Template-Engine (landing_page)
**Phase 3 — Ausgabekanäle**
- Wallet-Pässe (Apple/Google), E-Mail-Signaturen, Druckdaten-PDF
- NFC-Tag-Verwaltung & Zuweisung
- Messenger-Pipeline für async Generierung
**Phase 4 — Billing & Vertrieb**
- Plattform-Pakete + Stripe, Limit-Durchsetzung
- Reseller-Preisgestaltung, Umsatzrechner, PrintOrders, Invoices
**Phase 5 — Marketing-Site & Politur**
- Öffentliche Landingpage (vcard4reseller.de-Nachbau), Demo-Formular → DemoRequest
- Reporting, AuditLog, DSGVO-Tools
---
## 10. Geklärte Entscheidungen & offene Fragen
### Geklärt (2026-05-30)
1. **Domains:** Standard = **Subdomains** (`firma.reseller.de`). Firmenkunden können zusätzlich eine **eigene Domain** hinterlegen; sie müssen dann selbst per **A-Record auf unsere IP** zeigen. Plattform übernimmt Domain-Verifikation + TLS-Provisionierung (Let's Encrypt). Siehe §11.
2. **Self-Service:** **Möglich, aber muss vom Firmen-Admin freigegeben werden** — zweistufig: `Company.selfEditEnabled` (Firma erlaubt es generell) + `Employee.selfEditAllowed` (pro Mitarbeiter). Mitarbeiter bearbeitet nur freigegebene Felder.
3. **Billing:** Nur **Plattform → Reseller** (siehe §8). Reseller↔Company außerhalb der Plattform; spätere Erweiterung möglich.
### Noch offen
4. **Wallet-Pässe:** Apple Developer Account + Google Wallet API vorhanden? (Zertifikate erforderlich). White-Label-Frage: läuft der Apple-Pass unter *einem* zentralen Pass Type ID (Plattform) oder pro Reseller? Konzept dazu in §12.
5. **Druckdaten:** Welches Format erwarten die Druckereien (PDF/X, bestimmte Maße, Beschnitt)? Gibt es Vorlagen?
6. **Bestehende Daten/Branding:** Existieren Design-Assets/CI zur bestehenden vcard4reseller.de, die wir übernehmen?
---
## 11. Domain-Handling
**Subdomain (Default):** Jede Company bekommt `{company.slug}.{reseller.primaryDomain}`. Wildcard-DNS + Wildcard-TLS auf Reseller-Ebene → keine Aktion durch den Kunden nötig.
**Eigene Domain (optional):**
1. Firmen-Admin trägt seine Domain (`visitenkarte.firma.de`) im Dashboard ein → Status `pending`.
2. Plattform zeigt die einzutragende **IP (A-Record)** (+ optional CNAME) an.
3. Kunde setzt den DNS-Eintrag bei seinem Provider.
4. Plattform prüft periodisch (DNS-Lookup) → bei Treffer Status `verified`, dann **automatisches TLS-Zertifikat** (Let's Encrypt / ACME).
5. Reverse-Proxy/Router löst den `Host`-Header → Mandantenkontext (Company) auf.
**Datenmodell-Ergänzung** an `Company` bzw. neue Entität **`Domain`**:
`hostname`, `type` (`subdomain`|`custom`), `status` (`pending`|`verified`|`failed`), `verificationCheckedAt`, `tlsStatus`, `company_id`.
---
## 12. Wallet-Pässe & Kontakt-Synchronisation
### Das Grundproblem
Eine heruntergeladene **vCard (.vcf) ist ein Schnappschuss**: Einmal in der Kontakte-App gespeichert, aktualisiert sie sich nicht mehr. Ändert ein Mitarbeiter seine Nummer, haben alle Empfänger veraltete Daten. Das ist eine OS-Einschränkung. Es gibt drei Wege zu „aktuellen Daten":
| Weg | Echter Auto-Sync? | Wo landet es? | Aufwand |
|-----|-------------------|---------------|---------|
| **Wallet-Pass** (Apple/Google) | **Ja, over-the-air** | Wallet-App (nicht Kontakte) | hoch (Zertifikate) |
| **Link behalten** (QR/NFC/Kurz-URL) | Daten aktuell *beim Öffnen*, vCard wird live generiert | Browser → Kontakt bei Bedarf neu | ✅ umgesetzt (Phase 2) |
| **CardDAV** | Ja, auf Kontakt-Ebene | native Kontakte-App | mittel, für Einzel-Empfänger unpraktisch (account-basiert) |
**Fazit:** Den einzigen echten „Push in eine bereits gespeicherte Karte" liefert der **Wallet-Pass**. Der **Link** (unsere Profilseite) ist der pragmatische Standard: nicht die Daten, sondern der Link wird gespeichert; die vCard erzeugen wir bei jedem Abruf frisch.
### Apple Wallet (PassKit)
- `.pkpass` = ZIP aus `pass.json` + Bildern + `manifest.json` + **PKCS#7-Signatur** (Pass Type ID-Zertifikat + Apple-WWDR-Cert).
- Pass-Stil `generic` (Visitenkarte): Name / Position / Firma / Telefon / E-Mail / Link.
- PHP: z. B. `includable/php-pkpass`, gekapselt in einem `WalletService`.
- **Sync (Push):** Pass enthält `webServiceURL` + `authenticationToken`.
1. Nutzer fügt Pass hinzu → Gerät **registriert** sich (→ `WalletDevice`).
2. Profil ändert sich → **leerer APNs-Push** an registrierte Geräte.
3. Gerät holt aktualisierten Pass von unserer **PassKit-Web-Service-API** ab.
- Nötige Endpunkte (PassKit Web Service): `register`, `unregister`, `list serials`, `latest pass`.
### Google Wallet
- REST-API + Service-Account (kostenlos). *Generic*-Pass-**Klasse** + pro Mitarbeiter ein **Objekt**.
- Hinzufügen via „Add to Google Wallet"-Link (signiertes JWT).
- **Sync:** Objekt server-seitig per API **patchen** → Google pusht selbst. Kein APNs, keine Geräte-Registrierung. → einfacher als Apple, daher **als erster Wallet-Kanal empfohlen**.
### Einbettung in die Architektur
```
Employee geändert ──► ProfileUpdated-Event ──► Messenger (async)
├─ Apple: Pass neu signieren + APNs-Push an WalletDevices
└─ Google: Wallet-Objekt per API patchen (Google pusht selbst)
```
Neue Bausteine: `WalletPass`/`WalletDevice` (Datenmodell §4), `WalletService` (Pass bauen/signieren/patchen), `ApplePassController` (Web-Service-Endpunkte), Messenger-Handler am `ProfileUpdated`-Event (gleicher Trigger wie `Employee::touch()`).
### White-Label-Überlegung
Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein zentraler Pass Type ID der Plattform für alle Reseller (einfacher, Branding über Pass-Felder/Logo) oder **(b)** pro Reseller ein eigener (aufwändig, jeder Reseller braucht Apple-Account). Empfehlung: **(a)** zentral, Reseller-/Firmen-Branding über Logo & Farben im Pass. → zu klären (offene Frage #4).
### Voraussetzungen (offen)
- Apple Developer Account + Pass Type ID + Zertifikat (`.p12`) — für Apple Wallet.
- Google-Cloud-Projekt + Wallet-API + Service-Account — für Google Wallet.
---
## 13. Druckdaten: Visitenkarten-PDF (Kerngeschäft)
Reseller drucken für ihre Firmenkunden Visitenkarten und brauchen **druckfertige PDFs**. Das Layout variiert **pro Firma**. Später kommt **Briefpapier** (gleiches System, anderes Format) dazu.
### Festgelegte Entscheidungen (2026-05-31)
- **Farbraum/Qualität:** CMYK, Ziel **PDF/X-1a**, eingebettete Fonts.
- **Format:** 85×55 mm, **2 mm Beschnitt** (→ 89×59 mm Dokument), **Schnittmarken**, Sicherheitsabstand ~4 mm.
- **Seiten:** Vorder- **und** Rückseite (2 PDF-Seiten).
- **Ausgabe:** ein PDF pro Mitarbeiter (Druckbogen/Ausschießen später).
- **Layout-Definition:** **visueller Editor** im Dashboard → strukturierte Element-Definition (JSON), nicht HTML.
### Datenmodell
**`CardTemplate`** — gehört zu einer Company (`company = null` ⇒ globale Standardvorlage).
Felder: `name`, `widthMm` (85), `heightMm` (55), `bleedMm` (2), `safeMm` (4),
`front` (Element[]), `back` (Element[]), `type` (`card` | später `letterhead`), `company_id`.
**Element** (JSON): `type` (`field` | `text` | `image` | `qr` | `rect` | `line`),
`xMm`, `yMm`, `wMm`, `hMm`, `rotation`,
Typografie (`fontFamily`, `fontSizePt`, `bold`, `italic`, `align`, `lineHeight`),
`color` (CMYK `{c,m,y,k}` 0100), `fill` (CMYK),
`binding` (bei `field`: Mitarbeiter-Feld), `text` (bei `text`: statisch), `src` (bei `image`).
**Bindings** (Datenquelle = Mitarbeiterprofil): `firstName`, `lastName`, `fullName`,
`position`, `department`, `email`, `phone`, `mobile`, `company.name`, `profileUrl`,
`shortUrl`; Spezial: `qr` (Kurz-URL), `logo` (Firmen-Logo aus brandingConfig).
### Rendering-Pipeline
```
CardTemplate (JSON) + Employee + Branding
▼ TCPDF (Koordinaten in mm, CMYK, Fonts eingebettet)
89×59 mm Seite (Trim 85×55 + 2 mm Bleed), Schnittmarken, V/R = 2 Seiten
▼ (Finishing) Ghostscript → PDF/X-1a:2001 mit CMYK-Output-Intent (ICC, z. B. ISO Coated v2)
druckfertiges PDF
```
- **TCPDF**: exakte mm-Platzierung pro Element, `setColor`/`setTextColor` mit CMYK,
`cropMark()` an den Trim-Ecken, Bilder/QR als eingebettete Grafik, Fonts eingebettet.
- **PDF/X-1a-Finishing** (Ghostscript) als nachgelagerter Schritt — braucht `ghostscript`
+ ICC-Profil im Container. Bis dahin: valides CMYK-PDF mit Bleed + Schnittmarken.
### Endpunkte
- `GET /api/employees/{id}/card.pdf` — Einzelkarte (auth, Reseller/Firma; mandantengeprüft).
- später: Sammel-PDF / ausgeschossener Bogen je Firma; Anbindung an `PrintOrder`.
### Visueller Editor (SPA)
- Canvas im mm-Maßstab mit sichtbarem **Beschnitt-, Trim- und Sicherheitsrahmen**.
- Elemente per Drag&Drop; Eigenschaften-Panel (Position/Größe/Font/Farbe/Datenbindung).
- Tabs **Vorder-/Rückseite**; Live-Vorschau mit echten oder Beispiel-Daten.
- Speichern als `CardTemplate` (pro Firma); Default-Vorlage als Startpunkt.
### Briefpapier (später)
Gleiches `CardTemplate`-Modell mit `type = letterhead` und Format **A4**; der Renderer
ist format-agnostisch (Maße + Elementliste). Bindings identisch + ggf. Adressfeld/Faltmarken.
### Hintergrund-PDF (Variable Data Printing) & eigene Schriften
**Hintergrund-PDF:** Der Kunde gestaltet die Karte vollständig in seinem DTP-Programm
(InDesign/Illustrator: Logo, Farbflächen, Hintergrund, statische Texte) und exportiert
ein druckfertiges PDF (CMYK, mit Beschnitt). Dieses wird hochgeladen und dient als
**Hintergrund**; die Plattform legt darüber nur noch die **dynamischen Felder**
(Name, Position, Kontakt, QR). Klassisches *Variable Data Printing*.
- Technik: **FPDI** (`setasign/fpdi`) importiert die Seiten des Kunden-PDFs; der
Renderer (FPDI-Tcpdf) platziert sie als Seitenhintergrund (`useTemplate`) und
zeichnet die dynamischen Elemente darüber.
- Erwartung: Hintergrund-PDF hat die Maße Endformat + Beschnitt (`width+2·bleed` ×
`height+2·bleed`). Seite 1 = Vorderseite, optional Seite 2 = Rückseite.
- Bei Hintergrund-PDF: **keine** eigenen Schnittmarken/Beschnitt-Logik (der Kunde
liefert das); Element-Koordinaten weiterhin im Trim-Raum, Offset = `bleedMm`.
- Die Vorlage enthält dann nur noch dynamische Elemente (Felder/QR), die statischen
entfallen.
**Eigene Schriften:** TTF/OTF werden hochgeladen und per `TCPDF_FONTS::addTTFfont()`
**eingebettet** (Subset). `CardTemplate` führt eine Schriften-Liste (`family → Pfad`);
Elemente referenzieren eine `fontFamily` (Custom oder Core helvetica/times/courier).
Font-Embedding ist zugleich Voraussetzung für striktes **PDF/X**.
**Uploads/Datenmodell:** `CardTemplate.backgroundPath` (Pfad zum Kunden-PDF, nullable),
`CardTemplate.fonts` (`[{family, path}]`). Dateien liegen außerhalb des Webroots unter
`var/storage/cards/{companyId}/`. Upload-Endpunkte:
`POST /api/companies/{id}/card-template/background` (PDF) und `.../font` (TTF/OTF).
---
## 14. Zeiterfassung (Modul „Kommen/Gehen")
Erweiterung über die digitale Visitenkarte hinaus: eine **Arbeitszeiterfassung**
für die Firmenkunden der Reseller (vorzugsweise Druckshops). Der Mitarbeiter ist
die gemeinsame Klammer — dieselbe Identität (`Employee`), dieselben Standorte,
dieselbe NFC/QR-Infrastruktur. Positioniert die Plattform vom „Identity/Print-Tool"
hin zur **Mitarbeiter-Plattform** (ein Datensatz, mehrere Module).
### Markttreiber
EuGH 2019 + BAG 2022 → **Pflicht zur Arbeitszeiterfassung** in DE. SMB-Kunden
suchen einfache Lösungen → starkes Verkaufsargument; NFC-Terminals/Badges sind
zusätzlicher **Hardware-Umsatz** für den Druckshop.
### Erfassungswege
- **Web/App** (authentifizierter Mitarbeiter über sein `User`-Konto): Button
„Kommen / Gehen / Pause" im Dashboard bzw. Handy-Browser. Quelle `web`/`app`.
- **Kiosk** (geteiltes Standort-Gerät, per Geräte-Token): Mitarbeiter identifiziert
sich per **NFC-Tap** (`shortCode`) oder **PIN**. Quelle `kiosk`.
- NFC/QR nutzt die bestehende `shortCode`/`/t/`-Infrastruktur. *Hinweis:* da der
Profil-`shortCode` halb-öffentlich ist, am Kiosk zusätzlich **PIN** empfehlen.
### Datenmodell
**`TimeEntry`** — append-only Stempel-Ereignis (revisionssicher, **kein** Update/Delete):
`employee`, `type` (`clock_in`|`clock_out`|`break_start`|`break_end`),
`occurredAt` (Stempelzeit), `recordedAt` (Speicherzeit, immutable),
`source` (`web`|`app`|`kiosk`|`nfc`|`qr`|`manual`), `location` (nullable),
`createdBy` (User), `note`, `status` (`active`|`corrected`|`voided`),
`correctsEntry` (self-ref, nullable), `reason` (bei Korrektur/Storno).
**`KioskDevice`** — registriertes Standort-Terminal: `name`, `token`, `location`,
`status`, `lastSeenAt`.
**`Employee`-Ergänzung:** `clockPin` (optional, für Kiosk/App-Identifikation).
**Korrekturprinzip:** Originale bleiben erhalten; eine Korrektur erzeugt einen
**neuen** `TimeEntry` (`type=correction`-Bezug via `correctsEntry`) mit Grund +
Bearbeiter, der alte wird als `corrected` markiert. Der „gültige" Stundenzettel
wird aus den `active`-Einträgen berechnet. Jede Markierung ist protokolliert →
nachvollziehbar/manipulationssicher.
### Endpunkte (grob)
- `POST /api/time-clock/punch` — eingeloggter Mitarbeiter stempelt (Typ ermittelt
oder explizit), Quelle web/app.
- `POST /api/kiosk/punch` — Geräte-Token + Mitarbeiter-Kennung (NFC-`shortCode`/PIN).
- `GET /api/time-entries` — mandantengescoped (Mitarbeiter: eigene; Firmen-Admin: alle).
- `GET /api/timesheets?employee=&period=` — Aggregation (Tag/Woche, Überstunden, je Standort).
- `POST /api/time-entries/{id}/correct` — Firmen-Admin, hängt Korrektur an (Audit).
### Rollen
- **Mitarbeiter**: stempelt, sieht eigene Zeiten (Transparenzpflicht).
- **Firmen-Admin**: Stundenzettel, Korrekturen (mit Grund/Audit), Export, Kiosk-Geräte.
- **Reseller/Plattform**: white-label Einstellungen, Limits.
### Compliance (bewusst zu behandeln)
- **Revisionssicherheit**: append-only + protokollierte Korrekturen (s. o.).
- **ArbZG**: Pausen/Höchstarbeits-/Ruhezeiten erfassbar bzw. warnend.
- **DSGVO + BetrVG §87** (Mitbestimmung): kein heimliches Tracking, Einsicht für
Mitarbeiter, Standort EU, Lösch-/Exportkonzept.
### Auswertung & Export
Stundenzettel (Tag/Woche/Monat), Überstunden, Pausensummen je Mitarbeiter/Standort;
Export CSV/PDF — später Lohn-Schnittstelle (z. B. DATEV).
### MVP-Abgrenzung
**Drin:** Kommen/Gehen/Pause über Web/App/Kiosk (NFC/PIN), Stundenzettel,
revisionssichere Korrektur, CSV/PDF-Export. **Draußen (Folgeschritte):** Urlaub/
Krankmeldung, Schicht-/Dienstplanung, Projektzeiten, DATEV-Export, Geofencing.