Compare commits

...
2 Commits
Author SHA1 Message Date
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
21 changed files with 231 additions and 15 deletions
+5
View File
@@ -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
+6 -2
View File
@@ -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)
+10
View File
@@ -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
+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.
+2 -1
View File
@@ -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** |
+1 -1
View File
@@ -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;
+3
View File
@@ -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,
}}
/>
+3
View File
@@ -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">
+15 -2
View File
@@ -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>
+21
View File
@@ -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
+2
View File
@@ -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")),
});
+8 -1
View File
@@ -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,
+9 -1
View File
@@ -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 [
+3 -2
View File
@@ -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",
+2
View File
@@ -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 } } }],
},
}),
+1
View File
@@ -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,
+18 -5
View File
@@ -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: {
+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);
});
});
+1
View File
@@ -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),