Compare commits
3
Commits
v0.25.0
...
38e674de34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38e674de34 | ||
|
|
b745910afa | ||
|
|
840e902b26 |
@@ -17,6 +17,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 23 h 58 min
|
||||
**Gesamt:** 24 h 38 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -49,6 +49,7 @@
|
||||
| #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 |
|
||||
|---|---|
|
||||
@@ -71,7 +72,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.25.0
|
||||
**Version:** 0.26.0
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -220,6 +221,9 @@
|
||||
- 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
|
||||
|
||||
### Nächster Schritt
|
||||
- #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)
|
||||
|
||||
@@ -237,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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -47,4 +47,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#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 |
|
||||
| | **Summe** | | | **173 h** | **23 h 58 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
@@ -1,72 +1,117 @@
|
||||
# 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
|
||||
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
|
||||
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
|
||||
- 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
|
||||
Rollen und Benutzerverwaltung, Passkeys, Einstellungen im Dashboard, Betriebsdokumentation, die
|
||||
mobile Bedienung, Schlagworte und Notizen in der Übersicht, Stilllegen doppelter Hosts, Laufzeiten
|
||||
anlegen/bearbeiten/zusammenführen, Rabatt je Rechnungsposition, Eigenbedarf.
|
||||
|
||||
## 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 |
|
||||
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprü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** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
|
||||
| **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 Lizenz** | Bisher nur „keine Subscription“ geprüft. | 30 min |
|
||||
| **Sicherung einmal zurückspielen** | Eine Sicherung, die nie zurückgespielt wurde, ist keine. | 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
|
||||
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
|
||||
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
|
||||
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.
|
||||
Die Rechnungen von Proxmox haben eine feste Form: je Key eine Zeile mit Zeitraum und Betrag, dazu die
|
||||
Rabattzeilen. Ein Import, der das PDF liest und die Positionen den Laufzeiten über den Key zuordnet,
|
||||
spart bei jeder Sammelrechnung das meiste Tippen – und vermeidet Zahlendreher.
|
||||
|
||||
### 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 –
|
||||
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.
|
||||
### 2. Verlängerung mit einem Klick
|
||||
|
||||
### 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
|
||||
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.
|
||||
**Aufwand:** klein bis mittel.
|
||||
|
||||
## 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
|
||||
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
|
||||
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
|
||||
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
|
||||
jedem Proxmox-Host, auch auf einem alten.
|
||||
ProxLedger führt die Kundenrechnung bisher nur als Datensatz – geschrieben wird sie woanders. Mit
|
||||
Nummernkreis, Firmenangaben (stehen schon in den Einstellungen) und PDF-Ausgabe wäre der Kreis
|
||||
geschlossen. Zu klären wäre, ob das die vorhandene Buchhaltung ersetzen soll oder nur ergänzt.
|
||||
|
||||
**Aufwand:** mittel. **Rückfrage zuerst:** Soll ProxLedger Rechnungen *schreiben* oder nur *führen*?
|
||||
|
||||
### 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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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;
|
||||
@@ -61,6 +61,9 @@ model Kunde {
|
||||
ansprechpartner String?
|
||||
email 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?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
email: kunde.email,
|
||||
telefon: kunde.telefon,
|
||||
eigenbedarf: kunde.eigenbedarf,
|
||||
notizen: kunde.notizen,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,9 @@ export default async function KundenSeite() {
|
||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||
{kunde.name}
|
||||
</Link>
|
||||
{kunde.eigenbedarf ? (
|
||||
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
|
||||
) : null}
|
||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||
</td>
|
||||
<td className="pl-mono" data-label="Kundennummer">
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
verkaufBetrag ??
|
||||
(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)}`;
|
||||
|
||||
return (
|
||||
@@ -226,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
|
||||
<Card
|
||||
titel="Kundenrechnung (Verkauf)"
|
||||
rahmen={verkauf?.erstellt ? "info" : "warning"}
|
||||
rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
|
||||
werkzeuge={
|
||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
||||
eigenbedarf ? (
|
||||
<span className="badge pl-bg-neutral">Eigenbedarf</span>
|
||||
) : (
|
||||
<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
|
||||
laufzeitId={laufzeit.id}
|
||||
hostId={laufzeit.host.id}
|
||||
@@ -247,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
anhangName: verkauf?.anhang?.dateiname ?? null,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface KundeFormularWerte {
|
||||
ansprechpartner: string | null;
|
||||
email: string | null;
|
||||
telefon: string | null;
|
||||
eigenbedarf: boolean;
|
||||
notizen: string | null;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,26 @@ export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
|
||||
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
||||
</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">
|
||||
<label className="form-label" htmlFor="notizen">
|
||||
Notizen
|
||||
|
||||
@@ -25,6 +25,7 @@ const kundeSchema = z.object({
|
||||
ansprechpartner: z.string().nullable(),
|
||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||
telefon: z.string().nullable(),
|
||||
eigenbedarf: z.boolean().default(false),
|
||||
notizen: z.string().nullable(),
|
||||
});
|
||||
|
||||
@@ -38,6 +39,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||
email: leerZuNull(formular.get("email")),
|
||||
telefon: leerZuNull(formular.get("telefon")),
|
||||
eigenbedarf: formular.get("eigenbedarf") === "an",
|
||||
notizen: leerZuNull(formular.get("notizen")),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
||||
prisma.laufzeit.findMany({
|
||||
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
||||
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 } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
@@ -51,6 +57,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
||||
hostname: laufzeit.host.hostname,
|
||||
kundeId: laufzeit.host.kundeId,
|
||||
kundeName: laufzeit.host.kunde?.name ?? null,
|
||||
kundeEigenbedarf: laufzeit.host.kunde?.eigenbedarf ?? false,
|
||||
key: laufzeit.key,
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface LaufzeitDaten {
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
kundeId: string | null;
|
||||
/** Eigene Hosts werden nicht weiterberechnet – die Verkaufsseite entfällt (#044). */
|
||||
kundeEigenbedarf: boolean;
|
||||
kundeName: string | null;
|
||||
key: string;
|
||||
start: Date;
|
||||
@@ -359,7 +361,10 @@ const kundenrechnungFehlt: Regel = {
|
||||
standardSchwellwerte: {},
|
||||
pruefe(kontext) {
|
||||
return kontext.laufzeiten
|
||||
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
|
||||
.filter(
|
||||
(laufzeit) =>
|
||||
laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt && !laufzeit.kundeEigenbedarf,
|
||||
)
|
||||
.filter((laufzeit) => laufzeit.kundeId !== null)
|
||||
.map((laufzeit) => ({
|
||||
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
|
||||
@@ -384,6 +389,7 @@ const kundenrechnungUnbezahlt: Regel = {
|
||||
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
|
||||
|
||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||
if (laufzeit.kundeEigenbedarf) return [];
|
||||
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
|
||||
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
|
||||
if (offenSeit < tage) return [];
|
||||
@@ -463,6 +469,8 @@ const kundeBezahltEinkaufOffen: Regel = {
|
||||
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 [
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function offeneAufgaben(grenze = 12) {
|
||||
},
|
||||
orderBy: { start: "asc" },
|
||||
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 } },
|
||||
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({
|
||||
id: `${laufzeit.id}-verkauf`,
|
||||
titel: "Kundenrechnung erstellen",
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
...laufzeitSicht,
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPosition: { isNot: null },
|
||||
// Eigenbedarf wird nicht weiterberechnet – hier ist nichts offen (#044).
|
||||
host: { kunde: { eigenbedarf: false } },
|
||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function kundenListe() {
|
||||
return {
|
||||
id: kunde.id,
|
||||
name: kunde.name,
|
||||
eigenbedarf: kunde.eigenbedarf,
|
||||
kundennummer: kunde.kundennummer,
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
hosts: kunde._count.hosts,
|
||||
|
||||
@@ -67,7 +67,13 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
where,
|
||||
orderBy: [{ start: "desc" }],
|
||||
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 } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
@@ -82,6 +88,8 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||
: 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 {
|
||||
id: laufzeit.id,
|
||||
@@ -101,21 +109,26 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
verkaufBetrag: verkauf,
|
||||
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? 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(
|
||||
(summe, zeile) => ({
|
||||
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 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 {
|
||||
zeilen,
|
||||
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
|
||||
summen: { ...summen, marge: summen.verkauf - summen.einkaufVerkauft },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,7 +158,7 @@ export async function laufzeitDetail(id: string) {
|
||||
id: true,
|
||||
hostname: true,
|
||||
produkt: true,
|
||||
kunde: { select: { id: true, name: true } },
|
||||
kunde: { select: { id: true, name: true, eigenbedarf: true } },
|
||||
},
|
||||
},
|
||||
proxmoxPosition: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten
|
||||
hostname: "pve-01",
|
||||
kundeId: "k1",
|
||||
kundeName: "Brandl Elektrotechnik GmbH",
|
||||
kundeEigenbedarf: false,
|
||||
key: "pve1c-aaaa",
|
||||
start: inTagen(-100),
|
||||
ende: inTagen(265),
|
||||
|
||||
Reference in New Issue
Block a user