Eine echte Proxmox-Rechnung zeigt je Subscription-Key eine Zeile mit Listenpreis und eine mit "Client Discount". Unser Modell hing den Rabatt an die Rechnung - bei einer Sammelrechnung ueber zwei Hosts liesse er sich damit keinem Host zuordnen, und die Marge je Host waere falsch. Schlimmer: Er wurde nirgends abgezogen. Jetzt haengt der Rabatt an der Position, der Rechnungsbetrag ist die Summe nach Abzug, und Marge wie Summen rechnen mit dem tatsaechlich gezahlten Betrag. Die Migration verteilt vorhandene Rabatte anteilig auf die Positionen und rechnet die Summen neu - nichts geht verloren. Dazu ein eigener Steuersatz fuer den Einkauf mit Vorgabe 0: Proxmox rechnet gegenueber Unternehmen mit USt-IdNr. ueber Reverse Charge ab. Neue zwoelfte Warnregel aus Florians Ablauf: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen - der Moment, an dem man selbst zahlt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
527 lines
15 KiB
Plaintext
527 lines
15 KiB
Plaintext
// ProxLedger – Datenmodell
|
||
// Entwurfsentscheidungen siehe docs/architektur.md, Abschnitt 3.
|
||
// Kurz: Ein Host wird über seine agentId identifiziert (nicht über die Server-ID, die sich bei
|
||
// Hardwaretausch ändert), Rechnungen sind eigene Entitäten mit Positionen, damit Sammelbestellungen
|
||
// bei Proxmox und Sammelrechnungen an Kunden ohne doppelte Rechnungsnummern abbildbar sind.
|
||
|
||
generator client {
|
||
provider = "prisma-client"
|
||
output = "../src/generated/prisma"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Aufzählungen
|
||
|
||
enum Produkt {
|
||
PVE
|
||
PBS
|
||
PMG
|
||
}
|
||
|
||
enum SubscriptionStatus {
|
||
ACTIVE
|
||
NOTFOUND
|
||
INVALID
|
||
EXPIRED
|
||
SUSPENDED
|
||
UNKNOWN
|
||
}
|
||
|
||
enum LaufzeitStatus {
|
||
VORSCHLAG
|
||
BESTAETIGT
|
||
}
|
||
|
||
enum AlertSchwere {
|
||
INFO
|
||
WARNUNG
|
||
GEFAHR
|
||
}
|
||
|
||
enum NotificationKanal {
|
||
EMAIL
|
||
WEBHOOK
|
||
}
|
||
|
||
enum NotificationStatus {
|
||
GESENDET
|
||
FEHLGESCHLAGEN
|
||
WARTEND
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Kunden und Standorte
|
||
|
||
model Kunde {
|
||
id String @id @default(cuid())
|
||
name String
|
||
kundennummer String @unique
|
||
ansprechpartner String?
|
||
email String?
|
||
telefon String?
|
||
notizen String?
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
standorte Standort[]
|
||
hosts Host[]
|
||
kundenrechnungen Kundenrechnung[]
|
||
enrollmentTokens EnrollmentToken[]
|
||
alerts Alert[]
|
||
|
||
@@index([name])
|
||
benutzer Benutzer[]
|
||
}
|
||
|
||
model Standort {
|
||
id String @id @default(cuid())
|
||
kundeId String
|
||
name String
|
||
strasse String?
|
||
plz String?
|
||
ort String?
|
||
notizen String?
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade)
|
||
hosts Host[]
|
||
enrollmentTokens EnrollmentToken[]
|
||
|
||
@@index([kundeId])
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Hosts
|
||
|
||
model Host {
|
||
id String @id @default(cuid())
|
||
|
||
/// Stabile Kennung, die der Agent bei der Registrierung erzeugt (aus /etc/machine-id abgeleitet).
|
||
/// Sie bleibt auch dann gleich, wenn sich die Proxmox-Server-ID durch Hardwaretausch ändert.
|
||
agentId String @unique
|
||
|
||
hostname String
|
||
fqdn String?
|
||
kundeId String?
|
||
standortId String?
|
||
produkt Produkt
|
||
produktVersion String?
|
||
kernel String?
|
||
|
||
/// Aktuelle Server-ID laut letztem Report; die Historie steht in ServerIdHistorie.
|
||
serverId String?
|
||
clusterName String?
|
||
nodeName String?
|
||
/// Namen aller Knoten des Clusters laut corosync – auch solche ohne eigenen Agent (#022).
|
||
clusterMitglieder String[] @default([])
|
||
ips String[] @default([])
|
||
sockets Int?
|
||
cpuModell String?
|
||
uptimeSekunden BigInt?
|
||
paketUpdates Int?
|
||
agentVersion String?
|
||
notizen String?
|
||
|
||
// Momentaufnahme der Subscription aus dem letzten Report (für Listen und Filter).
|
||
subKey String?
|
||
subStatus SubscriptionStatus @default(UNKNOWN)
|
||
subLevel String?
|
||
subProduktname String?
|
||
subLizenzierteSockets Int?
|
||
subRegdate DateTime? @db.Date
|
||
subNextduedate DateTime? @db.Date
|
||
subChecktime DateTime?
|
||
|
||
erstmalsGesehenAm DateTime @default(now())
|
||
letzterReportAm DateTime?
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
|
||
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
|
||
reports Report[]
|
||
keyHistorie SubscriptionKeyHistorie[]
|
||
serverIdHistorie ServerIdHistorie[]
|
||
laufzeiten Laufzeit[]
|
||
tokens AgentToken[]
|
||
alerts Alert[]
|
||
tags Tag[] @relation("HostTags")
|
||
|
||
@@index([kundeId])
|
||
@@index([standortId])
|
||
@@index([hostname])
|
||
@@index([serverId])
|
||
@@index([subKey])
|
||
@@index([subNextduedate])
|
||
@@index([subStatus])
|
||
}
|
||
|
||
model Tag {
|
||
id String @id @default(cuid())
|
||
name String @unique
|
||
farbe String?
|
||
|
||
hosts Host[] @relation("HostTags")
|
||
}
|
||
|
||
model Report {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
|
||
/// Vom Agent erzeugte UUID; verhindert doppelte Verarbeitung nachgesendeter Reports.
|
||
reportId String @unique
|
||
|
||
/// Zeitpunkt der Datenerhebung auf dem Host (kann bei nachgesendeten Reports älter sein).
|
||
erhobenAm DateTime
|
||
empfangenAm DateTime @default(now())
|
||
|
||
subStatus SubscriptionStatus
|
||
subKey String?
|
||
subNextduedate DateTime? @db.Date
|
||
serverId String?
|
||
hostname String?
|
||
produktVersion String?
|
||
kernel String?
|
||
sockets Int?
|
||
paketUpdates Int?
|
||
agentVersion String?
|
||
ips String[] @default([])
|
||
quellIp String?
|
||
|
||
/// Vollständiger Report, damit auch künftige Felder nachträglich auswertbar sind.
|
||
rohdaten Json
|
||
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([hostId, erhobenAm])
|
||
@@index([empfangenAm])
|
||
}
|
||
|
||
model SubscriptionKeyHistorie {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
key String
|
||
ersteMeldung DateTime @default(now())
|
||
letzteMeldung DateTime @default(now())
|
||
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([hostId, key])
|
||
@@index([key])
|
||
}
|
||
|
||
model ServerIdHistorie {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
serverId String
|
||
ersteMeldung DateTime @default(now())
|
||
letzteMeldung DateTime @default(now())
|
||
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([hostId, serverId])
|
||
@@index([serverId])
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Laufzeiten und Rechnungen
|
||
|
||
model Laufzeit {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
key String
|
||
start DateTime @db.Date
|
||
ende DateTime @db.Date
|
||
status LaufzeitStatus @default(VORSCHLAG)
|
||
notizen String?
|
||
|
||
/// Position der Proxmox-Rechnung (Einkauf), die diese Laufzeit bezahlt.
|
||
proxmoxPositionId String? @unique
|
||
|
||
/// Position der Kundenrechnung (Verkauf), mit der die Laufzeit weiterberechnet wird.
|
||
kundenPositionId String? @unique
|
||
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
proxmoxPosition ProxmoxRechnungPosition? @relation(fields: [proxmoxPositionId], references: [id], onDelete: SetNull)
|
||
kundenPosition KundenrechnungPosition? @relation(fields: [kundenPositionId], references: [id], onDelete: SetNull)
|
||
alerts Alert[]
|
||
|
||
@@unique([hostId, start])
|
||
@@index([ende])
|
||
@@index([key])
|
||
}
|
||
|
||
/// Rechnung von Proxmox (Einkauf). Eine Bestellung kann mehrere Keys umfassen.
|
||
model ProxmoxRechnung {
|
||
id String @id @default(cuid())
|
||
nummer String @unique
|
||
datum DateTime @db.Date
|
||
faelligAm DateTime? @db.Date
|
||
/// Summe der Positionen nach Abzug ihrer Rabatte – wird beim Speichern nachgeführt.
|
||
nettoBetrag Decimal @db.Decimal(12, 2)
|
||
/// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
|
||
ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
|
||
zahlungsart String?
|
||
bezahlt Boolean @default(false)
|
||
bezahltAm DateTime? @db.Date
|
||
notizen String?
|
||
anhangId String?
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
anhang Anhang? @relation(fields: [anhangId], references: [id], onDelete: SetNull)
|
||
positionen ProxmoxRechnungPosition[]
|
||
|
||
@@index([bezahlt, faelligAm])
|
||
@@index([datum])
|
||
}
|
||
|
||
model ProxmoxRechnungPosition {
|
||
id String @id @default(cuid())
|
||
rechnungId String
|
||
bezeichnung String
|
||
menge Int @default(1)
|
||
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
|
||
nettoBetrag Decimal @db.Decimal(12, 2)
|
||
/// Rabatt dieser Position. Proxmox weist ihn je Key als eigene Zeile aus (#037).
|
||
rabatt Decimal? @db.Decimal(12, 2)
|
||
|
||
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
||
laufzeit Laufzeit?
|
||
|
||
@@index([rechnungId])
|
||
}
|
||
|
||
/// Rechnung an den Kunden (Verkauf). `erstellt = false` bedeutet: steht noch aus.
|
||
model Kundenrechnung {
|
||
id String @id @default(cuid())
|
||
kundeId String
|
||
erstellt Boolean @default(false)
|
||
nummer String? @unique
|
||
datum DateTime? @db.Date
|
||
nettoBetrag Decimal? @db.Decimal(12, 2)
|
||
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
|
||
bezahlt Boolean @default(false)
|
||
bezahltAm DateTime? @db.Date
|
||
notizen String?
|
||
anhangId String?
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade)
|
||
anhang Anhang? @relation(fields: [anhangId], references: [id], onDelete: SetNull)
|
||
positionen KundenrechnungPosition[]
|
||
|
||
@@index([kundeId])
|
||
@@index([erstellt, bezahlt])
|
||
}
|
||
|
||
model KundenrechnungPosition {
|
||
id String @id @default(cuid())
|
||
rechnungId String
|
||
bezeichnung String
|
||
menge Int @default(1)
|
||
nettoBetrag Decimal @db.Decimal(12, 2)
|
||
|
||
rechnung Kundenrechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
||
laufzeit Laufzeit?
|
||
|
||
@@index([rechnungId])
|
||
}
|
||
|
||
model Anhang {
|
||
id String @id @default(cuid())
|
||
dateiname String
|
||
pfad String
|
||
mimeTyp String
|
||
groesse Int
|
||
sha256 String
|
||
hochgeladenAm DateTime @default(now())
|
||
|
||
proxmoxRechnungen ProxmoxRechnung[]
|
||
kundenrechnungen Kundenrechnung[]
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Tokens
|
||
|
||
/// Host-spezifischer API-Token. In der Datenbank liegt nur der SHA-256-Hash.
|
||
model AgentToken {
|
||
id String @id @default(cuid())
|
||
hostId String
|
||
tokenHash String @unique
|
||
praefix String
|
||
bezeichnung String?
|
||
erstelltAm DateTime @default(now())
|
||
letzteNutzungAm DateTime?
|
||
widerrufenAm DateTime?
|
||
|
||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([hostId])
|
||
}
|
||
|
||
/// Token für die Erstregistrierung, optional mit Kunden- und Standortzuordnung.
|
||
model EnrollmentToken {
|
||
id String @id @default(cuid())
|
||
tokenHash String @unique
|
||
praefix String
|
||
bezeichnung String?
|
||
gueltigBis DateTime?
|
||
maxNutzungen Int?
|
||
nutzungen Int @default(0)
|
||
kundeId String?
|
||
standortId String?
|
||
erstelltAm DateTime @default(now())
|
||
widerrufenAm DateTime?
|
||
|
||
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
|
||
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Warnungen und Benachrichtigungen
|
||
|
||
model Alert {
|
||
id String @id @default(cuid())
|
||
regel String
|
||
|
||
/// Regel + betroffenes Objekt (+ Schwelle). Verhindert doppelte offene Warnungen.
|
||
fingerprint String @unique
|
||
|
||
schwere AlertSchwere
|
||
titel String
|
||
beschreibung String
|
||
|
||
hostId String?
|
||
kundeId String?
|
||
laufzeitId String?
|
||
|
||
geoeffnetAm DateTime @default(now())
|
||
quittiertAm DateTime?
|
||
erledigtAm DateTime?
|
||
|
||
host Host? @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Cascade)
|
||
laufzeit Laufzeit? @relation(fields: [laufzeitId], references: [id], onDelete: Cascade)
|
||
benachrichtigungen Benachrichtigung[]
|
||
|
||
@@index([erledigtAm])
|
||
@@index([hostId])
|
||
@@index([kundeId])
|
||
@@index([regel])
|
||
}
|
||
|
||
model Benachrichtigung {
|
||
id String @id @default(cuid())
|
||
alertId String?
|
||
kanal NotificationKanal
|
||
empfaenger String
|
||
betreff String?
|
||
status NotificationStatus @default(WARTEND)
|
||
fehler String?
|
||
versendetAm DateTime?
|
||
erstelltAm DateTime @default(now())
|
||
|
||
alert Alert? @relation(fields: [alertId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([alertId])
|
||
@@index([erstelltAm])
|
||
}
|
||
|
||
/// Schwellwerte je Warnregel, z. B. { "tage": [30, 14, 7] }.
|
||
model WarnregelEinstellung {
|
||
id String @id @default(cuid())
|
||
regel String @unique
|
||
aktiv Boolean @default(true)
|
||
schwellwerte Json @default("{}")
|
||
geaendertAm DateTime @updatedAt
|
||
}
|
||
|
||
model Benachrichtigungskanal {
|
||
id String @id @default(cuid())
|
||
typ NotificationKanal
|
||
name String
|
||
aktiv Boolean @default(true)
|
||
konfiguration Json
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Benutzer und Protokoll
|
||
|
||
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
|
||
/// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027).
|
||
enum Rolle {
|
||
ADMIN
|
||
TECHNIKER
|
||
KUNDE
|
||
}
|
||
|
||
model Benutzer {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
name String
|
||
passwortHash String?
|
||
oidcSubject String? @unique
|
||
/// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN.
|
||
rolle Rolle @default(ADMIN)
|
||
/// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist.
|
||
kundeId String?
|
||
aktiv Boolean @default(true)
|
||
letzterLoginAm DateTime?
|
||
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
|
||
sitzungenUngueltigAb DateTime?
|
||
erstelltAm DateTime @default(now())
|
||
geaendertAm DateTime @updatedAt
|
||
|
||
auditEintraege AuditEintrag[]
|
||
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
|
||
passkeys Passkey[]
|
||
|
||
@@index([kundeId])
|
||
}
|
||
|
||
/// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028).
|
||
model Passkey {
|
||
id String @id @default(cuid())
|
||
benutzerId String
|
||
/// Kennung des Schlüssels, base64url wie vom Browser geliefert.
|
||
credentialId String @unique
|
||
publicKey Bytes
|
||
zaehler BigInt @default(0)
|
||
transports String[] @default([])
|
||
bezeichnung String
|
||
erstelltAm DateTime @default(now())
|
||
zuletztAm DateTime?
|
||
|
||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([benutzerId])
|
||
}
|
||
|
||
model AuditEintrag {
|
||
id String @id @default(cuid())
|
||
benutzerId String?
|
||
aktion String
|
||
entitaet String
|
||
entitaetId String?
|
||
aenderung Json?
|
||
ipAdresse String?
|
||
erstelltAm DateTime @default(now())
|
||
|
||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([entitaet, entitaetId])
|
||
@@index([erstelltAm])
|
||
}
|
||
|
||
/// Freie Systemeinstellungen (z. B. SMTP-Konfiguration, Anzeigeoptionen).
|
||
model Einstellung {
|
||
schluessel String @id
|
||
wert Json
|
||
geaendertAm DateTime @updatedAt
|
||
}
|