Migration 20260915120000_planung (nur Spalten): Team.dailyCapacityMinutes/workingDays, OrderType.defaultDurationMinutes (+ Backfill Standardarten), WorkOrder.plannedDurationMinutes, Site.geocodedAt/geocodeStatus/geocodeQuery. Client-sichere Regeln: Haversine, Tage/Werktage, Dauer, Kolonnenkapazität, Konflikte inkl. Hinweis crew_incomplete, Vereinigung von Arbeitssegmenten, Fahrzeitschätzung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1320 lines
48 KiB
Plaintext
1320 lines
48 KiB
Plaintext
// Craftvia — Prisma-Schema (Fundament)
|
||
//
|
||
// Enthält ausschließlich die Plattform-Grundlage: Mandanten, Identitäten/Mitgliedschaften,
|
||
// RBAC, Audit-Log, Mail, Auth-Tokens, Backup/DSGVO. Die Craftvia-Fachmodelle (Kunden,
|
||
// Objekte, Aufträge, …) liefert die Architektur separat (docs/craftvia/ARCHITEKTUR.md).
|
||
//
|
||
// Multi-Tenant-Regel: Jede fachliche Tabelle trägt tenantId. Zugriff nur über den
|
||
// zentralen Tenant-Guard (src/server/db.ts); zusätzlich Postgres RLS.
|
||
// Neue Tenant-Tabelle ⇒ in der Migration `SELECT enable_tenant_rls('<table>');` aufrufen
|
||
// UND das Modell in beide TENANT_MODELS-Listen (src/server/db.ts, src/server/backup/topology.ts)
|
||
// eintragen — siehe docs/craftvia/MIGRATIONS.md.
|
||
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
previewFeatures = ["postgresqlExtensions"]
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
extensions = [pgvector(map: "vector")]
|
||
}
|
||
|
||
enum TenantStatus {
|
||
ACTIVE
|
||
SUSPENDED
|
||
ARCHIVED
|
||
}
|
||
|
||
enum UserStatus {
|
||
ACTIVE
|
||
INVITED
|
||
LOCKED
|
||
DEACTIVATED
|
||
}
|
||
|
||
model Tenant {
|
||
id String @id @default(cuid())
|
||
name String
|
||
slug String @unique
|
||
short String?
|
||
sector String?
|
||
status TenantStatus @default(ACTIVE)
|
||
config Json @default("{}")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
users User[]
|
||
roles Role[]
|
||
auditLogs AuditLog[]
|
||
settings TenantSettings?
|
||
modules TenantModule[]
|
||
|
||
@@map("tenants")
|
||
}
|
||
|
||
// Mandanten-Einstellungen: Unternehmensdaten, Branding, Sicherheits-Policy.
|
||
model TenantSettings {
|
||
id String @id @default(cuid())
|
||
tenantId String @unique @map("tenant_id")
|
||
orgName String @map("org_name")
|
||
orgShort String? @map("org_short")
|
||
address String?
|
||
phone String?
|
||
email String?
|
||
sector String?
|
||
// Platzhalter für das Mandanten-Logo im Objektspeicher (Upload folgt).
|
||
logoKey String? @map("logo_key")
|
||
accent String?
|
||
locale String @default("de")
|
||
timezone String @default("Europe/Berlin")
|
||
securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session
|
||
smtp Json @default("{}")
|
||
// Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM).
|
||
mailFromName String? @map("mail_from_name")
|
||
mailReplyTo String? @map("mail_reply_to")
|
||
emergencyRecipients String[] @default([]) @map("emergency_recipients")
|
||
billingRecipients String[] @default([]) @map("billing_recipients")
|
||
// Lotse (L9): "sie" | "du"; null = neutral without pronouns (Brandbook §9.2)
|
||
lotseAddressForm String? @map("lotse_address_form")
|
||
// L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited
|
||
aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([tenantId])
|
||
@@map("tenant_settings")
|
||
}
|
||
|
||
// Modul-Freischaltung je Mandant (Feature-Toggle) — gated Navigation, API und Daten
|
||
model TenantModule {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
moduleKey String @map("module_key")
|
||
enabled Boolean @default(true)
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([tenantId, moduleKey])
|
||
@@index([tenantId])
|
||
@@map("tenant_modules")
|
||
}
|
||
|
||
// Zentrale, GLOBALE Anmelde-Identität einer Person. E-Mail/Passwort/MFA leben hier;
|
||
// die per-Mandant-Zeile `User` ist die "Mitgliedschaft" und verweist per identityId hierher.
|
||
// - Identity ist GLOBAL: KEIN tenant_id, NICHT in TENANT_MODELS, KEINE RLS-Policy.
|
||
// Lookup läuft über den Owner-`prisma`-Client (Login).
|
||
model Identity {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
passwordHash String @map("password_hash")
|
||
// Erzwungener Passwortwechsel beim nächsten Login (Initial-/Reset-Passwort).
|
||
mustChangePassword Boolean @default(false) @map("must_change_password")
|
||
// Optionale TOTP-MFA der Identity. recoveryCodes = Argon2id-Hashes (F-17).
|
||
mfaSecret String? @map("mfa_secret")
|
||
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
||
recoveryCodes Json @default("[]") @map("recovery_codes")
|
||
// F-17: zuletzt akzeptierter TOTP-Zeitschritt (RFC 6238) — Replay-Schutz.
|
||
lastTotpStep BigInt? @map("last_totp_step")
|
||
// Brute-Force-Schutz Login (F-05): Fehlversuchszähler + Sperre bis.
|
||
failedLogins Int @default(0) @map("failed_logins")
|
||
lockedUntil DateTime? @map("locked_until")
|
||
// SEC2: globaler Session-Kill-Switch. Ein Token, dessen `iat` davor liegt, gilt als ungültig.
|
||
sessionsValidAfter DateTime? @map("sessions_valid_after")
|
||
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
|
||
// Persönliche UI-Sprache der Person (folgt der Identity über alle Mandanten).
|
||
uiLocale String @default("de") @map("ui_locale") // de | en
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
memberships User[]
|
||
// Passkeys sind identitäts-, nicht mandantengebunden.
|
||
webauthnCredentials WebAuthnCredential[]
|
||
|
||
@@map("identities")
|
||
}
|
||
|
||
// Mitgliedschaft einer Identity in einem Mandanten.
|
||
model User {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
identityId String @map("identity_id")
|
||
email String // Denormalisierung (Anzeige/Filter, Upsert-Key tenantId_email)
|
||
name String
|
||
status UserStatus @default(ACTIVE)
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||
identity Identity @relation(fields: [identityId], references: [id])
|
||
userRoles UserRole[]
|
||
|
||
teamsLed Team[] @relation("TeamLeader")
|
||
teamMemberships TeamMember[] @relation("TeamMemberships")
|
||
workOrdersLed WorkOrder[] @relation("WorkOrderTeamLead")
|
||
workOrderAssignments WorkOrderAssignee[] @relation("WorkOrderAssignments")
|
||
workSessions WorkSession[] @relation("WorkSessionUser")
|
||
notifications Notification[] @relation("UserNotifications")
|
||
|
||
@@unique([tenantId, email])
|
||
// Eine Person (Identity) hat je Mandant höchstens EINE Mitgliedschaft.
|
||
@@unique([tenantId, identityId])
|
||
@@index([tenantId])
|
||
@@index([identityId])
|
||
@@map("users")
|
||
}
|
||
|
||
// WebAuthn/Passkey-Credential einer GLOBALEN Identity (2. Faktor, Alternative zu TOTP).
|
||
// KEIN tenant_id, NICHT in TENANT_MODELS, keine RLS. credentialId/publicKey base64url.
|
||
model WebAuthnCredential {
|
||
id String @id @default(cuid())
|
||
identityId String @map("identity_id")
|
||
credentialId String @unique @map("credential_id")
|
||
publicKey String @map("public_key")
|
||
counter BigInt @default(0)
|
||
transports String[] @default([])
|
||
deviceName String? @map("device_name")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
lastUsedAt DateTime? @map("last_used_at")
|
||
|
||
identity Identity @relation(fields: [identityId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([identityId])
|
||
@@map("webauthn_credentials")
|
||
}
|
||
|
||
model Role {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
key String // z. B. tenant-admin, backoffice, team-lead, technician
|
||
name String
|
||
|
||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||
userRoles UserRole[]
|
||
rolePermissions RolePermission[]
|
||
|
||
@@unique([tenantId, key])
|
||
@@index([tenantId])
|
||
@@map("roles")
|
||
}
|
||
|
||
model Permission {
|
||
id String @id @default(cuid())
|
||
key String @unique // z. B. customer:read, work_order:write
|
||
|
||
rolePermissions RolePermission[]
|
||
|
||
@@map("permissions")
|
||
}
|
||
|
||
model UserRole {
|
||
userId String @map("user_id")
|
||
roleId String @map("role_id")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([userId, roleId])
|
||
@@map("user_roles")
|
||
}
|
||
|
||
model RolePermission {
|
||
roleId String @map("role_id")
|
||
permissionId String @map("permission_id")
|
||
|
||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([roleId, permissionId])
|
||
@@map("role_permissions")
|
||
}
|
||
|
||
// Plattform-Administratoren (Betreiber). Getrennter Store OHNE tenant_id, eigener
|
||
// Login (/platform/login), TOTP-MFA + Recovery-Codes. Kein Zugriff auf Mandanten-Fachdaten.
|
||
model PlatformAdmin {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
passwordHash String @map("password_hash")
|
||
name String
|
||
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
|
||
// SEC4: Plattform-Rolle. "full" = Voll-Admin (darf verwalten), "readonly" = nur lesen.
|
||
role String @default("full") @map("role") // full | readonly
|
||
mfaSecret String? @map("mfa_secret")
|
||
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
||
recoveryCodes Json @default("[]") @map("recovery_codes")
|
||
failedLogins Int @default(0) @map("failed_logins")
|
||
lockedUntil DateTime? @map("locked_until")
|
||
lastTotpStep BigInt? @map("last_totp_step")
|
||
lastLoginAt DateTime? @map("last_login_at")
|
||
sessionsValidAfter DateTime? @map("sessions_valid_after")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@map("platform_admins")
|
||
}
|
||
|
||
// Plattformweite Betriebseinstellungen (Singleton).
|
||
model PlatformSetting {
|
||
id String @id @default("singleton")
|
||
mfaRequired Boolean @default(false) @map("mfa_required")
|
||
|
||
// Backup-Zielspeicher. Präzedenz zur Laufzeit: DB-Config → Env (S3_*/BACKUP_LOCAL_DIR)
|
||
// → lokaler Default `.backups`. Das S3-Secret liegt NUR verschlüsselt (secret-crypto).
|
||
backupTarget String @default("local") @map("backup_target") // local | s3
|
||
backupLocalDir String? @map("backup_local_dir")
|
||
backupS3Endpoint String? @map("backup_s3_endpoint")
|
||
backupS3Bucket String? @map("backup_s3_bucket")
|
||
backupS3Region String? @map("backup_s3_region")
|
||
backupS3AccessKey String? @map("backup_s3_access_key")
|
||
backupS3SecretKeyEnc String? @map("backup_s3_secret_key_enc")
|
||
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@map("platform_settings")
|
||
}
|
||
|
||
model AuditLog {
|
||
id String @id @default(cuid())
|
||
// Nullable: Plattform-Ereignisse (scope=platform) haben keinen Mandantenbezug.
|
||
tenantId String? @map("tenant_id")
|
||
scope String @default("tenant") // tenant | platform
|
||
actorId String? @map("actor_id")
|
||
action String // create | update | delete | login | denied | export | …
|
||
entity String // z. B. user, role, tenant
|
||
entityId String? @map("entity_id")
|
||
before Json?
|
||
after Json?
|
||
ipAddress String? @map("ip_address")
|
||
userAgent String? @map("user_agent")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||
|
||
@@index([tenantId, entity, entityId])
|
||
@@index([tenantId, createdAt])
|
||
@@map("audit_logs")
|
||
}
|
||
|
||
/// Versandprotokoll jeder Mail (Auditierbarkeit + Idempotenz).
|
||
///
|
||
/// `tenantId` ist nullable: Plattform-Mails haben keinen Mandantenbezug (scope=platform).
|
||
/// `dedupeKey` ist plattformweit unique und verhindert Doppelversand.
|
||
/// Bewusst NICHT gespeichert: Mail-Inhalt und alle Token/Secrets.
|
||
model MailLog {
|
||
id String @id @default(cuid())
|
||
tenantId String? @map("tenant_id")
|
||
scope String @default("tenant") // tenant | platform
|
||
to String
|
||
template String
|
||
locale String @default("de")
|
||
status String @default("pending") // pending | sent | failed | bounced | suppressed
|
||
providerMessageId String? @map("provider_message_id")
|
||
error String?
|
||
attempts Int @default(0)
|
||
dedupeKey String? @unique @map("dedupe_key")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
sentAt DateTime? @map("sent_at")
|
||
|
||
@@index([tenantId, createdAt])
|
||
@@index([status])
|
||
@@map("mail_logs")
|
||
}
|
||
|
||
/// Benachrichtigungs-Einstellung je Nutzer und Ereignistyp.
|
||
///
|
||
/// Default ist opt-in: fehlt eine Zeile, gilt `email = true`. Transaktionsmails
|
||
/// (Reset, Passwortwechsel) unterliegen dieser Steuerung NICHT.
|
||
model NotificationPreference {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
userId String @map("user_id")
|
||
eventType String @map("event_type") // Ereignistyp der Fachmodule, z. B. work_order_assigned
|
||
email Boolean @default(true)
|
||
locale String?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@unique([userId, eventType])
|
||
@@index([tenantId])
|
||
@@map("notification_preferences")
|
||
}
|
||
|
||
/// Einmal-Token für Passwort-Reset, Einladung und E-Mail-Änderung.
|
||
///
|
||
/// Das Rohtoken steht ausschließlich im Link; in der DB liegt nur sein SHA-256-Hash.
|
||
/// `principalType` unterscheidet Mandanten-Nutzer und Plattform-Admins. `tenantId` ist
|
||
/// nur bei `tenant_user` gesetzt; Plattform-Zeilen haben `tenantId = null`.
|
||
model AuthToken {
|
||
id String @id @default(cuid())
|
||
principalType String @map("principal_type") // tenant_user | platform_admin
|
||
principalId String @map("principal_id")
|
||
tenantId String? @map("tenant_id")
|
||
type String // password_reset | email_change | invitation
|
||
tokenHash String @unique @map("token_hash")
|
||
/// Nur bei type=email_change: die zu bestätigende neue Adresse.
|
||
newEmail String? @map("new_email")
|
||
expiresAt DateTime @map("expires_at")
|
||
usedAt DateTime? @map("used_at")
|
||
requestIp String? @map("request_ip")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@index([principalType, principalId, type])
|
||
@@index([expiresAt])
|
||
@@map("auth_tokens")
|
||
}
|
||
|
||
// =============================================================================
|
||
// Backup/DSGVO — GLOBALE Modelle (kein tenant_id-RLS)
|
||
// =============================================================================
|
||
|
||
/// Löschnachweis (Art. 17 DSGVO). GLOBAL — überdauert eine Mandanten-Löschung.
|
||
/// Enthält nur Metadaten, KEINE gelöschten Personendaten selbst.
|
||
model DeletionCertificate {
|
||
id String @id @default(cuid())
|
||
/// tenant | person
|
||
scope String
|
||
tenantId String? @map("tenant_id")
|
||
tenantSlug String? @map("tenant_slug")
|
||
subjectIdentityId String? @map("subject_identity_id")
|
||
subjectEmail String? @map("subject_email")
|
||
actorId String? @map("actor_id")
|
||
/// Zeilenzahlen je Modell, die HART gelöscht wurden (JSON: {model: count}).
|
||
deletedCounts Json @default("{}") @map("deleted_counts")
|
||
/// Zeilenzahlen je Modell, die ANONYMISIERT wurden.
|
||
anonymizedCounts Json @default("{}") @map("anonymized_counts")
|
||
snapshotId String? @map("snapshot_id")
|
||
reason String?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@index([tenantId])
|
||
@@index([subjectIdentityId])
|
||
@@map("deletion_certificates")
|
||
}
|
||
|
||
/// Tombstone/Löschliste. GLOBAL — überlebt einen Tenant-Restore bewusst (nicht in
|
||
/// TENANT_MODELS), damit ein alter Snapshot gelöschte PII NICHT zurückbringt.
|
||
model TombstoneEntry {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
/// Prisma-Modellname der betroffenen Tabelle (z. B. "User").
|
||
model String
|
||
targetField String @map("target_field")
|
||
targetValue String @map("target_value")
|
||
/// delete | anonymize
|
||
action String
|
||
anonymizedFields Json @default("{}") @map("anonymized_fields")
|
||
reason String?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@unique([tenantId, model, targetValue])
|
||
@@index([tenantId])
|
||
@@map("tombstone_entries")
|
||
}
|
||
|
||
/// Betreiber-Portal: Status eines Backup-/DSGVO-Jobs. GLOBAL (Plattform-Fähigkeit).
|
||
/// Enthält KEINE PII und KEINE Secrets — nur Ausführungs-Metadaten.
|
||
model BackupJob {
|
||
id String @id @default(cuid())
|
||
/// tenant_restore | tenant_export | dsgvo_export
|
||
kind String
|
||
/// queued | running | done | failed
|
||
status String @default("queued")
|
||
tenantId String @map("tenant_id")
|
||
tenantSlug String? @map("tenant_slug")
|
||
subjectIdentityId String? @map("subject_identity_id")
|
||
actorId String? @map("actor_id")
|
||
snapshotId String? @map("snapshot_id")
|
||
params Json @default("{}")
|
||
result Json?
|
||
error String?
|
||
downloadToken String? @unique @map("download_token")
|
||
downloadExpiresAt DateTime? @map("download_expires_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@index([tenantId])
|
||
@@index([status])
|
||
@@map("backup_jobs")
|
||
}
|
||
|
||
// ============================================================================
|
||
// Craftvia domain model (MVP) — appended to prisma/schema.prisma
|
||
// Conventions: camelCase fields @map snake_case, tables @@map plural snake_case.
|
||
// Every tenant table carries tenantId (plain column, guarded by dbForTenant + RLS)
|
||
// and is listed in BOTH TENANT_MODELS lists (src/server/db.ts, backup/topology.ts).
|
||
// Soft delete: deletedAt on business records (spec §27.5).
|
||
// ============================================================================
|
||
|
||
// ---------- Numbering / configuration ----------
|
||
|
||
model NumberSequence {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
key String // "customer" | "work_order" | "report" | "emergency"
|
||
prefix String @default("")
|
||
nextValue Int @default(1) @map("next_value")
|
||
padding Int @default(5)
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@unique([tenantId, key])
|
||
@@map("number_sequences")
|
||
}
|
||
|
||
model OrderType {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
key String // montage | reparatur | wartung | stoerung | notdienst | besichtigung | abnahme | nacharbeit | custom
|
||
name String
|
||
active Boolean @default(true)
|
||
signatureRequired Boolean @default(true) @map("signature_required")
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
// L13 Planung: default duration for capacity planning when the order has no own duration
|
||
defaultDurationMinutes Int? @map("default_duration_minutes")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
checklistTemplates ChecklistTemplate[]
|
||
workOrders WorkOrder[]
|
||
|
||
@@unique([tenantId, key])
|
||
@@map("order_types")
|
||
}
|
||
|
||
model ChecklistTemplate {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
orderTypeId String? @map("order_type_id")
|
||
name String
|
||
active Boolean @default(true)
|
||
// items: [{ key, label, required, requiresPhoto }]
|
||
items Json @default("[]")
|
||
// requiredPhotos: [{ key, label }]
|
||
requiredPhotos Json @default("[]") @map("required_photos")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
orderType OrderType? @relation(fields: [orderTypeId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([tenantId])
|
||
@@map("checklist_templates")
|
||
}
|
||
|
||
// ---------- Customers / contacts / sites ----------
|
||
|
||
enum CustomerStatus {
|
||
active
|
||
inactive
|
||
provisional // "Vorläufig – Prüfung durch Backoffice erforderlich"
|
||
merged
|
||
}
|
||
|
||
model Customer {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
customerNumber String? @map("customer_number")
|
||
companyName String? @map("company_name")
|
||
salutation String?
|
||
firstName String? @map("first_name")
|
||
lastName String? @map("last_name")
|
||
street String?
|
||
houseNumber String? @map("house_number")
|
||
postalCode String? @map("postal_code")
|
||
city String?
|
||
country String @default("DE")
|
||
phone String?
|
||
mobile String?
|
||
email String?
|
||
notes String?
|
||
billingNotes String? @map("billing_notes")
|
||
status CustomerStatus @default(active)
|
||
isProvisional Boolean @default(false) @map("is_provisional")
|
||
mergedIntoId String? @map("merged_into_id")
|
||
createdById String? @map("created_by_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
contacts Contact[]
|
||
sites Site[]
|
||
workOrders WorkOrder[]
|
||
documents Document[]
|
||
|
||
@@unique([tenantId, customerNumber])
|
||
@@index([tenantId, status])
|
||
@@index([tenantId, companyName])
|
||
@@index([tenantId, lastName])
|
||
@@map("customers")
|
||
}
|
||
|
||
model Contact {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
customerId String @map("customer_id")
|
||
name String
|
||
role String? // Funktion
|
||
phone String?
|
||
mobile String?
|
||
email String?
|
||
preferredChannel String? @map("preferred_channel") // phone | mobile | email
|
||
notes String?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||
sites Site[]
|
||
workOrders WorkOrder[]
|
||
|
||
@@index([tenantId, customerId])
|
||
@@map("contacts")
|
||
}
|
||
|
||
enum SiteStatus {
|
||
active
|
||
inactive
|
||
provisional
|
||
}
|
||
|
||
model Site {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
customerId String @map("customer_id")
|
||
name String
|
||
street String?
|
||
houseNumber String? @map("house_number")
|
||
postalCode String? @map("postal_code")
|
||
city String?
|
||
country String @default("DE")
|
||
contactId String? @map("contact_id")
|
||
onSiteContact String? @map("on_site_contact") // free text if no Contact
|
||
phone String?
|
||
accessNotes String? @map("access_notes")
|
||
parkingNotes String? @map("parking_notes")
|
||
safetyNotes String? @map("safety_notes")
|
||
technicalNotes String? @map("technical_notes")
|
||
status SiteStatus @default(active)
|
||
latitude Float?
|
||
longitude Float?
|
||
// L13 Planung: geocoding cache (services/geo) — status ok | not_found | failed | skipped,
|
||
// geocodeQuery = normalized address the coordinates belong to (change detection)
|
||
geocodedAt DateTime? @map("geocoded_at")
|
||
geocodeStatus String? @map("geocode_status")
|
||
geocodeQuery String? @map("geocode_query")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
|
||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||
workOrders WorkOrder[]
|
||
documents Document[]
|
||
|
||
@@index([tenantId, customerId])
|
||
@@map("sites")
|
||
}
|
||
|
||
// ---------- Teams ----------
|
||
|
||
enum TeamStatus {
|
||
active
|
||
inactive
|
||
}
|
||
|
||
model Team {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
name String
|
||
leaderUserId String? @map("leader_user_id")
|
||
status TeamStatus @default(active)
|
||
phone String?
|
||
vehicle String?
|
||
area String?
|
||
notes String?
|
||
// L13 Planung: a team is a crew (Kolonne) working together — capacity = crew working day in minutes
|
||
// (not person-hours); workingDays = bit mask Mon=1 … Sun=64 (31 = Mo–Fr)
|
||
dailyCapacityMinutes Int @default(480) @map("daily_capacity_minutes")
|
||
workingDays Int @default(31) @map("working_days")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
leader User? @relation("TeamLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
|
||
members TeamMember[]
|
||
workOrders WorkOrder[]
|
||
|
||
@@unique([tenantId, name])
|
||
@@map("teams")
|
||
}
|
||
|
||
model TeamMember {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
teamId String @map("team_id")
|
||
userId String @map("user_id")
|
||
validFrom DateTime @default(now()) @map("valid_from")
|
||
validTo DateTime? @map("valid_to")
|
||
|
||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||
user User @relation("TeamMemberships", fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([tenantId, userId])
|
||
@@index([teamId])
|
||
@@map("team_members")
|
||
}
|
||
|
||
// ---------- Work orders ----------
|
||
|
||
enum WorkOrderStatus {
|
||
draft
|
||
review_required
|
||
planned
|
||
assigned
|
||
accepted
|
||
en_route
|
||
in_progress
|
||
paused
|
||
waiting_material
|
||
daily_report_created
|
||
technically_completed
|
||
signature_pending
|
||
in_review
|
||
released_for_billing
|
||
billed
|
||
cancelled
|
||
}
|
||
|
||
enum WorkOrderPriority {
|
||
low
|
||
normal
|
||
high
|
||
urgent
|
||
}
|
||
|
||
model WorkOrder {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
number String // internal, from NumberSequence "work_order"
|
||
externalOrderNumber String? @map("external_order_number")
|
||
offerNumber String? @map("offer_number")
|
||
customerId String @map("customer_id")
|
||
siteId String? @map("site_id")
|
||
contactId String? @map("contact_id")
|
||
orderTypeId String? @map("order_type_id")
|
||
priority WorkOrderPriority @default(normal)
|
||
status WorkOrderStatus @default(draft)
|
||
title String
|
||
description String?
|
||
scope String? // Leistungsumfang
|
||
plannedStart DateTime? @map("planned_start")
|
||
plannedEnd DateTime? @map("planned_end")
|
||
// L13 Planung: explicit duration; fallback plannedStart/End → OrderType.defaultDurationMinutes → 120
|
||
plannedDurationMinutes Int? @map("planned_duration_minutes")
|
||
assignedTeamId String? @map("assigned_team_id")
|
||
teamLeadUserId String? @map("team_lead_user_id")
|
||
signatureRequired Boolean @default(true) @map("signature_required")
|
||
billingType String? @map("billing_type") // fixed | time_material | maintenance_contract | warranty
|
||
internalNotes String? @map("internal_notes")
|
||
technicianNotes String? @map("technician_notes")
|
||
isEmergency Boolean @default(false) @map("is_emergency")
|
||
emergencyReason String? @map("emergency_reason")
|
||
sourceImportId String? @unique @map("source_import_id")
|
||
followUpWork String? @map("follow_up_work") // offene Folgearbeiten (aggregiert)
|
||
// incremented on every mutation — used by offline sync conflict detection
|
||
version Int @default(1)
|
||
createdById String? @map("created_by_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
|
||
site Site? @relation(fields: [siteId], references: [id], onDelete: SetNull)
|
||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||
orderType OrderType? @relation(fields: [orderTypeId], references: [id], onDelete: SetNull)
|
||
team Team? @relation(fields: [assignedTeamId], references: [id], onDelete: SetNull)
|
||
teamLead User? @relation("WorkOrderTeamLead", fields: [teamLeadUserId], references: [id], onDelete: SetNull)
|
||
sourceImport ImportJob? @relation("ImportCreatedOrder", fields: [sourceImportId], references: [id], onDelete: SetNull)
|
||
|
||
assignees WorkOrderAssignee[]
|
||
statusHistory WorkOrderStatusChange[]
|
||
checklistItems ChecklistItem[]
|
||
photoRequirements PhotoRequirement[]
|
||
materialPlans MaterialPlan[]
|
||
materialUsages MaterialUsage[]
|
||
workSessions WorkSession[]
|
||
notes ActivityNote[]
|
||
photos Photo[]
|
||
voiceNotes VoiceNote[]
|
||
reports Report[]
|
||
documents Document[]
|
||
|
||
@@unique([tenantId, number])
|
||
@@index([tenantId, status])
|
||
@@index([tenantId, assignedTeamId, status])
|
||
@@index([tenantId, plannedStart])
|
||
@@index([tenantId, customerId])
|
||
@@index([tenantId, siteId])
|
||
@@map("work_orders")
|
||
}
|
||
|
||
model WorkOrderAssignee {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
userId String @map("user_id")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
user User @relation("WorkOrderAssignments", fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([workOrderId, userId])
|
||
@@index([tenantId, userId])
|
||
@@map("work_order_assignees")
|
||
}
|
||
|
||
model WorkOrderStatusChange {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
fromStatus WorkOrderStatus? @map("from_status")
|
||
toStatus WorkOrderStatus @map("to_status")
|
||
actorId String? @map("actor_id")
|
||
reason String?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@map("work_order_status_changes")
|
||
}
|
||
|
||
model ChecklistItem {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
key String
|
||
label String
|
||
required Boolean @default(false)
|
||
requiresPhoto Boolean @default(false) @map("requires_photo")
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
checked Boolean @default(false)
|
||
checkedById String? @map("checked_by_id")
|
||
checkedAt DateTime? @map("checked_at")
|
||
comment String?
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
photos Photo[]
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@map("checklist_items")
|
||
}
|
||
|
||
model PhotoRequirement {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
key String // ausgangszustand | typenschild | leitungsverlauf | zwischenschritt | fertige_montage | funktionspruefung | arbeitsbereich_abschluss | custom
|
||
label String
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
photos Photo[]
|
||
|
||
@@unique([workOrderId, key])
|
||
@@index([tenantId])
|
||
@@map("photo_requirements")
|
||
}
|
||
|
||
// ---------- Materials ----------
|
||
|
||
model MaterialPlan {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
name String
|
||
articleNumber String? @map("article_number")
|
||
plannedQuantity Decimal @map("planned_quantity") @db.Decimal(12, 3)
|
||
unit String
|
||
notes String?
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
usages MaterialUsage[]
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@map("material_plans")
|
||
}
|
||
|
||
enum MaterialUsageStatus {
|
||
fully_used
|
||
partially_used
|
||
not_used
|
||
additional
|
||
}
|
||
|
||
model MaterialUsage {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
materialPlanId String? @map("material_plan_id")
|
||
workSessionId String? @map("work_session_id")
|
||
name String
|
||
articleNumber String? @map("article_number")
|
||
actualQuantity Decimal @map("actual_quantity") @db.Decimal(12, 3)
|
||
unit String
|
||
usageStatus MaterialUsageStatus @map("usage_status")
|
||
deviationReason String? @map("deviation_reason")
|
||
notes String?
|
||
photoId String? @map("photo_id")
|
||
recordedById String? @map("recorded_by_id")
|
||
clientId String? @map("client_id") // offline local id
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull)
|
||
workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@unique([tenantId, clientId])
|
||
@@map("material_usages")
|
||
}
|
||
|
||
// ---------- Field execution ----------
|
||
|
||
enum WorkSessionStatus {
|
||
en_route
|
||
running
|
||
paused
|
||
ended
|
||
}
|
||
|
||
model WorkSession {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
userId String @map("user_id")
|
||
teamId String? @map("team_id")
|
||
status WorkSessionStatus @default(running)
|
||
startedAt DateTime @map("started_at")
|
||
endedAt DateTime? @map("ended_at")
|
||
startLat Float? @map("start_lat")
|
||
startLng Float? @map("start_lng")
|
||
startedOffline Boolean @default(false) @map("started_offline")
|
||
deviceInfo String? @map("device_info")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
user User @relation("WorkSessionUser", fields: [userId], references: [id], onDelete: Restrict)
|
||
entries TimeEntry[]
|
||
materials MaterialUsage[]
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@index([tenantId, userId, status])
|
||
@@unique([tenantId, clientId])
|
||
@@map("work_sessions")
|
||
}
|
||
|
||
enum TimeEntryType {
|
||
travel
|
||
work
|
||
break
|
||
material_procurement
|
||
return_travel
|
||
interruption
|
||
}
|
||
|
||
model TimeEntry {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workSessionId String @map("work_session_id")
|
||
userId String @map("user_id")
|
||
type TimeEntryType @default(work)
|
||
startedAt DateTime @map("started_at")
|
||
endedAt DateTime? @map("ended_at")
|
||
// manual corrections must be audited (spec §12.2)
|
||
corrected Boolean @default(false)
|
||
correctionReason String? @map("correction_reason")
|
||
correctedById String? @map("corrected_by_id")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([tenantId, workSessionId])
|
||
@@index([tenantId, userId, startedAt])
|
||
@@unique([tenantId, clientId])
|
||
@@map("time_entries")
|
||
}
|
||
|
||
enum ActivityNoteKind {
|
||
work_done
|
||
deviation
|
||
problem
|
||
additional_work
|
||
not_executable
|
||
follow_up
|
||
recommendation
|
||
customer_note
|
||
general
|
||
}
|
||
|
||
model ActivityNote {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
authorId String? @map("author_id")
|
||
kind ActivityNoteKind @default(general)
|
||
text String
|
||
voiceNoteId String? @unique @map("voice_note_id")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
voiceNote VoiceNote? @relation(fields: [voiceNoteId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@unique([tenantId, clientId])
|
||
@@map("activity_notes")
|
||
}
|
||
|
||
// ---------- Files ----------
|
||
|
||
enum DocumentCategory {
|
||
order_confirmation
|
||
technical_drawing
|
||
floor_plan
|
||
wiring_diagram
|
||
assembly_instructions
|
||
safety_document
|
||
product_document
|
||
customer_note
|
||
work_record
|
||
daily_report
|
||
completion_report
|
||
customer_approval
|
||
photo
|
||
voice_note
|
||
signature
|
||
other
|
||
}
|
||
|
||
enum DocumentVisibility {
|
||
backoffice_only
|
||
team_lead
|
||
team
|
||
customer_report
|
||
}
|
||
|
||
enum UploadStatus {
|
||
pending
|
||
uploaded
|
||
failed
|
||
quarantined
|
||
}
|
||
|
||
model Document {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
customerId String? @map("customer_id")
|
||
siteId String? @map("site_id")
|
||
workOrderId String? @map("work_order_id")
|
||
category DocumentCategory
|
||
title String?
|
||
fileName String @map("file_name")
|
||
storageKey String @map("storage_key") // <tenantId>/...
|
||
previewKey String? @map("preview_key") // thumbnail
|
||
mimeType String @map("mime_type")
|
||
fileSize Int @map("file_size")
|
||
checksum String // sha256 hex
|
||
version Int @default(1)
|
||
// groups versions of the same logical document
|
||
lineageId String @map("lineage_id")
|
||
visibility DocumentVisibility @default(team)
|
||
approvalStatus String? @map("approval_status") // draft | approved
|
||
uploadStatus UploadStatus @default(uploaded) @map("upload_status")
|
||
uploadedById String? @map("uploaded_by_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||
site Site? @relation(fields: [siteId], references: [id], onDelete: SetNull)
|
||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: SetNull)
|
||
|
||
@@unique([lineageId, version])
|
||
@@index([tenantId, workOrderId])
|
||
@@index([tenantId, siteId])
|
||
@@index([tenantId, customerId])
|
||
@@map("documents")
|
||
}
|
||
|
||
enum PhotoPhase {
|
||
before
|
||
during
|
||
after
|
||
}
|
||
|
||
model Photo {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
workSessionId String? @map("work_session_id")
|
||
documentId String @unique @map("document_id")
|
||
checklistItemId String? @map("checklist_item_id")
|
||
photoRequirementId String? @map("photo_requirement_id")
|
||
phase PhotoPhase?
|
||
comment String?
|
||
takenAt DateTime @map("taken_at")
|
||
latitude Float?
|
||
longitude Float?
|
||
takenById String? @map("taken_by_id")
|
||
includeInReport Boolean @default(true) @map("include_in_report")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
checklistItem ChecklistItem? @relation(fields: [checklistItemId], references: [id], onDelete: SetNull)
|
||
photoRequirement PhotoRequirement? @relation(fields: [photoRequirementId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@unique([tenantId, clientId])
|
||
@@map("photos")
|
||
}
|
||
|
||
enum TranscriptionStatus {
|
||
pending
|
||
done
|
||
failed
|
||
disabled
|
||
}
|
||
|
||
model VoiceNote {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
workSessionId String? @map("work_session_id")
|
||
documentId String @unique @map("document_id")
|
||
durationSeconds Int? @map("duration_seconds")
|
||
transcript String?
|
||
transcriptionStatus TranscriptionStatus @default(pending) @map("transcription_status")
|
||
transcriptionModel String? @map("transcription_model")
|
||
recordedById String? @map("recorded_by_id")
|
||
recordedAt DateTime @map("recorded_at")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
activityNote ActivityNote?
|
||
|
||
@@index([tenantId, workOrderId])
|
||
@@unique([tenantId, clientId])
|
||
@@map("voice_notes")
|
||
}
|
||
|
||
// ---------- Reports & signatures ----------
|
||
|
||
enum ReportType {
|
||
daily
|
||
completion
|
||
}
|
||
|
||
enum ReportStatus {
|
||
draft // incl. Lotse-Entwurf
|
||
submitted // by technician, "Zur Prüfung"
|
||
team_approved
|
||
approved // final → immutable PDF
|
||
rejected // correction requested
|
||
superseded
|
||
}
|
||
|
||
model Report {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
workOrderId String @map("work_order_id")
|
||
type ReportType
|
||
reportDate DateTime @map("report_date") @db.Date
|
||
version Int @default(1)
|
||
// groups versions of the same report
|
||
lineageId String @map("lineage_id")
|
||
status ReportStatus @default(draft)
|
||
// structured snapshot (see src/lib/reports/content.ts ReportContent)
|
||
content Json
|
||
aiDrafted Boolean @default(false) @map("ai_drafted")
|
||
aiGenerationId String? @map("ai_generation_id")
|
||
pdfDocumentId String? @unique @map("pdf_document_id")
|
||
pdfChecksum String? @map("pdf_checksum")
|
||
createdById String? @map("created_by_id")
|
||
submittedAt DateTime? @map("submitted_at")
|
||
teamApprovedById String? @map("team_approved_by_id")
|
||
teamApprovedAt DateTime? @map("team_approved_at")
|
||
approvedById String? @map("approved_by_id")
|
||
approvedAt DateTime? @map("approved_at")
|
||
rejectionReason String? @map("rejection_reason")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Restrict)
|
||
signature Signature?
|
||
|
||
@@unique([lineageId, version])
|
||
@@index([tenantId, workOrderId])
|
||
@@index([tenantId, status])
|
||
@@unique([tenantId, clientId])
|
||
@@map("reports")
|
||
}
|
||
|
||
enum SignatureOutcome {
|
||
signed
|
||
customer_absent
|
||
refused
|
||
later
|
||
not_required
|
||
}
|
||
|
||
model Signature {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
reportId String @unique @map("report_id")
|
||
outcome SignatureOutcome
|
||
signerName String? @map("signer_name")
|
||
signerRole String? @map("signer_role")
|
||
imageDocumentId String? @unique @map("image_document_id") // PNG of the drawn signature
|
||
confirmationText String? @map("confirmation_text")
|
||
reason String? // required for absent/refused/later
|
||
signedAt DateTime @map("signed_at")
|
||
capturedById String? @map("captured_by_id")
|
||
clientId String? @map("client_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([tenantId])
|
||
@@unique([tenantId, clientId])
|
||
@@map("signatures")
|
||
}
|
||
|
||
// ---------- PDF import ----------
|
||
|
||
enum ImportStatus {
|
||
uploaded
|
||
processing
|
||
review_required // "Entwurf – Prüfung erforderlich"
|
||
confirmed
|
||
failed
|
||
discarded
|
||
}
|
||
|
||
model ImportJob {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
documentId String @unique @map("document_id") // original file (kept forever)
|
||
status ImportStatus @default(uploaded)
|
||
errorMessage String? @map("error_message")
|
||
extractedText String? @map("extracted_text")
|
||
// [{ field, value, confidence }] + positions — see src/lib/imports/extraction.ts
|
||
extraction Json?
|
||
extractionVersion String? @map("extraction_version")
|
||
extractionModel String? @map("extraction_model")
|
||
provider String?
|
||
// user corrections: { field: { from, to } }
|
||
corrections Json?
|
||
// duplicate candidates: [{ customerId, score, reasons[] }]
|
||
duplicateCandidates Json? @map("duplicate_candidates")
|
||
importedById String? @map("imported_by_id")
|
||
confirmedById String? @map("confirmed_by_id")
|
||
confirmedAt DateTime? @map("confirmed_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
createdWorkOrder WorkOrder? @relation("ImportCreatedOrder")
|
||
|
||
@@index([tenantId, status])
|
||
@@map("import_jobs")
|
||
}
|
||
|
||
// ---------- Notifications ----------
|
||
|
||
model Notification {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
userId String @map("user_id")
|
||
type String // see src/lib/events.ts EVENT_TYPES
|
||
title String
|
||
message String
|
||
entityType String? @map("entity_type")
|
||
entityId String? @map("entity_id")
|
||
link String?
|
||
readAt DateTime? @map("read_at")
|
||
emailedAt DateTime? @map("emailed_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([tenantId, userId, readAt])
|
||
@@map("notifications")
|
||
}
|
||
|
||
// ---------- Offline sync ----------
|
||
|
||
enum SyncOpStatus {
|
||
applied
|
||
conflict
|
||
rejected
|
||
}
|
||
|
||
model SyncOperation {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
userId String @map("user_id")
|
||
clientOpId String @map("client_op_id") // idempotency key from device
|
||
opType String @map("op_type")
|
||
entityType String? @map("entity_type")
|
||
entityId String? @map("entity_id")
|
||
baseVersion Int? @map("base_version")
|
||
payload Json
|
||
status SyncOpStatus
|
||
result Json?
|
||
errorCode String? @map("error_code")
|
||
clientCreatedAt DateTime @map("client_created_at")
|
||
receivedAt DateTime @default(now()) @map("received_at")
|
||
resolvedById String? @map("resolved_by_id")
|
||
resolvedAt DateTime? @map("resolved_at")
|
||
|
||
@@unique([tenantId, clientOpId])
|
||
@@index([tenantId, status])
|
||
@@map("sync_operations")
|
||
}
|
||
|
||
// ---------- Lotse (AI) ----------
|
||
|
||
model AiGeneration {
|
||
id String @id @default(cuid())
|
||
tenantId String @map("tenant_id")
|
||
kind String // report_draft | voice_summary | completeness_check | import_extraction
|
||
provider String
|
||
model String
|
||
entityType String? @map("entity_type")
|
||
entityId String? @map("entity_id")
|
||
input Json?
|
||
output Json?
|
||
inputTokens Int? @map("input_tokens")
|
||
outputTokens Int? @map("output_tokens")
|
||
createdById String? @map("created_by_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@index([tenantId, entityType, entityId])
|
||
@@map("ai_generations")
|
||
}
|