Compare commits

..
19 Commits
Author SHA1 Message Date
florian.wilde 8119aaad66 Merge branch 'phase/045-abhaengigkeiten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-10 19:01:22 +02:00
florian.wildeandClaude Opus 5 ee36f78cc3 fix(deps): sharp und source-map-js wegen hoher Meldungen anheben
Turnusmaessiger Blick auf npm audit: Fuer sharp vor 0.35.5 wurde eine als
hoch eingestufte Luecke in der mitgelieferten librsvg gemeldet
(CVE-2026-96889); source-map-js erlaubt in Fassungen vor 1.2.2 eine
Blockade der Ereignisschleife und kommt ueber postcss und vite mit.

sharp nutzt ProxLedger nur beim Erzeugen der Symbole, nicht zur Laufzeit -
angreifbar war die Installation also nicht. Aktualisiert gehoert es
trotzdem. Dazu Next.js auf 16.4.0.

Tests, Typen, Lint und Build gruen, npm audit ohne Befund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:01:22 +02:00
florian.wildeandClaude Opus 5 38e674de34 docs(roadmap): Reihenfolge nach dem Produktivgang neu ordnen
Prüfung / pruefung (push) Canceled after 0s
Die erste Fassung entstand vor dem Einsatz. Was seitdem aus der Benutzung kam,
war fast immer wichtiger als das, was vorher auf der Liste stand - deshalb
stehen jetzt Rechnungsimport, Verlaengerung mit einem Klick und
Oberflaechentests oben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 08:18:17 +02:00
florian.wilde b745910afa Merge branch 'phase/044-eigenbedarf'
Prüfung / pruefung (push) Canceled after 0s
2026-10-04 06:54:43 +02:00
florian.wildeandClaude Opus 5 840e902b26 feat(kunden): Eigenbedarf fuer eigene Hosts (#044)
Eigene Hosts brauchen einen Kunden - sonst stehen sie unter "Nicht
zugeordnet" und fehlen in jeder Auswertung. Als gewoehnlicher Kunde erzeugen
sie aber Aufgaben, die es nicht gibt: Kundenrechnung erstellen, Kundenrechnung
unbezahlt, dazu eine negative Marge in jeder Summe.

Das Kennzeichen Eigenbedarf am Kunden nimmt die Verkaufsseite heraus: kein
Formular fuer die Kundenrechnung, keine Warnungen darueber, keine Aufgabe,
keine Marge - und in den Summen zaehlt der Einkauf mit, Verkauf und Marge
nicht.

Die Einkaufsseite bleibt vollstaendig: Eigene Hosts kosten echtes Geld, und
die Rechnung von Proxmox will bezahlt werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 06:54:43 +02:00
florian.wilde 5e5028c53d Merge branch 'phase/043-kunden-laufzeiten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 20:57:07 +02:00
florian.wildeandClaude Opus 5 83320f0443 feat(laufzeiten): anlegen, bearbeiten und zusammenfuehren (#043)
Laufzeiten entstanden bisher nur als Vorschlag aus dem Agenten-Report;
bestaetigen oder verwerfen war alles. Anlegen von Hand gab es als Aktion ohne
Oberflaeche, Aendern und Zusammenfuehren gar nicht - obwohl der Doppelfall
zwangslaeufig auftritt, sobald das gemeldete Ablaufdatum von der Bestellung
abweicht.

Jetzt: Formular zum Anlegen auf der Rechnungsseite (Key vorgeschlagen, Ende
aus dem Beginn vorbelegt), Bearbeiten von Key, Zeitraum und Notiz im
Laufzeit-Detail, und Zusammenfuehren zweier Laufzeiten desselben Hosts. Die
aufgeloeste gibt ihre Rechnungen ab, sofern das Ziel dort nichts hat; sonst
bricht der Vorgang mit Erklaerung ab - zwei Einkaufsrechnungen auf einem
Lizenzjahr waeren nicht mehr zuzuordnen.

Dazu die Kundenansicht: Laufzeit und Host verlinkt, Marge und Rabatt je Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 20:57:07 +02:00
florian.wilde f076f49631 Merge branch 'phase/042-qdevice'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 18:11:38 +02:00
florian.wildeandClaude Opus 5 8e5bf74a30 fix(agent): Cluster-Knoten mit QDevice richtig lesen (#042)
Der Agent nahm aus `pvecm nodes` blind die dritte Spalte als Knotennamen.
Mit einem QDevice steht dort dessen Zustand (A,V,NMW oder NR), und der Name
rutscht eine Spalte weiter - gemeldet wurden damit die Zustandskuerzel und
das QDevice als zusaetzlicher vierter Knoten.

Die Namensspalte wird jetzt aus der Kopfzeile bestimmt, die Zeile des QDevice
(Nodeid 0) uebersprungen. Beide Ausgabeformen sind als Testfall hinterlegt.

Agent-Version 0.2.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 18:11:38 +02:00
florian.wilde 02845ed717 Merge branch 'phase/041-doppelte-hosts'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:57:18 +02:00
florian.wildeandClaude Opus 5 3657b60d16 feat(hosts): Doppelte Hosts erkennen und stilllegen (#041)
Zwei Eintraege desselben Namens beim selben Kunden entstehen, wenn sich die
Kennung aus /etc/machine-id aendert - Neuinstallation, geklonter Container,
zurueckgespielte Sicherung. Dieselbe Maschine meldet sich dann als neuer Host,
der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen
sind aber genauso moeglich.

Neue dreizehnte Warnregel meldet den Fall und nennt die IP-Adressen zum
Unterscheiden; gleiche Namen bei verschiedenen Kunden bleiben still.

Dazu die bis dahin fehlende Moeglichkeit, einen Host loszuwerden: Stilllegen
blendet ihn aus Listen, Zahlen, Suche und Warnungen aus und schliesst seine
offenen Warnungen, laesst Laufzeiten und Rechnungen aber unberuehrt.
Endgueltig loeschen geht nur ohne Laufzeiten - Buchhaltung wirft man nicht weg.

Kein automatisches Zusammenfuehren: Es muesste raten, welcher Eintrag welche
Rechnung erbt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:57:18 +02:00
florian.wilde f86a328b3c Merge branch 'phase/040-tags-sichtbar'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:43:55 +02:00
florian.wildeandClaude Opus 5 b2664de62f feat(hosts): Schlagworte und Notizen sichtbar machen (#040)
Gleichnamige Hosts in verschiedenen Kundennetzen liessen sich in der Liste
nicht auseinanderhalten. Schlagworte und Notizen gab es zwar schon, aber nur
im Reiter "Notizen und Tags" - und damit nutzlos zum Unterscheiden.

Jetzt stehen die Schlagworte als anklickbare Abzeichen in der Liste und oben
im Host-Detail, es gibt einen Filter dafuer, eine Notiz zeigt ein
Zettel-Symbol mit dem Text beim Ueberfahren, und beides wird mitdurchsucht -
auch von der globalen Suche.

Bewusst kein zweites Feld "Kommentar": Das haette dieselbe Angabe an zwei
Orten gehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:43:55 +02:00
florian.wilde dfb2118bac Merge branch 'phase/039-laufzeit-zugang'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:08:54 +02:00
florian.wildeandClaude Opus 5 335fc47496 fix(ui): Zugang zur Laufzeit auf dem Telefon (#039)
Im Reiter Rechnungen der Host-Detailansicht war die Laufzeit nie verlinkt,
und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar. Auf dem
Telefon fuehrte damit kein Weg zu den Rechnungsformularen.

Die Laufzeit ist jetzt ein Link und zugleich Ueberschrift der Karte; in der
Liste bleibt sie auf dem Telefon sichtbar und entfaellt nur zwischen 768 und
992 px. Dafuer braucht es eine eigene Klasse: Bootstraps d-*-Hilfsklassen
setzen display mit !important und ueberschreiben das Kartenlayout.

Nebenbefund: Auch dieser Reiter rechnete den Einkauf ohne Rabatt - dritte
Stelle nach #037 und #038.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:08:54 +02:00
florian.wilde e570d30019 Merge branch 'phase/038-klapplisten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:01:43 +02:00
florian.wildeandClaude Opus 5 c5293f5915 fix(ui): Ueberbreite auf dem Handy beseitigen (#038)
Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser
die ganze Seite heraus. Ursache war jeweils ein Element breiter als das
Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive
und eine 32-stellige Server-ID ohne Umbruch.

Klapplisten richten sich auf dem Telefon jetzt am Fenster aus statt am Knopf,
die Tabellen bekommen ihre Huelle, lange IDs duerfen umbrechen. Geprueft mit
einer neuen Aufnahme in Telefonbreite (--mobil).

Dabei ein eigener Fehler aus #037 aufgefallen: Die Laufzeit-Detailseite
rechnete weiter mit dem Listenpreis statt mit dem Betrag nach Rabatt - Marge
und Vorschlag fuer die Kundenrechnung waren um den Rabatt zu hoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:01:43 +02:00
florian.wilde c1177067e6 Merge branch 'phase/037-rabatt-position'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:42:12 +02:00
florian.wildeandClaude Opus 5 e50fb30ab3 feat(rechnungen): Rabatt je Position, Reverse Charge, Ablauf-Regel (#037)
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>
2026-10-03 16:42:12 +02:00
75 changed files with 2668 additions and 413 deletions
+74
View File
@@ -17,6 +17,80 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.26.1] – 2026-10-10
### Sicherheit
- `sharp` auf 0.35.5 angehoben: Für ältere Fassungen wurde eine als hoch eingestufte Lücke in der mitgelieferten librsvg gemeldet (CVE-2026-96889). ProxLedger nutzt `sharp` nur beim Erzeugen der Symbole, nicht zur Laufzeit – die Fassung gehört trotzdem aktualisiert
- `source-map-js` über `overrides` auf 1.2.2 angehoben (Denial of Service, kam über postcss und vite mit)
- Next.js auf 16.4.0
## [0.26.0] – 2026-10-04
### Hinzugefügt
- Kennzeichen **Eigenbedarf** am Kunden: für eigene Hosts entfallen Kundenrechnung, die zugehörigen Warnungen, die Aufgabe „Kundenrechnung erstellen“ und die Marge – die Proxmox-Rechnung wird weiterhin geführt (#044)
## [0.25.0] – 2026-10-03
### Hinzugefügt
- **Laufzeit von Hand anlegen** – für Hosts, die (noch) kein Ablaufdatum melden, und für Nachträge (#043)
- **Laufzeit bearbeiten**: Key, Beginn, Ende und Notiz lassen sich nachträglich ändern (#043)
- **Laufzeiten zusammenführen**, wenn neben einer erfassten Laufzeit ein Vorschlag mit abweichendem Datum steht; die Rechnungen wandern mit (#043)
- Kundenansicht: Laufzeit und Host verlinkt, dazu Marge und Rabatt je Zeile (#043)
## [0.24.1] – 2026-10-03
### Behoben
- **Cluster mit QDevice meldeten falsche Knotennamen** (`A,V,NMW`, `NR`) und das QDevice als zusätzlichen Knoten: `pvecm nodes` hat dann eine Spalte mehr. Die Namensspalte wird jetzt aus der Kopfzeile bestimmt (#042)
- Agent-Version 0.2.2
## [0.24.0] – 2026-10-03
### Hinzugefügt
- Dreizehnte Warnregel: Mehrere Hosts desselben Kunden tragen denselben Namen – mit IP-Adressen zum Unterscheiden (#041)
- Hosts lassen sich **stilllegen**: Sie verschwinden aus Listen, Zahlen, Suche und Warnungen, ihre Laufzeiten und Rechnungen bleiben erhalten (#041)
- Endgültiges Löschen für Hosts, an denen keine Laufzeit hängt (#041)
- Schalter „stillgelegte Hosts zeigen“ in der Host-Liste (#041)
## [0.23.0] – 2026-10-03
### Hinzugefügt
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
## [0.22.2] – 2026-10-03
### Behoben
- **Auf dem Telefon führte kein Weg zu den Rechnungsformularen**: Im Reiter *Rechnungen* der Host-Detailansicht war die Laufzeit nicht verlinkt, und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar (#039)
- Der Reiter *Rechnungen* rechnete den Einkauf noch ohne Rabatt (#039)
### Geändert
- Der Reiter *Rechnungen* nutzt auf dem Telefon das Kartenlayout (#039)
## [0.22.1] – 2026-10-03
### Behoben
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
### Hinzugefügt
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
## [0.22.0] – 2026-10-03
### Geändert
- **Der Rabatt gehört jetzt zur Rechnungsposition statt zur Rechnung** – so, wie Proxmox abrechnet: je Subscription-Key eine Zeile mit Listenpreis und eine mit „Client Discount“. Nur so bleibt er bei einer Sammelrechnung dem richtigen Host zugeordnet (#037)
- Der Rechnungsbetrag ist die Summe der Positionen nach Abzug ihrer Rabatte; Marge und Summen rechnen mit dem tatsächlich gezahlten Betrag (#037)
- Eigener Steuersatz für den Einkauf, Vorgabe 0 % wegen Reverse Charge (#037)
- Das Betragsfeld heißt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird (#037)
### Hinzugefügt
- Zwölfte Warnregel `kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen – der Moment, an dem man selbst zahlt (#037)
### Behoben
- Der Rabatt wurde bisher **nirgends abgezogen**, weder in der Summe noch in der Marge (#037)
## [0.21.0] – 2026-10-03 ## [0.21.0] – 2026-10-03
### Hinzugefügt ### Hinzugefügt
+41 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 18 h 58 min **Gesamt:** 24 h 38 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -42,6 +42,14 @@
| #034 Bedienung auf dem Handy | 0 h 40 min | | #034 Bedienung auf dem Handy | 0 h 40 min |
| #035 Tabellen als Karten | 0 h 50 min | | #035 Tabellen als Karten | 0 h 50 min |
| #036 Standort am Host | 0 h 30 min | | #036 Standort am Host | 0 h 30 min |
| #037 Rabatt je Position | 0 h 55 min |
| #038 Überbreite auf dem Handy | 0 h 40 min |
| #039 Zugang zur Laufzeit | 0 h 30 min |
| #040 Schlagworte sichtbar | 0 h 40 min |
| #041 Doppelte Hosts | 0 h 50 min |
| #042 QDevice im Cluster | 0 h 20 min |
| #043 Laufzeiten bearbeiten | 1 h 05 min |
| #044 Eigenbedarf | 0 h 40 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -64,7 +72,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.21.0 **Version:** 0.26.1
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -189,6 +197,37 @@
- Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las - Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las
- Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle - Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle
- Eine echte Proxmox-Rechnung hat drei Fehler im Rechnungsteil aufgedeckt (#037): Rabatt an der falschen Ebene, nirgends abgezogen, und 19 % USt als Vorgabe, wo Reverse Charge gilt
- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel
- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
- #039: Auf dem Telefon führte kein Weg zu den Rechnungsformularen – die Laufzeit war nicht verlinkt und die Spalte erst ab 992 px sichtbar
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden
- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt
- QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice
- Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen
- Laufzeiten (#043): Anlegen gab es nur als Server-Aktion ohne Oberfläche, Ändern und Zusammenführen gar nicht. Dabei tritt der Doppelfall zwangsläufig auf, sobald das gemeldete Ablaufdatum von der Bestellung abweicht
- Beim Zusammenführen muss die Quelle ihre Rechnungsposition zuerst loslassen – die Datenbank lässt eine Position nur an einer Laufzeit zu. Der Test hat die falsche Reihenfolge sofort gezeigt
- Eigenbedarf (#044): Eigene Hosts brauchen einen Kunden, sonst fehlen sie in jeder Auswertung – als gewöhnlicher Kunde erzeugen sie aber Aufgaben, die es nicht gibt, und eine negative Marge
- Die Einkaufsseite bleibt vollständig: Eigene Hosts kosten echtes Geld
### 10.10.2026 – Wartungsblick
- `npm audit` turnusmäßig geprüft: zwei neue hohe Meldungen (`sharp`/librsvg, `source-map-js`). Beide angehoben, Tests und Build grün
- Produktivinstanz antwortet: `https://proxledger.wilde-ste.de/api/gesundheit` meldet Datenbank erreichbar
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+17 -2
View File
@@ -12,7 +12,7 @@
set -o nounset set -o nounset
set -o pipefail set -o pipefail
AGENT_VERSION="0.2.1" AGENT_VERSION="0.2.2"
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}" CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}" SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
SPOOL_MAXIMUM=30 SPOOL_MAXIMUM=30
@@ -175,9 +175,24 @@ cluster_name() {
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier – # Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
# daraus erkennt das Dashboard vergessene Knoten (#022). # daraus erkennt das Dashboard vergessene Knoten (#022).
# Namen aller Cluster-Knoten aus `pvecm nodes`.
#
# Die Spaltenzahl ist nicht fest: Mit einem QDevice steht zwischen Votes und Name noch dessen
# Zustand (A,V,NMW oder NR). Deshalb wird die Spalte "Name" aus der Kopfzeile bestimmt, statt
# blind die dritte zu nehmen. Die Zeile des QDevice selbst traegt die Nodeid 0 und ist kein Knoten.
cluster_mitglieder() { cluster_mitglieder() {
if command -v pvecm >/dev/null 2>&1; then if command -v pvecm >/dev/null 2>&1; then
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//' pvecm nodes 2>/dev/null | awk '
/Nodeid/ {
for (i = 1; i <= NF; i++) if ($i == "Name") spalte = i
next
}
/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {
if ($1 == 0) next
name = (spalte ? $spalte : $3)
sub(/\(.*/, "", name)
if (name != "") print name
}'
fi fi
} }
+71
View File
@@ -192,6 +192,34 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst. deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
### Gleichnamige Hosts auseinanderhalten
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
Host-Detail steht die Notiz oben bei den Stammdaten.
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
### Wenn ein Host doppelt erscheint
Steht derselbe Name zweimal beim selben Kunden, meldet ProxLedger das als Hinweis und nennt die
IP-Adressen beider Einträge. Zwei Fälle sind möglich:
- **Zwei verschiedene Maschinen** heißen gleich. Dann helfen Schlagworte beim Auseinanderhalten.
- **Dieselbe Maschine hat sich neu registriert** – etwa nach einer Neuinstallation oder weil ein
Container geklont wurde. Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am
Namen; ändert sie sich, entsteht ein zweiter Eintrag.
Den alten Eintrag legst du im Host-Detail still. Er verschwindet dann aus Listen, Zahlen und
Warnungen, seine Laufzeiten und Rechnungen bleiben aber erhalten – mit dem Schalter *stillgelegte
Hosts zeigen* findest du ihn wieder. **Endgültig löschen** geht nur, solange keine Laufzeit daran
hängt.
--- ---
## 5. Kunden und Standorte pflegen ## 5. Kunden und Standorte pflegen
@@ -209,6 +237,16 @@ mit Marge. Die vier Kästen oben fassen Hosts, Einkauf, Verkauf und Marge zusamm
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
Host-Tabelle und lassen sich bei der Zuweisung mit angeben. Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
### Eigene Hosts
Auch die eigenen Hosts gehören einem Kunden zugeordnet – sonst fehlen sie in jeder Auswertung und
lösen die Warnung „Host ohne Kunde“ aus. Lege dafür dich selbst als Kunden an und setze im
Kundenformular den Haken **Eigenbedarf**.
Für solche Kunden entfällt alles, was mit Weiterberechnung zu tun hat: keine Kundenrechnung, keine
Warnungen darüber, keine Aufgabe „Kundenrechnung erstellen“ und keine Marge. Die Proxmox-Rechnung
wird weiterhin geführt und erinnert ans Bezahlen – die Kosten fallen ja an.
--- ---
## 6. Laufzeiten und Rechnungen ## 6. Laufzeiten und Rechnungen
@@ -251,6 +289,39 @@ Positionen und wird automatisch nachgeführt.
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions. Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
### Woher Laufzeiten kommen
Eine Laufzeit ist ein Lizenzjahr eines Hosts. Es gibt zwei Wege:
1. **Vom Agent vorgeschlagen.** Meldet ein Host ein Ablaufdatum, legt ProxLedger eine Laufzeit von
diesem Datum bis ein Jahr später an – zunächst als *Vorschlag*. Unter *Rechnungen → Vorschläge*
bestätigst oder verwirfst du ihn.
2. **Von Hand angelegt** über *Rechnungen → Laufzeit von Hand anlegen*. Nötig, wenn ein Host noch kein
Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein zurückliegendes Lizenzjahr
nachgetragen wird.
Jede Laufzeit lässt sich später ändern (*Laufzeit bearbeiten* in der Detailansicht): Key, Beginn, Ende
und Notiz. Stehen für denselben Host zwei Laufzeiten, die dasselbe Lizenzjahr meinen – etwa weil das
gemeldete Datum um einen Tag abweicht –, lassen sie sich **zusammenführen**. Die aufgelöste Laufzeit
gibt ihre Rechnungen an die verbleibende ab.
### Der übliche Ablauf
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
2. Die Kundenrechnung wird geschrieben und verschickt.
3. Der Kunde zahlt. Jetzt meldet ProxLedger: *Kunde hat bezahlt – Proxmox-Rechnung noch offen*.
4. Man zahlt bei Proxmox, setzt den Haken „bezahlt“ und lädt die **PAID**-Fassung des PDFs hoch – sie
ersetzt die alte.
### Die Felder der Proxmox-Rechnung
| Feld | Inhalt |
|---|---|
| Rechnungsnummer | Nummer von Proxmox. **Dieselbe Nummer bei einer weiteren Laufzeit** hängt diese als Position an dieselbe Rechnung – so werden Sammelrechnungen über mehrere Hosts abgebildet. |
| Listenpreis netto | Der Betrag dieser **einen** Laufzeit, wie er auf der Rechnung steht (z. B. 1.100 €). |
| Rabatt | Der „Client Discount“ dieser Laufzeit (z. B. 110 €). Gerechnet wird mit Listenpreis minus Rabatt. |
| USt | Bei Proxmox in aller Regel 0 – Reverse Charge nach Art. 196. Die Vorgabe steht unter *Einstellungen → System*. |
--- ---
## 7. Warnungen ## 7. Warnungen
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 194 KiB

+46
View File
@@ -0,0 +1,46 @@
# #037 Rabatt je Position und der tatsächliche Ablauf
**Status:** erledigt
**Meilenstein:** v0.22.0
**Labels:** backend, ui
**Geschätzt:** 3 h **Tatsächlich:** 0 h 55 min
## Auslöser
Florian zeigte eine echte Proxmox-Rechnung (#291017) und beschrieb den Ablauf:
```
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-3ef3be4d75 € 1.100,00
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-b7a0d516af € 1.100,00
Client Discount - … pve2s-3ef3be4d75 € -110,00
Client Discount - … pve2s-b7a0d516af € -110,00
Sub Total € 1.980,00
```
Dazu: Man bekommt die Rechnung zuerst als **UNPAID**. Erst wenn der Kunde seine Rechnung bezahlt hat,
zahlt man selbst bei Proxmox – und bekommt dieselbe Rechnung dann als **PAID** mit Transaktion.
## Was falsch war
1. **Der Rabatt hing an der Rechnung, nicht an der Position.** Bei einer Sammelrechnung über zwei
Hosts ließe sich ein Rabatt keinem Host zuordnen; die Marge je Host wäre falsch.
2. **Er wurde nirgends abgezogen** – weder in der Summe noch in der Marge. Ein Feld, das aussieht wie
eine Rechengröße und keine ist.
3. **Vorgabe 19 % USt** für den Einkauf. Proxmox sitzt in Österreich und rechnet gegenüber Unternehmen
mit USt-IdNr. über Reverse Charge ab – richtig sind 0 %.
## Behebung
- `rabatt` wandert an `ProxmoxRechnungPosition`; die Migration verteilt vorhandene Werte anteilig auf
die Positionen und rechnet die Rechnungssummen neu. Nichts geht verloren.
- Rechnungsbetrag = Summe der Positionen **nach Abzug ihrer Rabatte** – wie das „Sub Total“ auf der
echten Rechnung.
- Marge und alle Summen rechnen mit dem Betrag nach Abzug.
- Das Formular heißt jetzt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird.
- Eigener Steuersatz für den Einkauf, Vorgabe 0 (Reverse Charge).
- Die Sammelrechnung listet je Position „1.100,00 € − 110,00 € = 990,00 €“.
## Neue Warnregel
`kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen. Genau der
Zustand aus Florians Ablauf – das Geld ist da, die Rechnung auch, jetzt kann gezahlt werden. Bewusst
unabhängig vom Fälligkeitsdatum, denn die Reihenfolge ist Absicht und keine Nachlässigkeit.
## Zum PDF
Die PAID-Fassung ersetzt beim erneuten Hochladen einfach die UNPAID-Fassung – gleiche Rechnungsnummer,
gleiche Rechnung. Gleiche Dateien werden an ihrer Prüfsumme erkannt und nicht doppelt abgelegt.
+31
View File
@@ -0,0 +1,31 @@
# #038 Seite wird auf dem Handy herausgezoomt
**Status:** erledigt
**Meilenstein:** v0.22.1
**Labels:** bug, ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
Host-Detailansicht saß verschoben im Bild.
## Ursache
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
`table-responsive` – anders als die übrigen.
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
## Behebung
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
- Die drei Tabellen bekommen ihre Hülle
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
wurde die Host-Detailansicht gegengeprüft
## Nachtrag zu #037
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
+32
View File
@@ -0,0 +1,32 @@
# #039 Auf dem Telefon führte kein Weg zur Laufzeit
**Status:** erledigt
**Meilenstein:** v0.22.2
**Labels:** bug, ui
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
## Auslöser
„Kann ich am Handy nicht rein.“ Gemeint war der Reiter *Rechnungen* in der Host-Detailansicht: Die
Zeile zeigt Laufzeit, Rechnung und Beträge – aber nichts davon war anklickbar.
## Ursache
Zwei Stellen:
1. In der Host-Detailansicht war die Laufzeit nie verlinkt, auch am Rechner nicht. Man kam nur über
die Rechnungsliste dorthin.
2. In der Rechnungsliste ist die Laufzeit-Spalte erst ab 992 px eingeblendet. Auf dem Telefon fehlte
damit der einzige Verweis auf die Formulare – die Karte verwies nur auf den Host.
## Behebung
- Die Laufzeit in der Host-Detailansicht ist ein Link und zugleich die Überschrift der Karte
- In der Rechnungsliste bleibt die Laufzeit auf dem Telefon sichtbar; ausgeblendet wird sie nur
zwischen 768 und 992 px, wo die Tabelle am engsten ist
- Der Reiter *Rechnungen* nutzt ebenfalls das Kartenlayout
## Nebenbefund
Auch dieser Reiter rechnete den Einkauf noch ohne Rabatt – 1.100 € statt 990 €. Dritte Stelle nach
#037 und #038; alle gefunden, weil Florian die Zahl auf dem Bildschirm mit seiner Rechnung verglichen hat.
## Gelernt
Bootstraps `d-*`-Hilfsklassen setzen `display` mit `!important` und überschreiben damit das
Kartenlayout. Für Spalten, die in der Kartenansicht sichtbar bleiben sollen, braucht es eine eigene
Klasse (`pl-spalte-laufzeit`).
+26
View File
@@ -0,0 +1,26 @@
# #040 Schlagworte und Notizen sichtbar machen
**Status:** erledigt
**Meilenstein:** v0.23.0
**Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
## Ausgangslage
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
## Behebung
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
Notiztext; die Trefferzeile nennt die Schlagworte.
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
## Entscheidung
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
+35
View File
@@ -0,0 +1,35 @@
# #041 Gleichnamige Hosts erkennen und stilllegen
**Status:** erledigt
**Meilenstein:** v0.24.0
**Labels:** backend, ui, alerts
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
## Auslöser
Florian markierte in der Host-Liste zwei Paare: `hv-pve-01` und `hv-pve-02` standen jeweils **zweimal
beim selben Kunden** – einmal „Aktiv“ mit einer IP, einmal „Keine Subscription“ mit drei anderen.
## Was dahintersteckt
Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am Namen. Ändert sich diese Kennung
– Neuinstallation, geklonter Container, zurückgespielte Sicherung –, meldet sich dieselbe Maschine als
**neuer** Host, und der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind
aber genauso möglich. Welcher Fall vorliegt, weiß nur der Betreiber.
## Was fehlte
1. Kein Hinweis auf den Doppeleintrag.
2. **Keine Möglichkeit, einen Host loszuwerden.** Ein Eintrag, der nicht mehr existiert, blieb für
immer in Listen, Zahlen und Warnungen stehen.
## Behebung
- **Dreizehnte Warnregel `hostname-doppelt`:** meldet mehrere Hosts desselben Kunden mit gleichem
Namen und nennt die IP-Adressen zum Unterscheiden. Gleiche Namen bei **verschiedenen** Kunden sind
der Normalfall und bleiben still.
- **Stilllegen** (`archiviertAm`): Der Host verschwindet aus Listen, Zahlen, Suche und Warnungen;
seine offenen Warnungen werden geschlossen. Laufzeiten und Rechnungen bleiben erhalten.
- **Wieder aufnehmen** mit demselben Knopf.
- **Endgültig löschen** nur, solange keine Laufzeit am Host hängt – Buchhaltung wirft man nicht weg.
- In der Liste ein Schalter „stillgelegte Hosts zeigen“ und ein Abzeichen an der Zeile.
## Entscheidung
Stilllegen statt automatisch zusammenführen. Ein Zusammenführen müsste raten, welcher Eintrag welche
Rechnung erbt – im Zweifel zulasten der Buchhaltung. Die Warnung nennt beide Hosts mit IPs; die
Entscheidung trifft der Mensch.
+40
View File
@@ -0,0 +1,40 @@
# #042 Cluster-Knoten falsch gelesen, wenn ein QDevice im Spiel ist
**Status:** erledigt
**Meilenstein:** v0.24.1
**Labels:** bug, agent
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
## Auslöser
Florians Cluster hat drei Knoten (`hv-pve-01`, `hv-pve-02`, `hv-pve-05`). ProxLedger zeigte vier
„Cluster-Knoten“: `A,V,NMW`, `A,V,NMW`, `NR` und `Qdevice`.
## Ursache
Der Agent nahm aus `pvecm nodes` blind die dritte Spalte als Namen. Ohne QDevice stimmt das:
```
Nodeid Votes Name
1 1 hv-rz5-pve-01 (local)
```
Mit QDevice steht dort aber dessen Zustand, und der Name rutscht eine Spalte weiter:
```
Nodeid Votes Qdevice Name
1 1 A,V,NMW hv-pve-01
0 1 Qdevice (votes 1)
```
Gemeldet wurden damit die Zustandskürzel – und das QDevice selbst als vierter „Knoten“.
## Behebung
Die Spalte „Name“ wird aus der Kopfzeile bestimmt, statt sie zu erraten. Die Zeile des QDevice trägt
die Nodeid 0 und wird übersprungen. Beide Ausgabeformen sind als Testfall hinterlegt.
## Folgen
Die Warnregel „Cluster-Knoten ohne Agent“ hatte dadurch Warnungen für Knoten erzeugt, die es nicht
gibt. Sie schließen sich von selbst, sobald die Agents mit der neuen Fassung gemeldet haben.
## Lehre
Dritter Fall in dieser Reihe: Eine Ausgabe, die beim Feldtest auf **einem** Cluster richtig gelesen
wurde, ist nicht auf jedem Cluster gleich. Spalten gehören über ihre Überschrift angesprochen, nicht
über ihre Position.
+42
View File
@@ -0,0 +1,42 @@
# #043 Laufzeiten anlegen, bearbeiten und zusammenführen
**Status:** erledigt
**Meilenstein:** v0.25.0
**Labels:** backend, ui
**Geschätzt:** 4 h **Tatsächlich:** 1 h 05 min
## Auslöser
Drei Rückmeldungen aus dem Einsatz:
1. „In der Kundenübersicht wäre es auch cool, die Laufzeit / Rechnung zu überarbeiten.“
2. „Wie werden so Laufzeiten erfasst?“
3. „Auch die Laufzeit im Nachhinein zu bearbeiten geht nicht. Oder mit einer vorhandenen zu
verknüpfen oder zu vereinen.“
## Ausgangslage
Laufzeiten entstanden ausschließlich als **Vorschlag** aus dem Agenten-Report: Meldet ein Host ein
Ablaufdatum, legt ProxLedger eine Laufzeit von diesem Datum bis ein Jahr später an. Bestätigen oder
verwerfen war möglich – mehr nicht:
- **Anlegen von Hand** gab es als Server-Aktion, aber **nirgends in der Oberfläche**.
- **Ändern** war unmöglich. Weicht das gemeldete Datum von der Bestellung ab, blieb es falsch.
- **Zusammenführen** gab es nicht, obwohl der Fall zwangsläufig auftritt: Neben einer erfassten
Laufzeit legt der Agent eine zweite an, wenn das Ablaufdatum um einen Tag abweicht.
- Aus der **Kundenansicht** führte kein Weg in die Laufzeit.
## Behebung
- Formular *Laufzeit von Hand anlegen* auf der Rechnungsseite; der gemeldete Key wird vorgeschlagen,
das Ende aus dem Beginn vorbelegt (ein Jahr minus ein Tag – dieselbe Rechnung wie beim Vorschlag).
- *Laufzeit bearbeiten* im Laufzeit-Detail: Key, Beginn, Ende, Notiz. Eine Änderung bestätigt den
Vorschlag zugleich.
- *Zusammenführen* mit einer anderen Laufzeit desselben Hosts. Die aufgelöste Laufzeit gibt ihre
Rechnungen ab, sofern das Ziel dort nichts hat; haben beide eine Rechnung derselben Art, bricht der
Vorgang mit einer Erklärung ab.
- Kundenansicht: Laufzeit und Host sind verlinkt, Marge und Rabatt stehen dabei.
## Entscheidungen
- **Kein stilles Überschreiben beim Zusammenführen.** Zwei Einkaufsrechnungen auf einem Lizenzjahr
wären nicht mehr zuzuordnen – lieber ein Abbruch mit Hinweis.
- **Beim Ändern bleibt die Regel „ein Startdatum je Host“.** Kollidiert das neue Datum, verweist die
Meldung aufs Zusammenführen.
- Beim Zusammenführen muss die Quelle ihre Positionen zuerst loslassen: Eine Rechnungsposition gehört
zu genau einer Laufzeit, und die Datenbank wacht darüber. Erst dann darf das Ziel sie übernehmen.
+38
View File
@@ -0,0 +1,38 @@
# #044 Eigene Hosts ohne Weiterberechnung
**Status:** erledigt
**Meilenstein:** v0.26.0
**Labels:** backend, ui, alerts
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
„Ich habe mich auch selbst als Kunden angelegt, da ich ja auch Hosts habe. Aber mir selbst schreibe
ich ja keine E-Mails.“
## Das Problem
Die eigenen Hosts brauchen einen Kunden – sonst stehen sie unter *Nicht zugeordnet* und lösen die
Warnung „Host ohne Kunde“ aus. Als gewöhnlicher Kunde erzeugen sie aber lauter Aufgaben, die es nicht
gibt: „Kundenrechnung erstellen“, „Kundenrechnung unbezahlt“, dazu eine negative Marge in jeder
Summe, weil dem Einkauf kein Verkauf gegenübersteht.
## Behebung
Kennzeichen **Eigenbedarf** am Kunden. Für dessen Hosts gilt:
| | Verhalten |
|---|---|
| Proxmox-Rechnung | unverändert – die Kosten fallen ja an |
| Kundenrechnung | entfällt; das Formular wird durch eine Erklärung ersetzt |
| Warnungen „Kundenrechnung fehlt / unbezahlt“ | entfallen |
| Warnung „Kunde hat bezahlt – Einkauf offen“ | entfällt |
| Aufgabe „Kundenrechnung erstellen“ | entfällt |
| Marge | bleibt leer statt negativ |
| Summen | Einkauf zählt mit, Verkauf und Marge nicht |
Dazu ein Abzeichen in der Kundenliste und auf der Laufzeit.
## Entscheidung
Die Einkaufsseite bleibt vollständig erhalten. Eigene Hosts kosten echtes Geld, und die Rechnung von
Proxmox will bezahlt werden – nur die Weiterberechnung entfällt.
Die Alternative wäre gewesen, eigene Hosts ohne Kunden zu lassen und die Warnung „Host ohne Kunde“
abzuschalten. Dann fehlten sie aber in jeder Auswertung nach Kunde, und der Einkauf ließe sich nicht
mehr zuordnen.
+9 -1
View File
@@ -40,4 +40,12 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min | | [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min | | [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min | | [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
| | **Summe** | | | **157 h** | **18 h 58 min** | | [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min |
| [#042](042-qdevice-cluster.md) | Cluster-Knoten falsch gelesen bei QDevice | erledigt | v0.24.1 | 1 h | 0 h 20 min |
| [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min |
| [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min |
| | **Summe** | | | **175 h** | **24 h 38 min** |
+98 -53
View File
@@ -1,72 +1,117 @@
# Roadmap # Roadmap
Stand: 19.09.2026, Fassung 0.17.0 Stand: 04.10.2026, Fassung 0.26.0
## Was steht Die erste Fassung dieser Roadmap entstand vor dem produktiven Einsatz. Seitdem läuft ProxLedger mit
echten Daten, und das hat die Reihenfolge deutlich verändert: Was aus der Benutzung kam, war fast
immer wichtiger als das, was vorher auf der Liste stand.
Alles, was im ursprünglichen Auftrag stand, ist gebaut und in Betrieb erprobt: ## Was seit dem Produktivgang dazukam
- Agent für PVE, PBS und PMG; Push-Modell über systemd-Timer, Zwischenspeicher bei Ausfall Rollen und Benutzerverwaltung, Passkeys, Einstellungen im Dashboard, Betriebsdokumentation, die
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge mobile Bedienung, Schlagworte und Notizen in der Übersicht, Stilllegen doppelter Hosts, Laufzeiten
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung anlegen/bearbeiten/zusammenführen, Rabatt je Rechnungsposition, Eigenbedarf.
- Anmeldung lokal und per OIDC, Audit-Log, verschlüsselte Zugangsdaten, Härtung nach OWASP
- Auslieferung als Docker-Stack und als LXC-Container (eigener Installer und Helper-Script-Weg)
- Handbuch mit Bildern, 190 Tests, CI bei jedem Push
## Was bis 1.0.0 fehlt Ebenso die Fehler, die erst der Einsatz gezeigt hat: QDevice-Spalte im Cluster, PBS-Rahmentabelle,
Weiterleitungen auf die Lausch-Adresse, Standort beim Zuweisen gelöscht, Einkauf ohne Rabatt an drei
Stellen.
Nichts davon ist eine Baustelle im Code – es fehlt **Erprobung** und eine Handvoll Kanten: ---
| Punkt | Warum es zählt | Aufwand | ## Bis 1.0.0
Kein neuer Funktionsumfang – es fehlt Erprobung.
| Punkt | Warum | Aufwand |
|---|---|---| |---|---|---|
| **Mail Gateway im Feldtest** (#023) | Der einzige Produktzweig, den nie ein echter Host bestätigt hat. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet – bei PMG kann es genauso sein. | 1 h, sobald ein Host da ist | | **PMG im Feldtest** ([#023](issues/023-feldtest-pbs-pmg.md)) | Der einzige Produktzweig ohne echten Host. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet. | 1 h, sobald ein Host da ist |
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min | | **PBS mit aktiver Lizenz** | Bisher nur „keine Subscription“ geprüft. | 30 min |
| **Ein Monat Dauerbetrieb** | Timer, Nachsendung, nächtliches Aufräumen und die Zusammenfassung laufen erst seit einem Tag. Ein Lizenzablauf in echt wäre der beste Beweis. | Zeit, keine Arbeit | | **Sicherung einmal zurückspielen** | Eine Sicherung, die nie zurückgespielt wurde, ist keine. | 30 min |
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min | | **Ein Lizenzjahr durchlaufen lassen** | Warnung → Verlängerung → Einkauf → Kundenrechnung → bezahlt, einmal vollständig in echt. Die nächsten Abläufe stehen am 17.10.2026 an. | nur Zeit |
Danach ist 1.0.0 gerechtfertigt. Meine Empfehlung: erst laufen lassen, dann das Etikett vergeben. ---
## Nach 1.0 – Vorschläge, nach Nutzen sortiert ## Danach – nach Nutzen sortiert
### Lohnt sich am ehesten ### 1. Proxmox-Rechnung einlesen statt abtippen
1. **Verlängerung mit einem Klick.** Heute wird eine Laufzeit bestätigt und die Rechnung von Hand Die Rechnungen von Proxmox haben eine feste Form: je Key eine Zeile mit Zeitraum und Betrag, dazu die
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die Rabattzeilen. Ein Import, der das PDF liest und die Positionen den Laufzeiten über den Key zuordnet,
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks. spart bei jeder Sammelrechnung das meiste Tippen – und vermeidet Zahlendreher.
2. **Rechnungsnummern automatisch.** Ein Schema wie `PL-{jahr}-{nummer}` mit fortlaufendem Zähler;
heute tippt man jede Nummer. (In #025 bewusst weggelassen, weil es eine Vergabelogik braucht.)
3. **Mehrere Benutzer mit Rollen.** Derzeit darf jeder Angemeldete alles – in `docs/sicherheit.md`
offen vermerkt. Für Buchhaltung (nur Rechnungen) und Technik (nur Hosts) wären zwei Rollen genug.
Dazu gehört eine Benutzerverwaltung in der Oberfläche statt auf der Kommandozeille.
4. **Währung und Zeitzone auch in der Browseransicht.** Beides steht schon in den Einstellungen, wirkt
aber nur in PDF, CSV und Benachrichtigungen; die Oberfläche rechnet fest in Euro.
### Sinnvoll, wenn es wächst **Aufwand:** mittel (PDF-Text auslesen, Zeilen erkennen, Zuordnung bestätigen lassen).
**Voraussetzung:** zwei, drei echte Rechnungen als Vorlage.
5. **Kundenportal oder Bericht per E-Mail.** Ein monatlicher Stand je Kunde, automatisch verschickt – ### 2. Verlängerung mit einem Klick
spart Rückfragen.
6. **Angebot vor der Verlängerung.** Aus der Jahresbahn heraus ein PDF-Angebot für das kommende
Lizenzjahr erzeugen.
7. **Import der Proxmox-Shop-Rechnungen.** Wer dort per Sammelrechnung kauft, tippt heute jede Position
ab. Ein CSV- oder PDF-Import mit Zuordnung über den Key wäre eine echte Ersparnis.
8. **Metriken für Prometheus** (`/metrics`): Hosts je Zustand, Tage bis zum nächsten Ablauf, Alter des
letzten Reports. Wer schon Grafana betreibt, sieht ProxLedger damit im gewohnten Bild.
9. **Zwei-Faktor-Anmeldung** für den lokalen Zugang. Heute lautet die Antwort „dann nimm OIDC“ – das
ist vertretbar, aber nicht für jeden.
### Nur falls der Bedarf entsteht Heute wird eine Laufzeit bestätigt und die Folgelaufzeit von Hand angelegt. Ein Knopf „verlängern“,
der die nächste Laufzeit anlegt, den Einkauf vormerkt und die Kundenrechnung mit dem eingestellten
Aufschlag vorbereitet, nimmt bei jedem Lizenzjahr vier Schritte ab.
10. **Mandantenfähigkeit.** Mehrere Systemhäuser in einer Installation. Das ist ein Umbau, kein **Aufwand:** klein bis mittel.
Anbau – die Zugriffskontrolle müsste durchgängig auf Mandanten umgestellt werden.
11. **Agent für weitere Produkte** (Proxmox Datacenter Manager), sobald der Bedarf da ist.
12. **Hochverfügbarkeit.** Die Ratenbremse liegt im Prozessspeicher; für mehrere Instanzen müsste sie
in die Datenbank wandern. Bei einem Dashboard für ein Systemhaus ist das Aufwand ohne Gewinn.
## Was ich bewusst nicht vorschlage ### 3. Kundenrechnung als Dokument
- **Pull statt Push.** Das Dashboard würde dann Zugänge zu allen Kundenhosts brauchen. Der jetzige Weg ProxLedger führt die Kundenrechnung bisher nur als Datensatz – geschrieben wird sie woanders. Mit
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte. Nummernkreis, Firmenangaben (stehen schon in den Einstellungen) und PDF-Ausgabe wäre der Kreis
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon geschlossen. Zu klären wäre, ob das die vorhandene Buchhaltung ersetzen soll oder nur ergänzt.
ein IdP – dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt heute mit Bash und curl aus und läuft damit auf **Aufwand:** mittel. **Rückfrage zuerst:** Soll ProxLedger Rechnungen *schreiben* oder nur *führen*?
jedem Proxmox-Host, auch auf einem alten.
### 4. Oberflächentests mit echtem Browser
Die letzten sechs Fehler hat keiner der 251 Tests gefunden – alle kamen aus der Benutzung: Menü ließ
sich nicht schließen, Seite zoomte heraus, kein Weg zur Laufzeit, falscher Betrag in einer Tabelle.
Ein knappes Dutzend Abläufe mit Playwright (anmelden, Host zuweisen, Rechnung erfassen, auf dem
Telefon navigieren) würde genau diese Klasse abdecken.
**Aufwand:** mittel. **Nutzen:** steigt mit jeder weiteren Änderung.
### 5. Verlauf statt einer Notiz
Die Notiz am Host ist ein Textfeld – was wann passiert ist, verschwindet darin. Datierte Einträge
(„17.10.2026 · Sockets von 1 auf 2 erhöht“) wären bei Wechseln und Hardwaretausch nützlich.
**Aufwand:** klein.
### 6. Ablauf-Kalender abonnieren
Ein ICS-Feed mit allen Ablaufdaten, den man in Outlook oder Nextcloud einbindet. Dann stehen die
Verlängerungen im gewohnten Kalender, nicht nur im Dashboard.
**Aufwand:** klein.
### 7. Kundenzugang wirklich nutzen
Die Rolle *Kunde* existiert samt eingeschränkter Sicht, aber es gibt keinen Weg, jemanden einzuladen:
kein Einladungslink, keine erste Anmeldung ohne Passwort. Ohne das bleibt die Rolle Theorie.
**Aufwand:** klein bis mittel.
### 8. Metriken für Prometheus
`/metrics` mit Hosts je Zustand, Tagen bis zum nächsten Ablauf und Alter des letzten Reports. Wer
Grafana betreibt, sieht ProxLedger damit im gewohnten Bild – und merkt auch, wenn das Dashboard
selbst steht.
**Aufwand:** klein.
---
## Später, falls der Bedarf entsteht
- **Mandantenfähigkeit** – mehrere Systemhäuser in einer Installation. Ein Umbau, kein Anbau.
- **Agent für Proxmox Datacenter Manager**, sobald es dort Subscriptions gibt, die das rechtfertigen.
- **Mehrere Instanzen** (Hochverfügbarkeit). Die Ratenbremse liegt im Prozessspeicher und müsste in
die Datenbank wandern. Für ein Dashboard eines Systemhauses Aufwand ohne Gewinn.
- **Angebot vor der Verlängerung** als PDF aus der Jahresbahn heraus.
## Bewusst nicht
- **Pull statt Push.** Das Dashboard bräuchte Zugang zu allen Kundenhosts. Der jetzige Weg kommt ohne
gespeicherte root-Zugänge aus – ein Sicherheitsgewinn, den man nicht aufgeben sollte.
- **Eigene Rechteverwaltung mit Gruppen und Vererbung.** Wer das braucht, hat ein IdP; dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt mit Bash und curl aus und läuft damit auf jedem
Proxmox-Host.
- **Hosts automatisch zusammenführen.** Beim Doppeleintrag müsste geraten werden, welcher Eintrag
welche Rechnung erbt. Die Warnung nennt beide; entscheiden soll der Mensch.
+174 -174
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.19.1", "version": "0.26.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.19.1", "version": "0.26.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -20,7 +20,7 @@
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "^16.3.8", "next": "^16.4.0",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -35,11 +35,11 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^16.3.8", "eslint-config-next": "^16.4.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
"sharp": "^0.35.4", "sharp": "^0.35.5",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "^5", "typescript": "^5",
"vitest": "^5.0.1" "vitest": "^5.0.1"
@@ -1029,9 +1029,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1047,13 +1047,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3" "@img/sharp-libvips-darwin-arm64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1069,20 +1069,20 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3" "@img/sharp-libvips-darwin-x64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-freebsd-wasm32": { "node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.4" "@img/sharp-wasm32": "0.35.5"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -1092,9 +1092,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1108,9 +1108,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1124,9 +1124,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1143,9 +1143,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1162,9 +1162,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1181,9 +1181,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1200,9 +1200,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1219,9 +1219,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1238,9 +1238,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1257,9 +1257,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1276,9 +1276,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1297,13 +1297,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3" "@img/sharp-libvips-linux-arm": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1322,13 +1322,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3" "@img/sharp-libvips-linux-arm64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1347,13 +1347,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3" "@img/sharp-libvips-linux-ppc64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1372,13 +1372,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3" "@img/sharp-libvips-linux-riscv64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1397,13 +1397,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3" "@img/sharp-libvips-linux-s390x": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1422,13 +1422,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3" "@img/sharp-libvips-linux-x64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1447,13 +1447,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3" "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1472,13 +1472,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3" "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1492,16 +1492,16 @@
} }
}, },
"node_modules/@img/sharp-webcontainers-wasm32": { "node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.4" "@img/sharp-wasm32": "0.35.5"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -1511,9 +1511,9 @@
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1530,9 +1530,9 @@
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1549,9 +1549,9 @@
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1646,15 +1646,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz",
"integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz",
"integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", "integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1695,9 +1695,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz",
"integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1711,9 +1711,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz",
"integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1727,9 +1727,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz",
"integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1746,9 +1746,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz",
"integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1765,9 +1765,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz",
"integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1784,9 +1784,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz",
"integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1803,9 +1803,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz",
"integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1819,9 +1819,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz",
"integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -5889,21 +5889,21 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz",
"integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", "integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.3.8", "@next/eslint-plugin-next": "16.4.0",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.0", "eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-hooks": "^7.1.0",
"globals": "16.4.0", "globals": "16.4.0",
"typescript-eslint": "^8.46.0" "typescript-eslint": "^8.56.0"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=9.0.0", "eslint": ">=9.0.0",
@@ -8184,12 +8184,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.3.8", "version": "16.4.0",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz",
"integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.3.8", "@next/env": "16.4.0",
"@swc/helpers": "0.5.23", "@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -8203,14 +8203,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-arm64": "16.4.0",
"@next/swc-darwin-x64": "16.3.8", "@next/swc-darwin-x64": "16.4.0",
"@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-gnu": "16.4.0",
"@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-arm64-musl": "16.4.0",
"@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-gnu": "16.4.0",
"@next/swc-linux-x64-musl": "16.3.8", "@next/swc-linux-x64-musl": "16.4.0",
"@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-arm64-msvc": "16.4.0",
"@next/swc-win32-x64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.4.0",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -9422,9 +9422,9 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -9439,31 +9439,31 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.4", "@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.4", "@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.4" "@img/sharp-win32-x64": "0.35.5"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/node": { "@types/node": {
@@ -9604,9 +9604,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
+6 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.21.0", "version": "0.26.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -41,7 +41,7 @@
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "^16.3.8", "next": "^16.4.0",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -56,11 +56,11 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^16.3.8", "eslint-config-next": "^16.4.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
"sharp": "^0.35.4", "sharp": "^0.35.5",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "^5", "typescript": "^5",
"vitest": "^5.0.1" "vitest": "^5.0.1"
@@ -71,6 +71,7 @@
}, },
"overrides": { "overrides": {
"mysql2": "^3.24.4", "mysql2": "^3.24.4",
"deepmerge-ts": "^8.0.0" "deepmerge-ts": "^8.0.0",
"source-map-js": "^1.2.2"
} }
} }
@@ -0,0 +1,32 @@
-- Rabatt gehört an die Position, nicht an die Rechnung (#037).
--
-- Proxmox weist den Rabatt je Subscription-Key als eigene Zeile aus. Bei einer Sammelrechnung
-- über mehrere Hosts ließe sich ein Rabatt an der Rechnung keinem Host zuordnen – und die Marge
-- je Host wäre falsch.
ALTER TABLE "ProxmoxRechnungPosition" ADD COLUMN "rabatt" DECIMAL(12,2);
-- Vorhandene Rabatte von der Rechnung auf ihre Positionen verteilen, anteilig zum Nettobetrag.
-- Bei nur einer Position entspricht das genau dem bisherigen Wert.
UPDATE "ProxmoxRechnungPosition" AS p
SET "rabatt" = ROUND(r."rabatt" * p."nettoBetrag" / NULLIF(s."summe", 0), 2)
FROM "ProxmoxRechnung" AS r,
(SELECT "rechnungId", SUM("nettoBetrag") AS "summe"
FROM "ProxmoxRechnungPosition"
GROUP BY "rechnungId") AS s
WHERE p."rechnungId" = r."id"
AND s."rechnungId" = r."id"
AND r."rabatt" IS NOT NULL;
-- Der Rechnungsbetrag ist ab jetzt die Summe der Positionen nach Abzug ihrer Rabatte.
UPDATE "ProxmoxRechnung" AS r
SET "nettoBetrag" = COALESCE((
SELECT SUM(p."nettoBetrag" - COALESCE(p."rabatt", 0))
FROM "ProxmoxRechnungPosition" AS p
WHERE p."rechnungId" = r."id"
), r."nettoBetrag");
ALTER TABLE "ProxmoxRechnung" DROP COLUMN "rabatt";
-- Einkauf bei Proxmox läuft über Reverse Charge; 19 % war als Vorgabe irreführend.
ALTER TABLE "ProxmoxRechnung" ALTER COLUMN "ustSatz" SET DEFAULT 0.00;
@@ -0,0 +1,5 @@
-- Hosts stilllegen, statt sie zu löschen (#041).
-- Ein stillgelegter Host verschwindet aus Listen, Zahlen und Warnungen; seine Laufzeiten und
-- Rechnungen bleiben erhalten, weil sie zur Buchhaltung gehören.
ALTER TABLE "Host" ADD COLUMN "archiviertAm" TIMESTAMP(3);
CREATE INDEX "Host_archiviertAm_idx" ON "Host"("archiviertAm");
@@ -0,0 +1,3 @@
-- Eigenbedarf: Kunden, die man selbst ist (#044).
-- Für sie gibt es keine Kundenrechnung – und damit auch keine Warnungen darüber und keine Marge.
ALTER TABLE "Kunde" ADD COLUMN "eigenbedarf" BOOLEAN NOT NULL DEFAULT false;
+13 -2
View File
@@ -61,6 +61,9 @@ model Kunde {
ansprechpartner String? ansprechpartner String?
email String? email String?
telefon String? telefon String?
/// Eigenbedarf: eigene Hosts, die nicht weiterberechnet werden. Für sie entfallen
/// Kundenrechnung, die zugehörigen Warnungen und die Marge (#044).
eigenbedarf Boolean @default(false)
notizen String? notizen String?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
@@ -134,6 +137,10 @@ model Host {
subNextduedate DateTime? @db.Date subNextduedate DateTime? @db.Date
subChecktime DateTime? subChecktime DateTime?
/// Stillgelegt: Der Host erscheint nicht mehr in Listen, Zahlen und Warnungen – seine
/// Geschichte samt Laufzeiten und Rechnungen bleibt aber erhalten (#041).
archiviertAm DateTime?
erstmalsGesehenAm DateTime @default(now()) erstmalsGesehenAm DateTime @default(now())
letzterReportAm DateTime? letzterReportAm DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
@@ -261,9 +268,10 @@ model ProxmoxRechnung {
nummer String @unique nummer String @unique
datum DateTime @db.Date datum DateTime @db.Date
faelligAm 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) nettoBetrag Decimal @db.Decimal(12, 2)
ustSatz Decimal @default(19.00) @db.Decimal(5, 2) /// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
rabatt Decimal? @db.Decimal(12, 2) ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
zahlungsart String? zahlungsart String?
bezahlt Boolean @default(false) bezahlt Boolean @default(false)
bezahltAm DateTime? @db.Date bezahltAm DateTime? @db.Date
@@ -284,7 +292,10 @@ model ProxmoxRechnungPosition {
rechnungId String rechnungId String
bezeichnung String bezeichnung String
menge Int @default(1) menge Int @default(1)
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
nettoBetrag Decimal @db.Decimal(12, 2) 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) rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
laufzeit Laufzeit? laufzeit Laufzeit?
+2 -1
View File
@@ -166,7 +166,6 @@ async function seedDemo() {
faelligAm: datum(vorlage.faelligAm), faelligAm: datum(vorlage.faelligAm),
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0), nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
ustSatz: vorlage.ustSatz, ustSatz: vorlage.ustSatz,
rabatt: vorlage.rabatt ?? null,
zahlungsart: vorlage.zahlungsart ?? null, zahlungsart: vorlage.zahlungsart ?? null,
bezahlt: vorlage.bezahlt, bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm), bezahltAm: datum(vorlage.bezahltAm),
@@ -175,6 +174,8 @@ async function seedDemo() {
id: `prp-${l.id}`, id: `prp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`, bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.einkauf?.nettoBetrag ?? 0, nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
rabatt: vorlage.rabatt ?? null,
})), })),
}, },
}, },
+13
View File
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle"); const ALLE = process.argv.includes("--alle");
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
const MOBIL_ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--mobil")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "Handy" };
});
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */ /** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite") .filter((wert, i) => process.argv[i - 1] === "--seite")
@@ -187,6 +195,11 @@ async function main() {
console.log(` ${seite.name}.png – ${seite.titel}`); console.log(` ${seite.name}.png – ${seite.titel}`);
} }
for (const seite of MOBIL_ZUSATZ) {
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
if (ALLE) { if (ALLE) {
const dunkel = profilVorbereiten("dunkel", true); const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
+75 -9
View File
@@ -17,6 +17,7 @@ import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular"; import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts";
import { hostTokenWiderrufen } from "@/server/actions/tokens"; import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste"; import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts"; import { meldungsGrenze } from "@/server/queries/hosts";
@@ -108,6 +109,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2> <h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div> <div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null} {meldetNicht ? <KeineMeldungBadge /> : null}
{host.archiviertAm ? (
<div className="mt-2">
<span className="badge pl-bg-neutral">Stillgelegt</span>
</div>
) : null}
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
Hosts ist das die Stelle, an der man sie braucht (#040). */}
{host.tags.length > 0 ? (
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
{host.notizen ? (
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
{host.notizen}
</p>
) : null}
</div> </div>
<Zeile bezeichnung="Kunde"> <Zeile bezeichnung="Kunde">
@@ -177,6 +204,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
kundeId={host.kundeId} kundeId={host.kundeId}
standortId={host.standortId} standortId={host.standortId}
/> />
{/* Stilllegen statt löschen: Laufzeiten und Rechnungen gehören zur Buchhaltung (#041). */}
<div className="mt-3 pt-3 border-top d-flex flex-wrap gap-2">
<form action={hostArchivieren}>
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
</button>
</form>
{host.laufzeiten.length === 0 ? (
<form action={hostLoeschen}>
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Endgültig löschen
</button>
</form>
) : null}
</div>
<div className="form-text">
{host.archiviertAm
? `Stillgelegt am ${formatDatum(host.archiviertAm)} – erscheint nicht in Listen, Zahlen und Warnungen.`
: host.laufzeiten.length === 0
? "Stilllegen blendet den Host aus; löschen geht nur, solange keine Laufzeit daran hängt."
: "An diesem Host hängen Laufzeiten – er lässt sich nur stilllegen, nicht löschen."}
</div>
</Card> </Card>
</div> </div>
@@ -225,7 +278,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
inhalt: ( inhalt: (
<> <>
<h3 className="h6">Keys</h3> <h3 className="h6">Keys</h3>
<table className="table table-striped align-middle mb-4"> <div className="table-responsive mb-4">
<table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col">Key</th> <th scope="col">Key</th>
@@ -252,8 +306,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
<h3 className="h6">Server-IDs</h3> <h3 className="h6">Server-IDs</h3>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -281,6 +337,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
{host.serverIdHistorie.length > 1 ? ( {host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0"> <p className="small pl-text-warning mt-2 mb-0">
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
@@ -342,7 +399,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
icon: "bi-receipt", icon: "bi-receipt",
inhalt: ( inhalt: (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0 pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Laufzeit</th> <th scope="col">Laufzeit</th>
@@ -355,21 +412,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
</thead> </thead>
<tbody> <tbody>
{host.laufzeiten.map((laufzeit) => { {host.laufzeiten.map((laufzeit) => {
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkauf = laufzeit.proxmoxPosition const einkauf = laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag) ? Number(laufzeit.proxmoxPosition.nettoBetrag) -
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null; : null;
const verkauf = laufzeit.kundenPosition const verkauf = laufzeit.kundenPosition
? Number(laufzeit.kundenPosition.nettoBetrag) ? Number(laufzeit.kundenPosition.nettoBetrag)
: null; : null;
return ( return (
<tr key={laufzeit.id}> <tr key={laufzeit.id}>
<td> <td className="pl-karten-kopf">
<Link
href={`/rechnungen/${laufzeit.id}`}
className="fw-semibold text-decoration-none"
>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} {formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</Link>
{laufzeit.status === "VORSCHLAG" ? ( {laufzeit.status === "VORSCHLAG" ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span> <span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null} ) : null}
</td> </td>
<td> <td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} /> <CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
<div> <div>
<EinkaufBadge <EinkaufBadge
@@ -381,8 +445,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/> />
</div> </div>
</td> </td>
<td>{formatEuro(einkauf)}</td> <td data-label="Einkauf">{formatEuro(einkauf)}</td>
<td> <td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} /> <CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
<div> <div>
<VerkaufBadge <VerkaufBadge
@@ -391,8 +455,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/> />
</div> </div>
</td> </td>
<td>{formatEuro(verkauf)}</td> <td data-label="Verkauf">{formatEuro(verkauf)}</td>
<td> <td data-label="Marge">
{einkauf !== null && verkauf !== null {einkauf !== null && verkauf !== null
? formatEuro(verkauf - einkauf) ? formatEuro(verkauf - einkauf)
: "–"} : "–"}
@@ -429,6 +493,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
titel: "Tokens", titel: "Tokens",
icon: "bi-key-fill", icon: "bi-key-fill",
inhalt: ( inhalt: (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -476,6 +541,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
), ),
}, },
]} ]}
+29 -1
View File
@@ -68,6 +68,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const filter: HostFilter = { const filter: HostFilter = {
q: einzeln(roh.q), q: einzeln(roh.q),
kunde: einzeln(roh.kunde), kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag),
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
status: einzeln(roh.status), status: einzeln(roh.status),
produkt: einzeln(roh.produkt), produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"], ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
@@ -85,6 +87,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const basis: Record<string, string | undefined> = { const basis: Record<string, string | undefined> = {
q: filter.q, q: filter.q,
kunde: filter.kunde, kunde: filter.kunde,
tag: filter.tag,
archiviert: filter.archiviert,
status: filter.status, status: filter.status,
produkt: filter.produkt, produkt: filter.produkt,
ablauf: filter.ablauf, ablauf: filter.ablauf,
@@ -117,7 +121,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
</> </>
} }
> >
<HostFilterLeiste kunden={optionen.kunden} werte={basis} /> <HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
@@ -152,7 +156,31 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {host.meldetNicht ? <KeineMeldungBadge /> : null}
{host.archiviertAm ? (
<span className="badge pl-bg-neutral ms-2">Stillgelegt</span>
) : null}
{host.notizen ? (
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
<i
className="bi bi-sticky ms-2 text-body-secondary"
title={host.notizen}
aria-label={`Notiz: ${host.notizen}`}
/>
) : null}
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div> <div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
{host.tags.length > 0 ? (
<div className="mt-1 d-flex flex-wrap gap-1">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
</td> </td>
<td> <td>
{host.kunde ? ( {host.kunde ? (
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
ansprechpartner: kunde.ansprechpartner, ansprechpartner: kunde.ansprechpartner,
email: kunde.email, email: kunde.email,
telefon: kunde.telefon, telefon: kunde.telefon,
eigenbedarf: kunde.eigenbedarf,
notizen: kunde.notizen, notizen: kunde.notizen,
}} }}
/> />
+42 -8
View File
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle> <Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Laufzeit</th> <th scope="col">Laufzeit</th>
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
<th scope="col">Einkauf</th> <th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th> <th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th> <th scope="col">Verkauf</th>
<th scope="col">Marge</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{laufzeiten.map((laufzeit) => ( {laufzeiten.map((laufzeit) => (
<tr key={laufzeit.id}> <tr key={laufzeit.id}>
<td> {/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
Kundenansicht in eine Sackgasse (#043). */}
<td className="pl-karten-kopf">
<Link
href={`/rechnungen/${laufzeit.id}`}
className="fw-semibold text-decoration-none"
>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} {formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</Link>
{laufzeit.vorschlag ? ( {laufzeit.vorschlag ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span> <span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null} ) : null}
</td> </td>
<td>{laufzeit.hostname}</td> <td data-label="Host">
<td> <Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
{laufzeit.hostname}
</Link>
</td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} /> <CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
<div> <div>
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} /> <EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
</div> </div>
</td> </td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td> <td data-label="Einkauf">
<td> {formatEuro(laufzeit.einkauf?.nettoBetrag)}
{laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? (
<div className="small text-body-secondary">
{formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "}
Rabatt
</div>
) : null}
</td>
<td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} /> <CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
<div> <div>
<VerkaufBadge <VerkaufBadge
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
/> />
</div> </div>
</td> </td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td> <td data-label="Verkauf">{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td
data-label="Marge"
className={
laufzeit.einkauf && laufzeit.verkauf
? (laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag >= 0
? "pl-text-success"
: "pl-text-danger"
: undefined
}
>
{laufzeit.einkauf && laufzeit.verkauf
? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag)
: "–"}
</td>
</tr> </tr>
))} ))}
{laufzeiten.length === 0 ? ( {laufzeiten.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="text-center text-body-secondary py-4"> <td colSpan={7} className="text-center text-body-secondary py-4">
Noch keine Laufzeiten erfasst. Noch keine Laufzeiten erfasst.
</td> </td>
</tr> </tr>
+3
View File
@@ -60,6 +60,9 @@ export default async function KundenSeite() {
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none"> <Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name} {kunde.name}
</Link> </Link>
{kunde.eigenbedarf ? (
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
) : null}
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div> <div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td> </td>
<td className="pl-mono" data-label="Kundennummer"> <td className="pl-mono" data-label="Kundennummer">
+74 -8
View File
@@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen"; import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen"; import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen"; import { laufzeitDetail } from "@/server/queries/rechnungen";
import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten";
import { prisma } from "@/lib/db";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -25,16 +27,35 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null; const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.rechnung ?? null; const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null; // Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025). // Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]); const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([
einstellungen(),
faelligkeitsVorschlag(),
prisma.laufzeit.findMany({
where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } },
orderBy: { start: "desc" },
select: {
id: true,
key: true,
start: true,
ende: true,
proxmoxPositionId: true,
kundenPositionId: true,
},
}),
]);
const verkaufVorschlag = const verkaufVorschlag =
verkaufBetrag ?? verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100); (einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`; const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return ( return (
@@ -109,7 +130,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<hr /> <hr />
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt> <dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd> <dd className="col-7">
{formatEuro(einkaufBetrag)}
{einkaufRabatt > 0 ? (
<span className="small text-body-secondary d-block">
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
</span>
) : null}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt> <dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd> <dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt> <dt className="col-5 fw-normal text-body-secondary">Marge</dt>
@@ -117,6 +145,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
{marge === null ? "–" : formatEuro(marge)} {marge === null ? "–" : formatEuro(marge)}
</dd> </dd>
</dl> </dl>
<hr />
<LaufzeitBearbeiten
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
schluessel={laufzeit.key}
start={laufzeit.start.toISOString().slice(0, 10)}
ende={laufzeit.ende.toISOString().slice(0, 10)}
notizen={laufzeit.notizen}
nachbarn={nachbarn.map((nachbar) => ({
id: nachbar.id,
zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`,
key: nachbar.key,
hatEinkauf: nachbar.proxmoxPositionId !== null,
hatVerkauf: nachbar.kundenPositionId !== null,
}))}
/>
</Card> </Card>
{einkauf && einkauf.positionen.length > 1 ? ( {einkauf && einkauf.positionen.length > 1 ? (
@@ -135,7 +180,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
) : ( ) : (
position.bezeichnung position.bezeichnung
)}{" "} )}{" "}
· {formatEuro(Number(position.nettoBetrag))} ·{" "}
{position.rabatt ? (
<>
{formatEuro(Number(position.nettoBetrag))} − {formatEuro(Number(position.rabatt))}{" "}
= <strong>{formatEuro(Number(position.nettoBetrag) - Number(position.rabatt))}</strong>
</>
) : (
formatEuro(Number(position.nettoBetrag))
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -156,12 +209,13 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
nummer: einkauf?.nummer ?? null, nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null, datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag, faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag, nettoBetrag: einkaufListenpreis,
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz), ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt: rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined laufzeit.proxmoxPosition?.rabatt === null ||
laufzeit.proxmoxPosition?.rabatt === undefined
? null ? null
: Number(einkauf.rabatt), : Number(laufzeit.proxmoxPosition.rabatt),
zahlungsart: einkauf?.zahlungsart ?? null, zahlungsart: einkauf?.zahlungsart ?? null,
bezahlt: einkauf?.bezahlt ?? false, bezahlt: einkauf?.bezahlt ?? false,
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null, bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
@@ -173,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<Card <Card
titel="Kundenrechnung (Verkauf)" titel="Kundenrechnung (Verkauf)"
rahmen={verkauf?.erstellt ? "info" : "warning"} rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
werkzeuge={ werkzeuge={
eigenbedarf ? (
<span className="badge pl-bg-neutral">Eigenbedarf</span>
) : (
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} /> <VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
)
} }
> >
{eigenbedarf ? (
<p className="mb-0 text-body-secondary">
<strong>{laufzeit.host.kunde?.name}</strong> ist als Eigenbedarf gekennzeichnet – diese
Hosts werden nicht weiterberechnet. Deshalb gibt es hier keine Kundenrechnung, keine
Warnung darüber und keine Marge. Die Proxmox-Rechnung wird weiterhin geführt.
</p>
) : (
<KundenrechnungFormular <KundenrechnungFormular
laufzeitId={laufzeit.id} laufzeitId={laufzeit.id}
hostId={laufzeit.host.id} hostId={laufzeit.host.id}
@@ -194,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
anhangName: verkauf?.anhang?.dateiname ?? null, anhangName: verkauf?.anhang?.dateiname ?? null,
}} }}
/> />
)}
</Card> </Card>
</div> </div>
</div> </div>
+22 -3
View File
@@ -8,6 +8,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { InfoBox } from "@/components/ui/SmallBox"; import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDatum, formatEuro, tageBis } from "@/lib/format"; import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen";
import { import {
anzahlVorschlaege, anzahlVorschlaege,
laufzeitJahre, laufzeitJahre,
@@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
q: einzeln(roh.q), q: einzeln(roh.q),
}; };
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([ const [{ zeilen, summen }, jahre, kunden, vorschlaege, hostsFuerLaufzeit] = await Promise.all([
rechnungsListe(filter), rechnungsListe(filter),
laufzeitJahre(), laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(), anzahlVorschlaege(),
prisma.host.findMany({
where: { archiviertAm: null },
orderBy: { hostname: "asc" },
select: { id: true, hostname: true, subKey: true, kunde: { select: { name: true } } },
}),
]); ]);
const heute = new Date(); const heute = new Date();
@@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div> </div>
</div> </div>
{/* Laufzeiten entstehen sonst aus den Agenten-Reports; von Hand braucht man es für
Hosts ohne gemeldetes Ablaufdatum oder für Nachträge (#043). */}
<Card titel="Laufzeit von Hand anlegen" rahmen="keiner">
<LaufzeitAnlegen
hosts={hostsFuerLaufzeit.map((host) => ({
id: host.id,
hostname: host.hostname,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
}))}
/>
</Card>
<Card <Card
titel="Laufzeiten und Rechnungen" titel="Laufzeiten und Rechnungen"
rahmen="primary" rahmen="primary"
@@ -204,7 +223,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
<th scope="col" className="d-none d-md-table-cell"> <th scope="col" className="d-none d-md-table-cell">
Host Host
</th> </th>
<th scope="col" className="d-none d-lg-table-cell"> <th scope="col" className="pl-spalte-laufzeit">
Laufzeit Laufzeit
</th> </th>
<th scope="col">Proxmox-Rechnung</th> <th scope="col">Proxmox-Rechnung</th>
@@ -254,7 +273,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
{zeile.hostname} {zeile.hostname}
</Link> </Link>
</td> </td>
<td className="d-none d-lg-table-cell" data-label="Laufzeit"> <td className="pl-spalte-laufzeit" data-label="Laufzeit">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none"> <Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)} {formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link> </Link>
+42
View File
@@ -395,6 +395,24 @@ body {
width: 13rem; width: 13rem;
} }
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
.pl-klappliste {
width: 22rem;
max-width: calc(100vw - 1rem);
}
/*
* Die Laufzeit-Spalte ist auf dem Telefon der einzige Weg zu den Rechnungsformularen, deshalb
* bleibt sie dort sichtbar. Zwischen 768 und 992 px ist die Tabelle am engsten – dort entfällt sie.
* Bewusst eine eigene Klasse statt der Bootstrap-Hilfsklassen: deren `display: … !important`
* überschreibt das Kartenlayout (#039).
*/
@media (min-width: 768px) and (max-width: 991.98px) {
.pl-spalte-laufzeit {
display: none;
}
}
/* ---------- Mobil ---------- */ /* ---------- Mobil ---------- */
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
@@ -425,6 +443,30 @@ body {
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen. * Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
*/ */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
/*
* Auf dem Telefon ist die Klappliste breiter als das Fenster – der Browser zoomt dann die
* **ganze Seite** heraus, damit sie hineinpasst. Deshalb hier fest am Fenster ausrichten
* statt am Knopf (#038).
*/
.pl-klappliste {
position: fixed;
top: 3.5rem;
right: 0.5rem;
left: 0.5rem;
width: auto;
max-width: none;
max-height: 70vh;
overflow-y: auto;
}
/*
* Lange Schlüssel und Server-IDs dürfen umbrechen. Sonst machen sie die Seite breiter als das
* Fenster – und der Browser zoomt die ganze Seite heraus, damit sie hineinpasst (#038).
*/
.pl-mono {
overflow-wrap: anywhere;
}
.pl-karten-tabelle, .pl-karten-tabelle,
.pl-karten-tabelle tbody, .pl-karten-tabelle tbody,
.pl-karten-tabelle tr, .pl-karten-tabelle tr,
@@ -119,8 +119,8 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
<Feld <Feld
name="steuersatz" name="steuersatz"
beschriftung="Steuersatz in Prozent" beschriftung="Steuersatz Verkauf in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen." hinweis="Vorgabe für neue Kundenrechnungen."
fehler={fehler.steuersatz} fehler={fehler.steuersatz}
> >
<input <input
@@ -135,6 +135,24 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
/> />
</Feld> </Feld>
<Feld
name="steuersatzEinkauf"
beschriftung="Steuersatz Einkauf in Prozent"
hinweis="Für die Proxmox-Rechnungen. 0 bei Reverse Charge – der Normalfall mit USt-IdNr."
fehler={fehler.steuersatzEinkauf}
>
<input
type="number"
step="0.1"
min={0}
max={100}
className="form-control"
id="steuersatzEinkauf"
name="steuersatzEinkauf"
defaultValue={werte.steuersatzEinkauf}
/>
</Feld>
<Feld <Feld
name="zahlungszielTage" name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen" beschriftung="Zahlungsziel in Tagen"
+38 -1
View File
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
*/ */
export function HostFilterLeiste({ export function HostFilterLeiste({
kunden, kunden,
tags,
werte, werte,
}: { }: {
kunden: { id: string; name: string }[]; kunden: { id: string; name: string }[];
tags: string[];
werte: Record<string, string | undefined>; werte: Record<string, string | undefined>;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
router.push(`${pathname}?${neu.toString()}`); router.push(`${pathname}?${neu.toString()}`);
} }
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter( const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "archiviert", "q"].filter(
(schluessel) => werte[schluessel], (schluessel) => werte[schluessel],
).length; ).length;
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
</select> </select>
</div> </div>
{tags.length > 0 ? (
<div className="col-6 col-md-2">
<label className="visually-hidden" htmlFor="tag">
Schlagwort
</label>
<select
className="form-select"
id="tag"
value={werte.tag ?? ""}
onChange={(e) => setzen("tag", e.target.value)}
>
<option value="">Alle Schlagworte</option>
{tags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
) : null}
<div className="col-6 col-md-2"> <div className="col-6 col-md-2">
<select <select
className="form-select" className="form-select"
@@ -135,6 +158,20 @@ export function HostFilterLeiste({
nur mit offenen Rechnungspunkten nur mit offenen Rechnungspunkten
</label> </label>
</div> </div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="archiviert"
checked={werte.archiviert === "ja"}
onChange={(e) => setzen("archiviert", e.target.checked ? "ja" : "")}
/>
<label className="form-check-label small" htmlFor="archiviert">
stillgelegte Hosts zeigen
</label>
</div>
{aktiveFilter > 0 ? ( {aktiveFilter > 0 ? (
<Link href="/hosts" className="small"> <Link href="/hosts" className="small">
Filter zurücksetzen ({aktiveFilter}) Filter zurücksetzen ({aktiveFilter})
+21
View File
@@ -11,6 +11,7 @@ export interface KundeFormularWerte {
ansprechpartner: string | null; ansprechpartner: string | null;
email: string | null; email: string | null;
telefon: string | null; telefon: string | null;
eigenbedarf: boolean;
notizen: string | null; notizen: string | null;
} }
@@ -98,6 +99,26 @@ export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} /> <input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
</div> </div>
<div className="col-12">
<div className="form-check">
<input
className="form-check-input pl-touch-target"
type="checkbox"
id="eigenbedarf"
name="eigenbedarf"
value="an"
defaultChecked={werte?.eigenbedarf ?? false}
/>
<label className="form-check-label" htmlFor="eigenbedarf">
Eigenbedarf – diese Hosts werden nicht weiterberechnet
</label>
<div className="form-text">
Für solche Kunden entfällt die Kundenrechnung: keine Warnungen darüber, keine Marge. Die
Einkaufsrechnung von Proxmox wird weiterhin geführt.
</div>
</div>
</div>
<div className="col-12"> <div className="col-12">
<label className="form-label" htmlFor="notizen"> <label className="form-label" htmlFor="notizen">
Notizen Notizen
+4 -3
View File
@@ -140,8 +140,7 @@ export function Header({
) : null} ) : null}
</button> </button>
<div <div
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`} className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
style={{ width: "22rem" }}
> >
<span className="dropdown-header">{alerts.length} offene Warnungen</span> <span className="dropdown-header">{alerts.length} offene Warnungen</span>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
@@ -206,7 +205,9 @@ export function Header({
<i className="bi bi-person-circle" aria-hidden="true" /> <i className="bi bi-person-circle" aria-hidden="true" />
<span className="d-none d-sm-inline">{benutzer}</span> <span className="d-none d-sm-inline">{benutzer}</span>
</button> </button>
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}> <div
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
>
<span className="dropdown-header"> <span className="dropdown-header">
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]} Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
</span> </span>
@@ -0,0 +1,131 @@
"use client";
import { useActionState, useState } from "react";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import { laufzeitAnlegenAktion } from "@/server/actions/rechnungen";
const LEER: AktionsErgebnis = {};
/** Ein Jahr minus einen Tag – dieselbe Rechnung wie beim Vorschlag aus dem Agenten-Report. */
function endeAus(start: string): string {
if (!start) return "";
const datum = new Date(`${start}T00:00:00Z`);
datum.setUTCFullYear(datum.getUTCFullYear() + 1);
datum.setUTCDate(datum.getUTCDate() - 1);
return datum.toISOString().slice(0, 10);
}
/**
* Laufzeit von Hand anlegen (#043).
*
* Der Normalfall ist der Vorschlag aus dem Agenten-Report. Von Hand braucht man es, wenn ein Host
* (noch) kein Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein
* zurückliegendes Lizenzjahr nachgetragen werden soll.
*/
export function LaufzeitAnlegen({
hosts,
}: {
hosts: { id: string; hostname: string; kundeName: string | null; subKey: string | null }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(laufzeitAnlegenAktion, LEER);
const [hostId, setHostId] = useState("");
const [key, setKey] = useState("");
const [start, setStart] = useState("");
const [ende, setEnde] = useState("");
const gewaehlt = hosts.find((host) => host.id === hostId);
return (
<form action={aktion} className="row g-3 align-items-start">
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="lz-host">
Host
</label>
<select
className="form-select"
id="lz-host"
name="hostId"
value={hostId}
onChange={(e) => {
setHostId(e.target.value);
// Den gemeldeten Key vorschlagen – abtippen ist hier die häufigste Fehlerquelle.
const host = hosts.find((h) => h.id === e.target.value);
if (host?.subKey) setKey(host.subKey);
}}
required
>
<option value="">– bitte wählen –</option>
{hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.hostname}
{host.kundeName ? ` · ${host.kundeName}` : " · nicht zugeordnet"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="lz-key">
Subscription-Key
</label>
<input
className="form-control pl-mono"
id="lz-key"
name="key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="pve2s-…"
required
/>
{gewaehlt?.subKey ? (
<div className="form-text">Gemeldet: {gewaehlt.subKey}</div>
) : (
<div className="form-text">Dieser Host meldet keinen Key – Nummer aus der Bestellung eintragen.</div>
)}
</div>
<div className="col-6 col-md-2">
<label className="form-label" htmlFor="lz-start">
Beginn
</label>
<input
type="date"
className="form-control"
id="lz-start"
name="start"
value={start}
onChange={(e) => {
setStart(e.target.value);
setEnde(endeAus(e.target.value));
}}
required
/>
</div>
<div className="col-6 col-md-2">
<label className="form-label" htmlFor="lz-ende">
Ende
</label>
<input
type="date"
className="form-control"
id="lz-ende"
name="ende"
value={ende}
onChange={(e) => setEnde(e.target.value)}
required
/>
<div className="form-text">Ein Jahr, vorbelegt.</div>
</div>
<div className="col-12">
<button type="submit" className="btn btn-outline-primary" disabled={laeuft}>
{laeuft ? "Wird angelegt …" : "Laufzeit anlegen"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useActionState, useState } from "react";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import {
laufzeitBearbeitenAktion,
laufzeitenZusammenfuehrenAktion,
} from "@/server/actions/rechnungen";
const LEER: AktionsErgebnis = {};
export interface NachbarLaufzeit {
id: string;
zeitraum: string;
key: string;
hatEinkauf: boolean;
hatVerkauf: boolean;
}
/**
* Laufzeit nachträglich ändern und mit einer anderen desselben Hosts zusammenführen (#043).
*
* Nötig, weil Laufzeiten meist aus einem Agenten-Report entstehen: Weicht das gemeldete
* Ablaufdatum von der Bestellung ab, steht die Laufzeit falsch da – oder gleich doppelt.
*/
export function LaufzeitBearbeiten({
laufzeitId,
hostId,
// Nicht `key` nennen: Das ist in React reserviert und käme hier nie an.
schluessel,
start,
ende,
notizen,
nachbarn,
}: {
laufzeitId: string;
hostId: string;
schluessel: string;
start: string;
ende: string;
notizen: string | null;
nachbarn: NachbarLaufzeit[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(laufzeitBearbeitenAktion, LEER);
const [zusammen, zusammenAktion, fuehrtZusammen] = useActionState(laufzeitenZusammenfuehrenAktion, LEER);
const [offen, setOffen] = useState(false);
return (
<>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setOffen(!offen)}
aria-expanded={offen}
>
<i className="bi bi-pencil me-1" aria-hidden="true" />
{offen ? "Bearbeiten schließen" : "Laufzeit bearbeiten"}
</button>
{offen ? (
<div className="mt-3">
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
<form action={aktion} className="row g-3 align-items-start">
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="lzb-key">
Subscription-Key
</label>
<input
className="form-control pl-mono"
id="lzb-key"
name="key"
defaultValue={schluessel}
required
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="lzb-start">
Beginn
</label>
<input type="date" className="form-control" id="lzb-start" name="start" defaultValue={start} required />
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="lzb-ende">
Ende
</label>
<input type="date" className="form-control" id="lzb-ende" name="ende" defaultValue={ende} required />
</div>
<div className="col-12">
<label className="form-label" htmlFor="lzb-notizen">
Notiz
</label>
<textarea
className="form-control"
id="lzb-notizen"
name="notizen"
rows={2}
defaultValue={notizen ?? ""}
/>
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Änderung speichern"}
</button>
</div>
</form>
{nachbarn.length > 0 ? (
<div className="mt-4 pt-3 border-top">
<h4 className="h6">Mit einer anderen Laufzeit zusammenführen</h4>
<p className="small text-body-secondary">
Die gewählte Laufzeit wird aufgelöst; ihre Rechnungen wandern hierher, sofern hier noch
keine stehen. Nützlich, wenn der Agent neben einer erfassten Laufzeit einen Vorschlag
mit abweichendem Datum angelegt hat.
</p>
{zusammen.fehler ? <div className="alert alert-danger py-2">{zusammen.fehler}</div> : null}
{zusammen.erfolg ? <div className="alert alert-success py-2">{zusammen.erfolg}</div> : null}
<form action={zusammenAktion} className="row g-2 align-items-end">
<input type="hidden" name="zielId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<div className="col-12 col-md-8">
<label className="form-label" htmlFor="lzb-quelle">
Diese Laufzeit auflösen
</label>
<select className="form-select" id="lzb-quelle" name="quelleId" required>
<option value="">– bitte wählen –</option>
{nachbarn.map((nachbar) => (
<option key={nachbar.id} value={nachbar.id}>
{nachbar.zeitraum} · {nachbar.key}
{nachbar.hatEinkauf ? " · mit Einkauf" : ""}
{nachbar.hatVerkauf ? " · mit Verkauf" : ""}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<button type="submit" className="btn btn-outline-danger" disabled={fuehrtZusammen}>
{fuehrtZusammen ? "Wird zusammengeführt …" : "Zusammenführen"}
</button>
</div>
</form>
</div>
) : null}
</div>
) : null}
</>
);
}
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useActionState } from "react"; import { useActionState, useState } from "react";
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen"; import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
import type { AktionsErgebnis } from "@/server/actions/kunden"; import type { AktionsErgebnis } from "@/server/actions/kunden";
import { formatEuro } from "@/lib/format";
const LEER: AktionsErgebnis = {}; const LEER: AktionsErgebnis = {};
@@ -42,6 +43,13 @@ export function ProxmoxRechnungFormular({
}) { }) {
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER); const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
// Listenpreis und Rabatt im Zustand, damit der tatsächlich gezahlte Betrag sofort danebensteht.
const [netto, setNetto] = useState(werte.nettoBetrag === null ? "" : String(werte.nettoBetrag));
const [rabatt, setRabatt] = useState(werte.rabatt === null ? "" : String(werte.rabatt));
const zahl = (wert: string) => Number(wert.replace(",", ".")) || 0;
const nettoZahl = zahl(netto);
const rabattZahl = zahl(rabatt);
return ( return (
<form action={aktion}> <form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} /> <input type="hidden" name="laufzeitId" value={laufzeitId} />
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label" htmlFor="px-netto"> <label className="form-label" htmlFor="px-netto">
Netto (€) * Listenpreis netto (€) *
</label> </label>
<input <input
id="px-netto" id="px-netto"
name="nettoBetrag" name="nettoBetrag"
className="form-control" className="form-control"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""} value={netto}
onChange={(e) => setNetto(e.target.value)}
required required
/> />
<div className="form-text">Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.</div>
</div> </div>
<div className="col-md-2"> <div className="col-md-2">
<label className="form-label" htmlFor="px-ust"> <label className="form-label" htmlFor="px-ust">
@@ -125,8 +135,18 @@ export function ProxmoxRechnungFormular({
name="rabatt" name="rabatt"
className="form-control" className="form-control"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.rabatt ?? ""} value={rabatt}
onChange={(e) => setRabatt(e.target.value)}
/> />
<div className="form-text">
{rabattZahl > 0 ? (
<>
Gerechnet wird mit <strong>{formatEuro(nettoZahl - rabattZahl)}</strong>.
</>
) : (
"Proxmox weist ihn je Key als eigene Zeile aus („Client Discount“)."
)}
</div>
</div> </div>
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label" htmlFor="px-zahlungsart"> <label className="form-label" htmlFor="px-zahlungsart">
+7 -1
View File
@@ -55,8 +55,14 @@ export function eigeneAdresse(request: Request, pfad: string, eingetragen?: stri
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt. * Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
*/ */
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> { export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
let kopfzeilen: Headers;
try {
const { headers } = await import("next/headers"); const { headers } = await import("next/headers");
const kopfzeilen = await headers(); kopfzeilen = await headers();
} catch {
// Außerhalb einer Anfrage (Tests, Skripte) gibt es keine Kopfzeilen – dann bleibt der Pfad relativ.
return pfad;
}
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt. // Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen); const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
+1
View File
@@ -35,6 +35,7 @@ export async function einstellungenSpeichernAktion(
waehrung: text(formular, "waehrung") || vorher.waehrung, waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"), steuersatz: zahl(formular, "steuersatz"),
steuersatzEinkauf: zahl(formular, "steuersatzEinkauf"),
zahlungszielTage: zahl(formular, "zahlungszielTage"), zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"), margeProzent: zahl(formular, "margeProzent"),
+60
View File
@@ -1,6 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eigenerPfad } from "@/lib/adresse";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
revalidatePath("/kunden"); revalidatePath("/kunden");
revalidatePath("/"); revalidatePath("/");
} }
/**
* Legt einen Host still: Er verschwindet aus Listen, Zahlen und Warnungen, bleibt aber mit seiner
* Geschichte erhalten (#041). Gedacht für Maschinen, die es nicht mehr gibt – oder für einen
* doppelten Eintrag, der entstanden ist, weil sich die Maschinenkennung geändert hat.
*/
export async function hostArchivieren(formular: FormData) {
await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
if (!hostId) return;
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { archiviertAm: true } });
if (!host) return;
const stilllegen = host.archiviertAm === null;
await prisma.host.update({
where: { id: hostId },
data: { archiviertAm: stilllegen ? new Date() : null },
});
// Offene Warnungen eines stillgelegten Hosts schließen – sonst bleiben sie für immer stehen.
if (stilllegen) {
await prisma.alert.updateMany({
where: { hostId, erledigtAm: null },
data: { erledigtAm: new Date() },
});
}
await protokolliere(stilllegen ? "host.stillgelegt" : "host.reaktiviert", "Host", hostId, {});
revalidatePath(`/hosts/${hostId}`);
revalidatePath("/hosts");
revalidatePath("/");
}
/**
* Löscht einen Host endgültig – nur, solange keine Laufzeit daran hängt. Sobald Rechnungen im
* Spiel sind, bleibt nur das Stilllegen: Buchhaltung wirft man nicht weg.
*/
export async function hostLoeschen(formular: FormData): Promise<void> {
await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
if (!hostId) return;
const laufzeiten = await prisma.laufzeit.count({ where: { hostId } });
if (laufzeiten > 0) return;
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { hostname: true } });
if (!host) return;
await prisma.host.delete({ where: { id: hostId } });
await protokolliere("host.geloescht", "Host", hostId, { hostname: host.hostname });
revalidatePath("/hosts");
revalidatePath("/");
redirect(await eigenerPfad("/hosts"));
}
+2
View File
@@ -25,6 +25,7 @@ const kundeSchema = z.object({
ansprechpartner: z.string().nullable(), ansprechpartner: z.string().nullable(),
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]), email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
telefon: z.string().nullable(), telefon: z.string().nullable(),
eigenbedarf: z.boolean().default(false),
notizen: z.string().nullable(), notizen: z.string().nullable(),
}); });
@@ -38,6 +39,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
ansprechpartner: leerZuNull(formular.get("ansprechpartner")), ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
email: leerZuNull(formular.get("email")), email: leerZuNull(formular.get("email")),
telefon: leerZuNull(formular.get("telefon")), telefon: leerZuNull(formular.get("telefon")),
eigenbedarf: formular.get("eigenbedarf") === "an",
notizen: leerZuNull(formular.get("notizen")), notizen: leerZuNull(formular.get("notizen")),
}); });
+45
View File
@@ -7,6 +7,8 @@ import {
einkaufBezahltSetzen, einkaufBezahltSetzen,
kundenrechnungErfassen, kundenrechnungErfassen,
laufzeitAnlegen, laufzeitAnlegen,
laufzeitBearbeiten,
laufzeitenZusammenfuehren,
laufzeitBestaetigen, laufzeitBestaetigen,
laufzeitVerwerfen, laufzeitVerwerfen,
proxmoxRechnungErfassen, proxmoxRechnungErfassen,
@@ -173,3 +175,46 @@ export async function laufzeitAnlegenAktion(
return "Laufzeit angelegt."; return "Laufzeit angelegt.";
}); });
} }
export async function laufzeitBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("laufzeitId")?.toString();
const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start"));
const ende = datum(formular.get("ende"));
const notizen = formular.get("notizen")?.toString().trim() || null;
if (!id || !hostId || !key || !start || !ende) {
return { fehler: "Bitte Key, Beginn und Ende angeben." };
}
return ausfuehren(async () => {
await laufzeitBearbeiten(id, { key, start, ende, notizen });
seitenNeuLaden(hostId);
return "Laufzeit geändert.";
});
}
export async function laufzeitenZusammenfuehrenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("fachlichSchreiben");
const zielId = formular.get("zielId")?.toString();
const quelleId = formular.get("quelleId")?.toString();
const hostId = formular.get("hostId")?.toString();
if (!zielId || !quelleId || !hostId) return { fehler: "Bitte eine Laufzeit zum Zusammenführen wählen." };
return ausfuehren(async () => {
await laufzeitenZusammenfuehren(zielId, quelleId);
seitenNeuLaden(hostId);
return "Laufzeiten zusammengeführt.";
});
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
import { AgentApiFehler } from "./service"; import { AgentApiFehler } from "./service";
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */ /** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
export const AGENT_VERSION = "0.2.1"; export const AGENT_VERSION = "0.2.2";
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) { export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra }); return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
+12 -2
View File
@@ -9,15 +9,23 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> { export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
const [hosts, laufzeiten, einstellungen] = await Promise.all([ const [hosts, laufzeiten, einstellungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
where: { archiviertAm: null },
include: { include: {
kunde: { select: { name: true } }, kunde: { select: { name: true } },
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } }, serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
}, },
}), }),
prisma.laufzeit.findMany({ prisma.laufzeit.findMany({
where: { status: "BESTAETIGT" }, where: { status: "BESTAETIGT", host: { archiviertAm: null } },
include: { include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } }, host: {
select: {
hostname: true,
kundeId: true,
kunde: { select: { name: true, eigenbedarf: true } },
},
},
proxmoxPosition: { include: { rechnung: true } }, proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } }, kundenPosition: { include: { rechnung: true } },
}, },
@@ -40,6 +48,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
subLizenzierteSockets: host.subLizenzierteSockets, subLizenzierteSockets: host.subLizenzierteSockets,
sockets: host.sockets, sockets: host.sockets,
letzterReportAm: host.letzterReportAm, letzterReportAm: host.letzterReportAm,
ips: host.ips,
serverIds: host.serverIdHistorie, serverIds: host.serverIdHistorie,
})), })),
laufzeiten: laufzeiten.map((laufzeit) => ({ laufzeiten: laufzeiten.map((laufzeit) => ({
@@ -48,6 +57,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
hostname: laufzeit.host.hostname, hostname: laufzeit.host.hostname,
kundeId: laufzeit.host.kundeId, kundeId: laufzeit.host.kundeId,
kundeName: laufzeit.host.kunde?.name ?? null, kundeName: laufzeit.host.kunde?.name ?? null,
kundeEigenbedarf: laufzeit.host.kunde?.eigenbedarf ?? false,
key: laufzeit.key, key: laufzeit.key,
start: laufzeit.start, start: laufzeit.start,
ende: laufzeit.ende, ende: laufzeit.ende,
+89 -1
View File
@@ -32,6 +32,7 @@ export interface HostDaten {
subLizenzierteSockets: number | null; subLizenzierteSockets: number | null;
sockets: number | null; sockets: number | null;
letzterReportAm: Date | null; letzterReportAm: Date | null;
ips: string[];
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[]; serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
} }
@@ -40,6 +41,8 @@ export interface LaufzeitDaten {
hostId: string; hostId: string;
hostname: string; hostname: string;
kundeId: string | null; kundeId: string | null;
/** Eigene Hosts werden nicht weiterberechnet – die Verkaufsseite entfällt (#044). */
kundeEigenbedarf: boolean;
kundeName: string | null; kundeName: string | null;
key: string; key: string;
start: Date; start: Date;
@@ -358,7 +361,10 @@ const kundenrechnungFehlt: Regel = {
standardSchwellwerte: {}, standardSchwellwerte: {},
pruefe(kontext) { pruefe(kontext) {
return kontext.laufzeiten return kontext.laufzeiten
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt) .filter(
(laufzeit) =>
laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt && !laufzeit.kundeEigenbedarf,
)
.filter((laufzeit) => laufzeit.kundeId !== null) .filter((laufzeit) => laufzeit.kundeId !== null)
.map((laufzeit) => ({ .map((laufzeit) => ({
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`, fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
@@ -383,6 +389,7 @@ const kundenrechnungUnbezahlt: Regel = {
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30); const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
return kontext.laufzeiten.flatMap((laufzeit) => { return kontext.laufzeiten.flatMap((laufzeit) => {
if (laufzeit.kundeEigenbedarf) return [];
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return []; if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt); const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
if (offenSeit < tage) return []; if (offenSeit < tage) return [];
@@ -405,6 +412,85 @@ const kundenrechnungUnbezahlt: Regel = {
}, },
}; };
/**
* Zwei Hosts desselben Kunden mit demselben Namen (#041).
*
* Das ist entweder gewollt – zwei Maschinen in getrennten Netzen heißen eben gleich – oder ein
* Doppeleintrag: Hat eine Maschine ihre Kennung aus `/etc/machine-id` verloren (Neuinstallation,
* geklonter Container), registriert sie sich als neuer Host, und der alte Eintrag bleibt stehen.
* Welcher Fall vorliegt, weiß nur der Betreiber – deshalb ein Hinweis mit den IP-Adressen zum
* Unterscheiden und nicht mehr.
*/
const hostnameDoppelt: Regel = {
name: "hostname-doppelt",
beschreibung: "Mehrere Hosts desselben Kunden tragen denselben Namen",
standardSchwellwerte: {},
pruefe(kontext) {
const gruppen = new Map<string, HostDaten[]>();
for (const host of kontext.hosts) {
// Ohne Kunde ist der Vergleich wenig wert: Gleiche Namen sind dort der Normalfall.
if (!host.kundeId) continue;
const schluessel = `${host.kundeId}:${host.hostname.toLowerCase()}`;
gruppen.set(schluessel, [...(gruppen.get(schluessel) ?? []), host]);
}
return [...gruppen.values()]
.filter((gruppe) => gruppe.length > 1)
.map((gruppe) => {
const beschreibungen = gruppe.map(
(host) => `${host.ips.join(", ") || "keine IP gemeldet"}`,
);
return {
// Der Fingerabdruck hängt an Kunde und Name, nicht an den einzelnen Hosts – sonst gäbe
// es für jede Maschine eine eigene Warnung über denselben Sachverhalt.
fingerprint: `hostname-doppelt:${gruppe[0].kundeId}:${gruppe[0].hostname.toLowerCase()}`,
regel: "hostname-doppelt",
schwere: "INFO" as AlertSchwere,
titel: `${gruppe.length} Hosts heißen „${gruppe[0].hostname}“`,
beschreibung: `${kundenHinweis(gruppe[0].kundeName)} · ${beschreibungen.join(" | ")} · Entweder getrennte Maschinen – dann helfen Schlagworte – oder ein Doppeleintrag nach Neuinstallation; den alten Host dann stilllegen`,
hostId: gruppe[0].id,
kundeId: gruppe[0].kundeId,
laufzeitId: null,
};
});
},
};
/**
* Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
* bezahlt und die Einkaufsrechnung ist noch offen, liegt das Geld da und die Rechnung auch – das ist
* der Moment zum Zahlen, unabhängig vom Fälligkeitsdatum (#037).
*/
const kundeBezahltEinkaufOffen: Regel = {
name: "kunde-bezahlt-einkauf-offen",
beschreibung: "Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.laufzeiten.flatMap((laufzeit) => {
// Beim Eigenbedarf gibt es keine Kundenzahlung, auf die man warten würde.
if (laufzeit.kundeEigenbedarf) return [];
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
return [
{
fingerprint: `kunde-bezahlt-einkauf-offen:${laufzeit.id}`,
regel: "kunde-bezahlt-einkauf-offen",
schwere: "INFO" as AlertSchwere,
titel: "Kunde hat bezahlt – Proxmox-Rechnung noch offen",
beschreibung: `${laufzeit.hostname} · ${kundenHinweis(laufzeit.kundeName)} · Einkauf ${
laufzeit.einkaufNummer ?? "ohne Nummer"
} über ${formatEuro(laufzeit.einkaufBetrag)} netto ist noch nicht bezahlt`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
},
];
});
},
};
export const REGELN: Regel[] = [ export const REGELN: Regel[] = [
subscriptionLaeuftAb, subscriptionLaeuftAb,
statusNichtAktiv, statusNichtAktiv,
@@ -417,6 +503,8 @@ export const REGELN: Regel[] = [
proxmoxRechnungFaellig, proxmoxRechnungFaellig,
kundenrechnungFehlt, kundenrechnungFehlt,
kundenrechnungUnbezahlt, kundenrechnungUnbezahlt,
kundeBezahltEinkaufOffen,
hostnameDoppelt,
]; ];
/** Wendet alle aktiven Regeln an. */ /** Wendet alle aktiven Regeln an. */
+6
View File
@@ -24,7 +24,13 @@ export const einstellungenSchema = z.object({
// --- Kaufmännisches --- // --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"), waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
/** Steuersatz der Kundenrechnungen. */
steuersatz: z.number().min(0).max(100).default(19), steuersatz: z.number().min(0).max(100).default(19),
/**
* Steuersatz der Proxmox-Rechnungen. Vorgabe 0: Proxmox sitzt in Österreich und rechnet
* gegenüber Unternehmen mit USt-IdNr. über Reverse Charge ab (#037).
*/
steuersatzEinkauf: z.number().min(0).max(100).default(0),
zahlungszielTage: z.number().int().min(0).max(120).default(14), zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */ /** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20), margeProzent: z.number().min(0).max(500).default(20),
+4 -3
View File
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
const einkaufSichtbar = await darfEinkaufSehen(); const einkaufSichtbar = await darfEinkaufSehen();
const hosts = await prisma.host.findMany({ const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) }, where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: { subNextduedate: "asc" }, orderBy: { subNextduedate: "asc" },
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
@@ -72,7 +72,7 @@ export async function offeneAufgaben(grenze = 12) {
}, },
orderBy: { start: "asc" }, orderBy: { start: "asc" },
include: { include: {
host: { select: { id: true, hostname: true, kunde: { select: { name: true } } } }, host: { select: { id: true, hostname: true, kunde: { select: { name: true, eigenbedarf: true } } } },
proxmoxPosition: { include: { rechnung: true } }, proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } }, kundenPosition: { include: { rechnung: true } },
}, },
@@ -111,7 +111,8 @@ export async function offeneAufgaben(grenze = 12) {
}); });
} }
if (einkauf && !laufzeit.kundenPosition?.rechnung.erstellt) { // Für Eigenbedarf gibt es keine Kundenrechnung zu schreiben (#044).
if (einkauf && !laufzeit.host.kunde?.eigenbedarf && !laufzeit.kundenPosition?.rechnung.erstellt) {
aufgaben.push({ aufgaben.push({
id: `${laufzeit.id}-verkauf`, id: `${laufzeit.id}-verkauf`,
titel: "Kundenrechnung erstellen", titel: "Kundenrechnung erstellen",
+22 -1
View File
@@ -6,6 +6,10 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter { export interface HostFilter {
q?: string; q?: string;
kunde?: string; kunde?: string;
/** Schlagwort, nach dem gefiltert wird (#040). */
tag?: string;
/** Stillgelegte Hosts mitzeigen (#041). */
archiviert?: "ja";
status?: string; status?: string;
produkt?: string; produkt?: string;
ablauf?: "30" | "14" | "abgelaufen"; ablauf?: "30" | "14" | "abgelaufen";
@@ -20,6 +24,13 @@ export const SEITENGROESSE = 25;
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput { function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
const und: Prisma.HostWhereInput[] = []; const und: Prisma.HostWhereInput[] = [];
// Stillgelegte Hosts erscheinen nur, wenn ausdrücklich danach gefragt wird.
if (filter.archiviert !== "ja") und.push({ archiviertAm: null });
if (filter.tag) {
und.push({ tags: { some: { name: filter.tag } } });
}
if (filter.q && filter.q.trim().length > 0) { if (filter.q && filter.q.trim().length > 0) {
const suche = filter.q.trim(); const suche = filter.q.trim();
und.push({ und.push({
@@ -30,6 +41,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
{ subKey: { contains: suche, mode: "insensitive" } }, { subKey: { contains: suche, mode: "insensitive" } },
{ ips: { has: suche } }, { ips: { has: suche } },
{ kunde: { name: { contains: suche, mode: "insensitive" } } }, { kunde: { name: { contains: suche, mode: "insensitive" } } },
{ notizen: { contains: suche, mode: "insensitive" } },
{ tags: { some: { name: { contains: suche, mode: "insensitive" } } } },
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host. // Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
{ {
laufzeiten: { laufzeiten: {
@@ -113,6 +126,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
standort: { select: { name: true } }, standort: { select: { name: true } },
tags: { orderBy: { name: "asc" }, select: { id: true, name: true } },
}, },
}), }),
]); ]);
@@ -181,5 +195,12 @@ export async function filterOptionen() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true }, select: { id: true, name: true },
}); });
return { kunden };
// Nur Schlagworte, die auch an einem sichtbaren Host hängen – sonst stünden leere zur Auswahl.
const tags = await prisma.tag.findMany({
where: { hosts: { some: kundeId ? { kundeId } : {} } },
orderBy: { name: "asc" },
select: { name: true },
});
return { kunden, tags: tags.map((tag) => tag.name) };
} }
+28 -8
View File
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000); return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
} }
/** Stillgelegte Hosts zählen nirgends mit (#041). */
const NICHT_ARCHIVIERT = { archiviertAm: null } as const;
export async function hostsOhneKunde() { export async function hostsOhneKunde() {
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition // Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
// noch niemandem, dürfen also auch niemandem gezeigt werden. // noch niemandem, dürfen also auch niemandem gezeigt werden.
if (await sichtbarerKunde()) return []; if (await sichtbarerKunde()) return [];
return prisma.host.findMany({ return prisma.host.findMany({
where: { kundeId: null }, where: { kundeId: null, ...NICHT_ARCHIVIERT },
orderBy: { erstmalsGesehenAm: "desc" }, orderBy: { erstmalsGesehenAm: "desc" },
}); });
} }
export async function hostAnzahl() { export async function hostAnzahl() {
const kundeId = await sichtbarerKunde(); const kundeId = await sichtbarerKunde();
return prisma.host.count({ where: kundeId ? { kundeId } : {} }); return prisma.host.count({ where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } });
} }
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */ /** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
export async function kennzahlen(jetzt: Date = new Date()) { export async function kennzahlen(jetzt: Date = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000); const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const kundeId = await sichtbarerKunde(); const kundeId = await sichtbarerKunde();
const hostSicht = kundeId ? { kundeId } : {}; const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
const laufzeitSicht = kundeId ? { host: { kundeId } } : {}; const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([ const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
prisma.host.count({ where: hostSicht }), prisma.host.count({ where: hostSicht }),
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }), prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }), kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null, ...NICHT_ARCHIVIERT } }),
prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }), prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
prisma.host.count({ prisma.host.count({
where: { where: {
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }], OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
}, },
}), }),
prisma.alert.count({ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) } }), prisma.alert.count({
where: {
erledigtAm: null,
OR: [{ hostId: null }, { host: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } }],
},
}),
]); ]);
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([ const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
@@ -59,6 +67,8 @@ export async function kennzahlen(jetzt: Date = new Date()) {
...laufzeitSicht, ...laufzeitSicht,
status: "BESTAETIGT", status: "BESTAETIGT",
proxmoxPosition: { isNot: null }, proxmoxPosition: { isNot: null },
// Eigenbedarf wird nicht weiterberechnet – hier ist nichts offen (#044).
host: { kunde: { eigenbedarf: false } },
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }], OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
}, },
}), }),
@@ -94,7 +104,7 @@ export async function suchIndex() {
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([ const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
where: kundeId ? { kundeId } : {}, where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
select: { select: {
id: true, id: true,
hostname: true, hostname: true,
@@ -103,7 +113,9 @@ export async function suchIndex() {
subKey: true, subKey: true,
ips: true, ips: true,
produkt: true, produkt: true,
notizen: true,
kunde: { select: { name: true } }, kunde: { select: { name: true } },
tags: { select: { name: true } },
}, },
}), }),
prisma.kunde.findMany({ prisma.kunde.findMany({
@@ -128,9 +140,17 @@ export async function suchIndex() {
titel: host.hostname, titel: host.hostname,
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${ untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
host.subKey ? ` · ${host.subKey}` : "" host.subKey ? ` · ${host.subKey}` : ""
}`, }${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
href: `/hosts/${host.id}`, href: `/hosts/${host.id}`,
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips] begriffe: [
host.hostname,
host.fqdn,
host.serverId,
host.subKey,
host.notizen,
...host.tags.map((tag) => tag.name),
...host.ips,
]
.filter((w): w is string => Boolean(w)) .filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()), .map((w) => w.toLowerCase()),
})), })),
+1 -1
View File
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
const nurKunde = await sichtbarerKunde(); const nurKunde = await sichtbarerKunde();
const hosts = await prisma.host.findMany({ const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) }, where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: [{ subNextduedate: "asc" }], orderBy: [{ subNextduedate: "asc" }],
select: { select: {
id: true, id: true,
+6 -1
View File
@@ -35,6 +35,7 @@ export async function kundenListe() {
return { return {
id: kunde.id, id: kunde.id,
name: kunde.name, name: kunde.name,
eigenbedarf: kunde.eigenbedarf,
kundennummer: kunde.kundennummer, kundennummer: kunde.kundennummer,
ansprechpartner: kunde.ansprechpartner, ansprechpartner: kunde.ansprechpartner,
hosts: kunde._count.hosts, hosts: kunde._count.hosts,
@@ -87,7 +88,11 @@ export async function laufzeitenVonKunde(kundeId: string) {
einkaufSichtbar && laufzeit.proxmoxPosition einkaufSichtbar && laufzeit.proxmoxPosition
? { ? {
nummer: laufzeit.proxmoxPosition.rechnung.nummer, nummer: laufzeit.proxmoxPosition.rechnung.nummer,
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag), // Nach Abzug des Rabatts – siehe #037.
nettoBetrag:
Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0),
listenpreis: Number(laufzeit.proxmoxPosition.nettoBetrag),
rabatt: Number(laufzeit.proxmoxPosition.rabatt ?? 0),
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt, bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm, faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
} }
+22 -6
View File
@@ -67,7 +67,13 @@ export async function rechnungsListe(filter: RechnungsFilter) {
where, where,
orderBy: [{ start: "desc" }], orderBy: [{ start: "desc" }],
include: { include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } }, host: {
select: {
id: true,
hostname: true,
kunde: { select: { id: true, name: true, eigenbedarf: true } },
},
},
proxmoxPosition: { include: { rechnung: true } }, proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } }, kundenPosition: { include: { rechnung: true } },
}, },
@@ -76,9 +82,14 @@ export async function rechnungsListe(filter: RechnungsFilter) {
const zeilen = laufzeiten.map((laufzeit) => { const zeilen = laufzeiten.map((laufzeit) => {
// Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt, // Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
// nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML. // nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML.
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird.
const einkauf = const einkauf =
einkaufSichtbar && laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null; einkaufSichtbar && laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
// Eigene Hosts werden nicht weiterberechnet; eine Marge daraus wäre eine erfundene Zahl (#044).
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
return { return {
id: laufzeit.id, id: laufzeit.id,
@@ -98,21 +109,26 @@ export async function rechnungsListe(filter: RechnungsFilter) {
verkaufBetrag: verkauf, verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false, verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false, verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null, eigenbedarf,
marge: !eigenbedarf && einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
}; };
}); });
// Eigenbedarf zählt beim Einkauf mit – es sind echte Kosten –, bleibt bei Verkauf und Marge
// aber außen vor.
const summen = zeilen.reduce( const summen = zeilen.reduce(
(summe, zeile) => ({ (summe, zeile) => ({
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0), einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0), verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
einkaufVerkauft: summe.einkaufVerkauft + (zeile.eigenbedarf ? 0 : (zeile.einkaufBetrag ?? 0)),
eigenbedarf: summe.eigenbedarf + (zeile.eigenbedarf ? (zeile.einkaufBetrag ?? 0) : 0),
}), }),
{ einkauf: 0, verkauf: 0 }, { einkauf: 0, verkauf: 0, einkaufVerkauft: 0, eigenbedarf: 0 },
); );
return { return {
zeilen, zeilen,
summen: { ...summen, marge: summen.verkauf - summen.einkauf }, summen: { ...summen, marge: summen.verkauf - summen.einkaufVerkauft },
}; };
} }
@@ -142,7 +158,7 @@ export async function laufzeitDetail(id: string) {
id: true, id: true,
hostname: true, hostname: true,
produkt: true, produkt: true,
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true, eigenbedarf: true } },
}, },
}, },
proxmoxPosition: { proxmoxPosition: {
+119 -7
View File
@@ -68,7 +68,8 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden."); if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben."); if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const { steuersatz: vorgabeSteuersatz } = await einstellungen(); // Einkauf bei Proxmox: eigener Satz, Vorgabe 0 wegen Reverse Charge.
const { steuersatzEinkauf: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer.trim(); const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`; const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
@@ -82,7 +83,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
datum: eingabe.datum, datum: eingabe.datum,
faelligAm: eingabe.faelligAm, faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz, ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null, zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt, bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null, bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
@@ -97,7 +97,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
faelligAm: eingabe.faelligAm, faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag, nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz, ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null, zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt, bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null, bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
@@ -109,10 +108,20 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
const position = laufzeit.proxmoxPosition const position = laufzeit.proxmoxPosition
? await tx.proxmoxRechnungPosition.update({ ? await tx.proxmoxRechnungPosition.update({
where: { id: laufzeit.proxmoxPosition.id }, where: { id: laufzeit.proxmoxPosition.id },
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag }, data: {
rechnungId: rechnung.id,
bezeichnung,
nettoBetrag: eingabe.nettoBetrag,
rabatt: eingabe.rabatt ?? null,
},
}) })
: await tx.proxmoxRechnungPosition.create({ : await tx.proxmoxRechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag }, data: {
rechnungId: rechnung.id,
bezeichnung,
nettoBetrag: eingabe.nettoBetrag,
rabatt: eingabe.rabatt ?? null,
},
}); });
await tx.laufzeit.update({ await tx.laufzeit.update({
@@ -120,9 +129,11 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" }, data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
}); });
// Der Rechnungsbetrag ist immer die Summe der Positionen. // Der Rechnungsbetrag ist immer die Summe der Positionen – nach Abzug ihrer Rabatte.
// Genau so steht es auch auf der Rechnung von Proxmox: Listenpreise und Rabatte als eigene
// Zeilen, darunter die Summe.
const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } }); const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0); const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag) - Number(p.rabatt ?? 0), 0);
await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } }); await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, { await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
@@ -313,3 +324,104 @@ export async function laufzeitAnlegen(daten: {
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten); await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
return laufzeit; return laufzeit;
} }
/** Ändert Key, Zeitraum oder Notiz einer Laufzeit (#043). */
export async function laufzeitBearbeiten(
id: string,
daten: { key: string; start: Date; ende: Date; notizen?: string | null },
) {
const laufzeit = await prisma.laufzeit.findUnique({ where: { id } });
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (daten.ende <= daten.start) throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen.");
// Je Host und Startdatum darf es nur eine Laufzeit geben – sonst entstünden zwei Lizenzjahre
// für denselben Zeitraum, und die Rechnungen ließen sich nicht mehr zuordnen.
const kollision = await prisma.laufzeit.findUnique({
where: { hostId_start: { hostId: laufzeit.hostId, start: daten.start } },
});
if (kollision && kollision.id !== id) {
throw new RechnungsFehler(
"Für dieses Startdatum gibt es an diesem Host schon eine Laufzeit. Beide lassen sich zusammenführen.",
);
}
const geaendert = await prisma.laufzeit.update({
where: { id },
data: {
key: daten.key,
start: daten.start,
ende: daten.ende,
notizen: daten.notizen ?? null,
// Von Hand geändert heißt: der Vorschlag ist angenommen.
status: "BESTAETIGT",
},
});
await protokolliere("laufzeit.geaendert", "Laufzeit", id, {
key: daten.key,
start: daten.start,
ende: daten.ende,
});
return geaendert;
}
/**
* Führt zwei Laufzeiten desselben Hosts zusammen (#043).
*
* Gedacht für den Fall, dass neben einer von Hand erfassten Laufzeit ein Vorschlag des Agenten
* steht – etwa weil das gemeldete Ablaufdatum einen Tag abweicht. Die Rechnungspositionen der
* aufgelösten Laufzeit wandern mit, sofern das Ziel dort noch nichts hat; andernfalls bricht der
* Vorgang ab. Zwei Einkaufsrechnungen auf einem Lizenzjahr wären nicht mehr zuzuordnen.
*/
export async function laufzeitenZusammenfuehren(zielId: string, quelleId: string) {
if (zielId === quelleId) throw new RechnungsFehler("Das ist dieselbe Laufzeit.");
return prisma.$transaction(async (tx) => {
const [ziel, quelle] = await Promise.all([
tx.laufzeit.findUnique({ where: { id: zielId } }),
tx.laufzeit.findUnique({ where: { id: quelleId } }),
]);
if (!ziel || !quelle) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (ziel.hostId !== quelle.hostId) {
throw new RechnungsFehler("Zusammenführen geht nur innerhalb desselben Hosts.");
}
if (ziel.proxmoxPositionId && quelle.proxmoxPositionId) {
throw new RechnungsFehler(
"Beide Laufzeiten haben eine Proxmox-Rechnung. Bitte zuerst die falsche Rechnung entfernen.",
);
}
if (ziel.kundenPositionId && quelle.kundenPositionId) {
throw new RechnungsFehler(
"Beide Laufzeiten haben eine Kundenrechnung. Bitte zuerst die falsche Rechnung entfernen.",
);
}
// Zuerst muss die Quelle ihre Positionen loslassen: Eine Position gehört zu genau einer
// Laufzeit, und die Datenbank wacht darüber. Erst danach darf das Ziel sie übernehmen.
await tx.laufzeit.update({
where: { id: quelleId },
data: { proxmoxPositionId: null, kundenPositionId: null },
});
await tx.laufzeit.update({
where: { id: zielId },
data: {
proxmoxPositionId: ziel.proxmoxPositionId ?? quelle.proxmoxPositionId,
kundenPositionId: ziel.kundenPositionId ?? quelle.kundenPositionId,
status: "BESTAETIGT",
notizen: [ziel.notizen, quelle.notizen].filter(Boolean).join(" · ") || null,
},
});
await tx.laufzeit.delete({ where: { id: quelleId } });
await protokolliere("laufzeit.zusammengefuehrt", "Laufzeit", zielId, {
quelle: quelleId,
quelleStart: quelle.start,
});
return ziel;
});
}
+19
View File
@@ -241,6 +241,25 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]); expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
}); });
it("liest die Knoten auch mit QDevice-Spalte richtig", () => {
// Mit einem QDevice steht zwischen Votes und Name dessen Zustand. Vorher landeten
// „A,V,NMW“ und „NR“ als Knotennamen im Report (#042).
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
befehlErsetzen(
"pvecm",
`Membership information
----------------------
Nodeid Votes Qdevice Name
1 1 A,V,NMW hv-pve-01
2 1 A,V,NMW hv-pve-02 (local)
3 1 NR hv-pve-05
0 1 Qdevice (votes 1)`,
);
const report = reportErzeugen() as { host: { clusterMitglieder: string[] } };
expect(report.host.clusterMitglieder).toEqual(["hv-pve-01", "hv-pve-02", "hv-pve-05"]);
});
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => { it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION); befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
+1 -1
View File
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
it("legt die Standard-Einstellungen für alle Regeln an", async () => { it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany(); const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(11); expect(einstellungen).toHaveLength(13);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab"); const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] }); expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
}); });
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { alleRegelnPruefen, type LaufzeitDaten, type RegelKontext } from "@/server/alerts/regeln";
/** Eigenbedarf: eigene Hosts ohne Weiterberechnung (#044). */
const JETZT = new Date("2026-10-04T12:00:00Z");
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
return {
id: "l1",
hostId: "h1",
hostname: "hv-ste-pve-01",
kundeId: "k1",
kundeEigenbedarf: false,
kundeName: "Florian Wilde IT SysOp",
key: "pve2s-aaaa",
start: new Date("2026-06-03"),
ende: new Date("2027-06-02"),
einkaufNummer: "291017",
einkaufBetrag: 990,
einkaufBezahlt: true,
einkaufFaelligAm: new Date("2026-10-17"),
verkaufNummer: null,
verkaufBetrag: null,
verkaufErstellt: false,
verkaufBezahlt: false,
verkaufDatum: null,
...überschreibungen,
};
}
function kontext(laufzeiten: LaufzeitDaten[]): RegelKontext {
return { jetzt: JETZT, hosts: [], laufzeiten, einstellungen: new Map() };
}
function regeln(k: RegelKontext, name: string) {
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
}
describe("Eigenbedarf", () => {
it("mahnt bei einem normalen Kunden die fehlende Kundenrechnung an", () => {
expect(regeln(kontext([laufzeit()]), "kundenrechnung-fehlt")).toHaveLength(1);
});
it("schweigt beim Eigenbedarf – es gibt niemanden, dem man schreibt", () => {
expect(regeln(kontext([laufzeit({ kundeEigenbedarf: true })]), "kundenrechnung-fehlt")).toHaveLength(0);
});
it("mahnt beim Eigenbedarf keine unbezahlte Kundenrechnung an", () => {
const k = kontext([
laufzeit({
kundeEigenbedarf: true,
verkaufErstellt: true,
verkaufDatum: new Date("2026-01-01"),
verkaufBezahlt: false,
}),
]);
expect(regeln(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
});
it("meldet beim Eigenbedarf nicht „Kunde hat bezahlt“", () => {
const k = kontext([
laufzeit({ kundeEigenbedarf: true, verkaufBezahlt: true, einkaufBezahlt: false }),
]);
expect(regeln(k, "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
});
it("lässt die Einkaufsseite unberührt", () => {
// Die Proxmox-Rechnung muss auch für eigene Hosts bezahlt werden.
const k = kontext([
laufzeit({
kundeEigenbedarf: true,
einkaufBezahlt: false,
einkaufFaelligAm: new Date("2026-10-06"),
}),
]);
expect(regeln(k, "proxmox-rechnung-faellig")).toHaveLength(1);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Rabatt je Position und Sammelrechnung über mehrere Hosts – so, wie Proxmox abrechnet (#037):
* je Key eine Zeile mit Listenpreis und eine Zeile mit „Client Discount“.
*/
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
const { proxmoxRechnungErfassen } = await import("@/server/services/rechnungen");
async function laufzeit(key: string) {
const kunde = await testPrisma.kunde.upsert({
where: { kundennummer: "K-1" },
update: {},
create: { name: "Wilde IT", kundennummer: "K-1" },
});
const host = await testPrisma.host.create({
data: { agentId: `a-${key}`, hostname: `hv-${key}`, produkt: "PVE", kundeId: kunde.id },
});
return testPrisma.laufzeit.create({
data: {
hostId: host.id,
key,
start: new Date("2026-10-17"),
ende: new Date("2027-10-16"),
status: "VORSCHLAG",
},
});
}
const eingabe = {
nummer: "291017",
datum: new Date("2026-09-17"),
faelligAm: new Date("2026-10-17"),
nettoBetrag: 1100,
rabatt: 110,
ustSatz: 0,
bezahlt: false,
bezahltAm: null,
zahlungsart: null,
anhangId: null,
notizen: null,
};
describe.runIf(datenbankVerfuegbar)("Proxmox-Rechnung mit Rabatt", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("legt den Rabatt an der Position ab, nicht an der Rechnung", async () => {
const l = await laufzeit("pve2s-3ef3be4d75");
await proxmoxRechnungErfassen(l.id, eingabe);
const position = await testPrisma.proxmoxRechnungPosition.findFirstOrThrow({});
expect(Number(position.nettoBetrag)).toBe(1100);
expect(Number(position.rabatt)).toBe(110);
});
it("rechnet die Rechnungssumme als Listenpreise minus Rabatte", async () => {
// Genau der Fall aus der echten Rechnung 291017: zwei Keys, je 1100 minus 110 = 1980.
const ersteLaufzeit = await laufzeit("pve2s-3ef3be4d75");
const zweiteLaufzeit = await laufzeit("pve2s-b7a0d516af");
await proxmoxRechnungErfassen(ersteLaufzeit.id, eingabe);
await proxmoxRechnungErfassen(zweiteLaufzeit.id, eingabe);
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "291017" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
expect(Number(rechnung.nettoBetrag)).toBe(1980);
});
it("bestätigt die Laufzeit beim Erfassen", async () => {
const l = await laufzeit("pve2s-3ef3be4d75");
await proxmoxRechnungErfassen(l.id, eingabe);
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: l.id } });
expect(danach.status).toBe("BESTAETIGT");
});
it("führt die Summe nach, wenn sich ein Betrag ändert", async () => {
const l = await laufzeit("pve2s-3ef3be4d75");
await proxmoxRechnungErfassen(l.id, eingabe);
await proxmoxRechnungErfassen(l.id, { ...eingabe, nettoBetrag: 1200, rabatt: 0 });
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: "291017" } });
expect(Number(rechnung.nettoBetrag)).toBe(1200);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Hosts stilllegen statt löschen – und löschen nur, wenn nichts daran hängt (#041). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
vi.mock("next/cache", () => ({ revalidatePath: () => {} }));
vi.mock("next/navigation", () => ({ redirect: () => {} }));
const { hostArchivieren, hostLoeschen } = await import("@/server/actions/hosts");
const { hostListe } = await import("@/server/queries/hostliste");
function formular(werte: Record<string, string>) {
const daten = new FormData();
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
return daten;
}
async function host(hostname = "hv-pve-01") {
return testPrisma.host.create({ data: { agentId: `a-${hostname}`, hostname, produkt: "PVE" } });
}
describe.runIf(datenbankVerfuegbar)("Hosts stilllegen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("nimmt einen stillgelegten Host aus der Liste", async () => {
const h = await host();
expect((await hostListe({})).gesamt).toBe(1);
await hostArchivieren(formular({ hostId: h.id }));
expect((await hostListe({})).gesamt).toBe(0);
expect((await hostListe({ archiviert: "ja" })).gesamt).toBe(1);
});
it("schließt beim Stilllegen die offenen Warnungen", async () => {
const h = await host();
await testPrisma.alert.create({
data: {
regel: "status-nicht-aktiv",
fingerprint: `status-nicht-aktiv:${h.id}`,
schwere: "WARNUNG",
titel: "Keine Subscription gefunden",
beschreibung: "…",
hostId: h.id,
},
});
await hostArchivieren(formular({ hostId: h.id }));
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: h.id } });
expect(alert.erledigtAm).not.toBeNull();
});
it("nimmt einen Host auf Wunsch wieder auf", async () => {
const h = await host();
await hostArchivieren(formular({ hostId: h.id }));
await hostArchivieren(formular({ hostId: h.id }));
expect((await hostListe({})).gesamt).toBe(1);
});
it("löscht einen Host ohne Laufzeiten", async () => {
const h = await host();
await hostLoeschen(formular({ hostId: h.id }));
expect(await testPrisma.host.count()).toBe(0);
});
it("löscht keinen Host, an dem eine Laufzeit hängt", async () => {
const h = await host();
await testPrisma.laufzeit.create({
data: {
hostId: h.id,
key: "pve1c-aaaa",
start: new Date("2026-01-01"),
ende: new Date("2026-12-31"),
status: "BESTAETIGT",
},
});
await hostLoeschen(formular({ hostId: h.id }));
// Buchhaltung bleibt: Der Host ist noch da und lässt sich nur stilllegen.
expect(await testPrisma.host.count()).toBe(1);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Schlagworte und Notizen in Liste, Filter und Suche (#040). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
sitzungErforderlich: async () => {
throw new Error("in diesem Test nicht vorgesehen");
},
}));
const { hostListe, filterOptionen } = await import("@/server/queries/hostliste");
const { suchIndex } = await import("@/server/queries/hosts");
async function hosts() {
// Zwei Hosts mit demselben Namen bei verschiedenen Kunden – genau der Fall aus der Praxis.
const [kundeA, kundeB] = await Promise.all([
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
]);
await testPrisma.host.create({
data: {
agentId: "a-1",
hostname: "hv-pve-01",
produkt: "PVE",
kundeId: kundeA.id,
notizen: "Steht im Serverraum links",
tags: { connectOrCreate: [{ where: { name: "rechenzentrum" }, create: { name: "rechenzentrum" } }] },
},
});
await testPrisma.host.create({
data: {
agentId: "a-2",
hostname: "hv-pve-01",
produkt: "PVE",
kundeId: kundeB.id,
tags: { connectOrCreate: [{ where: { name: "aussenstelle" }, create: { name: "aussenstelle" } }] },
},
});
}
describe.runIf(datenbankVerfuegbar)("Schlagworte und Notizen", () => {
beforeEach(async () => {
await datenbankLeeren();
await hosts();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("liefert die Schlagworte zu jedem Host mit", async () => {
const liste = await hostListe({});
const namen = liste.hosts.flatMap((host) => host.tags.map((tag) => tag.name)).sort();
expect(namen).toEqual(["aussenstelle", "rechenzentrum"]);
});
it("filtert nach Schlagwort", async () => {
const liste = await hostListe({ tag: "aussenstelle" });
expect(liste.gesamt).toBe(1);
expect(liste.hosts[0].kunde?.name).toBe("Kunde B");
});
it("findet einen Host über sein Schlagwort", async () => {
const liste = await hostListe({ q: "rechenzentrum" });
expect(liste.gesamt).toBe(1);
expect(liste.hosts[0].kunde?.name).toBe("Kunde A");
});
it("findet einen Host über seine Notiz", async () => {
const liste = await hostListe({ q: "serverraum" });
expect(liste.gesamt).toBe(1);
});
it("bietet nur vergebene Schlagworte zur Auswahl an", async () => {
const { tags } = await filterOptionen();
expect(tags).toEqual(["aussenstelle", "rechenzentrum"]);
});
it("nimmt Schlagworte und Notizen in die globale Suche auf", async () => {
const eintraege = await suchIndex();
const treffer = eintraege.filter((eintrag) => eintrag.begriffe.includes("rechenzentrum"));
expect(treffer).toHaveLength(1);
expect(treffer[0].untertitel).toContain("rechenzentrum");
});
});
+128
View File
@@ -0,0 +1,128 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Laufzeiten nachträglich ändern und zusammenführen (#043). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
const { laufzeitBearbeiten, laufzeitenZusammenfuehren, RechnungsFehler } = await import(
"@/server/services/rechnungen"
);
async function host(name = "hv-pve-01") {
return testPrisma.host.create({ data: { agentId: `a-${name}`, hostname: name, produkt: "PVE" } });
}
async function laufzeit(hostId: string, start: string, key = "pve2s-aaaa") {
return testPrisma.laufzeit.create({
data: {
hostId,
key,
start: new Date(start),
ende: new Date(new Date(start).setUTCFullYear(new Date(start).getUTCFullYear() + 1)),
status: "VORSCHLAG",
},
});
}
async function mitEinkauf(laufzeitId: string, nummer: string) {
const rechnung = await testPrisma.proxmoxRechnung.create({
data: { nummer, datum: new Date("2026-09-17"), nettoBetrag: 990, ustSatz: 0 },
});
const position = await testPrisma.proxmoxRechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung: "Position", nettoBetrag: 1100, rabatt: 110 },
});
await testPrisma.laufzeit.update({
where: { id: laufzeitId },
data: { proxmoxPosition: { connect: { id: position.id } } },
});
}
describe.runIf(datenbankVerfuegbar)("Laufzeit bearbeiten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("ändert Key und Zeitraum und bestätigt dabei den Vorschlag", async () => {
const h = await host();
const l = await laufzeit(h.id, "2026-10-17");
await laufzeitBearbeiten(l.id, {
key: "pve2s-neu",
start: new Date("2026-10-18"),
ende: new Date("2027-10-17"),
notizen: "Datum laut Bestellung",
});
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: l.id } });
expect(danach.key).toBe("pve2s-neu");
expect(danach.start.toISOString().slice(0, 10)).toBe("2026-10-18");
expect(danach.status).toBe("BESTAETIGT");
});
it("weist ein Ende vor dem Beginn ab", async () => {
const h = await host();
const l = await laufzeit(h.id, "2026-10-17");
await expect(
laufzeitBearbeiten(l.id, { key: "pve2s-aaaa", start: new Date("2026-10-17"), ende: new Date("2026-10-01") }),
).rejects.toBeInstanceOf(RechnungsFehler);
});
it("weist ein Startdatum ab, das am selben Host schon belegt ist", async () => {
const h = await host();
const ersteLaufzeit = await laufzeit(h.id, "2026-10-17");
await laufzeit(h.id, "2025-10-17");
await expect(
laufzeitBearbeiten(ersteLaufzeit.id, {
key: "pve2s-aaaa",
start: new Date("2025-10-17"),
ende: new Date("2026-10-16"),
}),
).rejects.toBeInstanceOf(RechnungsFehler);
});
it("führt zwei Laufzeiten zusammen und nimmt die Rechnung mit", async () => {
const h = await host();
const ziel = await laufzeit(h.id, "2026-10-17");
const quelle = await laufzeit(h.id, "2026-10-18");
await mitEinkauf(quelle.id, "291017");
await laufzeitenZusammenfuehren(ziel.id, quelle.id);
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: ziel.id } });
expect(danach.proxmoxPositionId).not.toBeNull();
expect(danach.status).toBe("BESTAETIGT");
expect(await testPrisma.laufzeit.count()).toBe(1);
// Die Rechnung selbst bleibt erhalten – nur die doppelte Laufzeit verschwindet.
expect(await testPrisma.proxmoxRechnungPosition.count()).toBe(1);
});
it("verweigert das Zusammenführen, wenn beide eine Einkaufsrechnung haben", async () => {
const h = await host();
const ziel = await laufzeit(h.id, "2026-10-17");
const quelle = await laufzeit(h.id, "2026-10-18");
await mitEinkauf(ziel.id, "291017");
await mitEinkauf(quelle.id, "291018");
await expect(laufzeitenZusammenfuehren(ziel.id, quelle.id)).rejects.toBeInstanceOf(RechnungsFehler);
expect(await testPrisma.laufzeit.count()).toBe(2);
});
it("führt nichts über Hostgrenzen hinweg zusammen", async () => {
const [a, b] = await Promise.all([host("hv-pve-01"), host("hv-pve-02")]);
const ziel = await laufzeit(a.id, "2026-10-17");
const quelle = await laufzeit(b.id, "2026-10-17");
await expect(laufzeitenZusammenfuehren(ziel.id, quelle.id)).rejects.toBeInstanceOf(RechnungsFehler);
});
});
+96 -3
View File
@@ -28,6 +28,7 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
subLizenzierteSockets: 1, subLizenzierteSockets: 1,
sockets: 1, sockets: 1,
letzterReportAm: new Date(JETZT.getTime() - 3_600_000), letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
ips: ["10.0.0.10"],
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }], serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
...überschreibungen, ...überschreibungen,
}; };
@@ -40,6 +41,7 @@ function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten
hostname: "pve-01", hostname: "pve-01",
kundeId: "k1", kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH", kundeName: "Brandl Elektrotechnik GmbH",
kundeEigenbedarf: false,
key: "pve1c-aaaa", key: "pve1c-aaaa",
start: inTagen(-100), start: inTagen(-100),
ende: inTagen(265), ende: inTagen(265),
@@ -72,9 +74,9 @@ function ausRegel(k: RegelKontext, name: string) {
} }
describe("Warnregeln", () => { describe("Warnregeln", () => {
it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => { it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(11); expect(REGELN).toHaveLength(13);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(11); expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
}); });
describe("Subscription läuft ab", () => { describe("Subscription läuft ab", () => {
@@ -376,4 +378,95 @@ describe("Warnregeln", () => {
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint); const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
expect(new Set(fingerprints).size).toBe(fingerprints.length); expect(new Set(fingerprints).size).toBe(fingerprints.length);
}); });
describe("Kunde hat bezahlt, Einkauf noch offen", () => {
it("meldet, sobald der Kunde bezahlt hat und die Proxmox-Rechnung offen ist", () => {
// Der übliche Ablauf: unbezahlte Rechnung von Proxmox, Kundenrechnung, Kunde zahlt,
// dann zahlt man selbst. Dieser Zustand ist der Moment zum Zahlen.
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: false, verkaufErstellt: true, verkaufBezahlt: true })],
});
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen");
expect(treffer).toHaveLength(1);
expect(treffer[0].schwere).toBe("INFO");
});
it("schweigt, solange der Kunde noch nicht bezahlt hat", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: false, verkaufErstellt: true, verkaufBezahlt: false })],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
});
it("schweigt, wenn die Proxmox-Rechnung längst bezahlt ist", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: true, verkaufErstellt: true, verkaufBezahlt: true })],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
});
it("schweigt, wenn es noch gar keine Einkaufsrechnung gibt", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: null, verkaufErstellt: true, verkaufBezahlt: true })],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
});
});
describe("Gleicher Hostname beim selben Kunden", () => {
it("meldet zwei gleichnamige Hosts desselben Kunden – mit IPs zum Unterscheiden", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01", ips: ["10.10.5.32"] }),
host({ id: "h2", hostname: "hv-pve-01", ips: ["10.10.12.33", "10.10.5.11"] }),
],
});
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt");
expect(treffer).toHaveLength(1);
expect(treffer[0].titel).toBe("2 Hosts heißen „hv-pve-01“");
expect(treffer[0].beschreibung).toContain("10.10.5.32");
expect(treffer[0].beschreibung).toContain("10.10.12.33");
});
it("schweigt bei gleichem Namen in verschiedenen Kundennetzen", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01", kundeId: "k1" }),
host({ id: "h2", hostname: "hv-pve-01", kundeId: "k2" }),
],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0);
});
it("schweigt bei Hosts ohne Kunde", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01", kundeId: null, kundeName: null }),
host({ id: "h2", hostname: "hv-pve-01", kundeId: null, kundeName: null }),
],
});
expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0);
});
it("erzeugt für drei gleichnamige Hosts nur eine Warnung", () => {
const k = kontext({
hosts: [
host({ id: "h1", hostname: "hv-pve-01" }),
host({ id: "h2", hostname: "hv-pve-01" }),
host({ id: "h3", hostname: "HV-PVE-01" }),
],
});
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt");
expect(treffer).toHaveLength(1);
expect(treffer[0].titel).toContain("3 Hosts");
});
});
}); });