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>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
alleRegelnPruefen,
|
|
REGELN,
|
|
type HostDaten,
|
|
type LaufzeitDaten,
|
|
type RegelKontext,
|
|
} from "@/server/alerts/regeln";
|
|
|
|
const JETZT = new Date("2026-09-17T12:00:00Z");
|
|
const TAG = 86_400_000;
|
|
|
|
function inTagen(tage: number): Date {
|
|
return new Date(JETZT.getTime() + tage * TAG);
|
|
}
|
|
|
|
function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
|
|
return {
|
|
id: "h1",
|
|
hostname: "pve-01",
|
|
clusterName: null,
|
|
clusterMitglieder: [],
|
|
kundeId: "k1",
|
|
kundeName: "Brandl Elektrotechnik GmbH",
|
|
subKey: "pve1c-aaaa",
|
|
subStatus: "ACTIVE",
|
|
subNextduedate: inTagen(200),
|
|
subLizenzierteSockets: 1,
|
|
sockets: 1,
|
|
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
|
|
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
|
|
...überschreibungen,
|
|
};
|
|
}
|
|
|
|
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
|
|
return {
|
|
id: "l1",
|
|
hostId: "h1",
|
|
hostname: "pve-01",
|
|
kundeId: "k1",
|
|
kundeName: "Brandl Elektrotechnik GmbH",
|
|
key: "pve1c-aaaa",
|
|
start: inTagen(-100),
|
|
ende: inTagen(265),
|
|
einkaufNummer: "PX-2026-1",
|
|
einkaufBetrag: 115,
|
|
einkaufBezahlt: true,
|
|
einkaufFaelligAm: inTagen(-80),
|
|
verkaufNummer: "RE-2026-1",
|
|
verkaufBetrag: 165,
|
|
verkaufErstellt: true,
|
|
verkaufBezahlt: true,
|
|
verkaufDatum: inTagen(-90),
|
|
...überschreibungen,
|
|
};
|
|
}
|
|
|
|
function kontext(teil: Partial<RegelKontext> = {}): RegelKontext {
|
|
return {
|
|
jetzt: JETZT,
|
|
hosts: [],
|
|
laufzeiten: [],
|
|
einstellungen: new Map(),
|
|
...teil,
|
|
};
|
|
}
|
|
|
|
/** Hilfsfunktion: Kandidaten einer bestimmten Regel. */
|
|
function ausRegel(k: RegelKontext, name: string) {
|
|
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
|
|
}
|
|
|
|
describe("Warnregeln", () => {
|
|
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => {
|
|
expect(REGELN).toHaveLength(12);
|
|
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12);
|
|
});
|
|
|
|
describe("Subscription läuft ab", () => {
|
|
it.each([
|
|
[31, 0, null],
|
|
[30, 1, "INFO"],
|
|
[20, 1, "INFO"],
|
|
[14, 1, "WARNUNG"],
|
|
[8, 1, "WARNUNG"],
|
|
[7, 1, "GEFAHR"],
|
|
[1, 1, "GEFAHR"],
|
|
[0, 1, "GEFAHR"],
|
|
[-1, 0, null],
|
|
])("bei %i Tagen Restlaufzeit: %i Warnung(en)", (tage, anzahl, schwere) => {
|
|
const kandidaten = ausRegel(
|
|
kontext({ hosts: [host({ subNextduedate: inTagen(tage) })] }),
|
|
"subscription-laeuft-ab",
|
|
);
|
|
|
|
expect(kandidaten).toHaveLength(anzahl);
|
|
if (schwere) expect(kandidaten[0].schwere).toBe(schwere);
|
|
});
|
|
|
|
it("meldet nur die strengste erreichte Stufe", () => {
|
|
const kandidaten = ausRegel(
|
|
kontext({ hosts: [host({ subNextduedate: inTagen(5) })] }),
|
|
"subscription-laeuft-ab",
|
|
);
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].fingerprint).toBe("subscription-laeuft-ab:h1:7");
|
|
});
|
|
|
|
it("beachtet eigene Schwellwerte", () => {
|
|
const k = kontext({
|
|
hosts: [host({ subNextduedate: inTagen(50) })],
|
|
einstellungen: new Map([
|
|
["subscription-laeuft-ab", { aktiv: true, schwellwerte: { tage: [60, 30] } }],
|
|
]),
|
|
});
|
|
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(1);
|
|
});
|
|
|
|
it("meldet nichts für Hosts ohne aktive Subscription", () => {
|
|
const k = kontext({ hosts: [host({ subStatus: "EXPIRED", subNextduedate: inTagen(3) })] });
|
|
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Status nicht aktiv", () => {
|
|
it.each([
|
|
["EXPIRED", "GEFAHR"],
|
|
["INVALID", "GEFAHR"],
|
|
["SUSPENDED", "GEFAHR"],
|
|
["NOTFOUND", "WARNUNG"],
|
|
["UNKNOWN", "WARNUNG"],
|
|
] as const)("meldet %s als %s", (status, schwere) => {
|
|
const kandidaten = ausRegel(kontext({ hosts: [host({ subStatus: status })] }), "status-nicht-aktiv");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].schwere).toBe(schwere);
|
|
});
|
|
|
|
it("meldet aktive Subscriptions nicht", () => {
|
|
expect(ausRegel(kontext({ hosts: [host()] }), "status-nicht-aktiv")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Sockets", () => {
|
|
it("meldet eine Abweichung", () => {
|
|
const k = kontext({ hosts: [host({ subLizenzierteSockets: 1, sockets: 2 })] });
|
|
const kandidaten = ausRegel(k, "sockets-abweichung");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].beschreibung).toContain("1 lizenziert, 2 physisch");
|
|
});
|
|
|
|
it("meldet nichts, wenn ein Wert fehlt", () => {
|
|
expect(ausRegel(kontext({ hosts: [host({ sockets: null })] }), "sockets-abweichung")).toHaveLength(0);
|
|
expect(
|
|
ausRegel(kontext({ hosts: [host({ subLizenzierteSockets: null })] }), "sockets-abweichung"),
|
|
).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Keine Meldung", () => {
|
|
it("meldet nach Ablauf der Frist", () => {
|
|
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 49 * 3_600_000) })] });
|
|
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
|
|
});
|
|
|
|
it("meldet innerhalb der Frist nicht", () => {
|
|
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 47 * 3_600_000) })] });
|
|
expect(ausRegel(k, "keine-meldung")).toHaveLength(0);
|
|
});
|
|
|
|
it("beachtet einen eigenen Schwellwert", () => {
|
|
const k = kontext({
|
|
hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 13 * 3_600_000) })],
|
|
einstellungen: new Map([["keine-meldung", { aktiv: true, schwellwerte: { stunden: 12 } }]]),
|
|
});
|
|
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("Server-ID", () => {
|
|
it("meldet einen frischen Wechsel", () => {
|
|
const k = kontext({
|
|
hosts: [
|
|
host({
|
|
serverIds: [
|
|
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-10) },
|
|
{ serverId: "NEU", ersteMeldung: inTagen(-5), letzteMeldung: JETZT },
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
const kandidaten = ausRegel(k, "serverid-geaendert");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].beschreibung).toContain("neu NEU, vorher ALT");
|
|
});
|
|
|
|
it("meldet einen alten Wechsel nicht mehr", () => {
|
|
const k = kontext({
|
|
hosts: [
|
|
host({
|
|
serverIds: [
|
|
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-200) },
|
|
{ serverId: "NEU", ersteMeldung: inTagen(-190), letzteMeldung: JETZT },
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
expect(ausRegel(k, "serverid-geaendert")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Key mehrfach gemeldet", () => {
|
|
it("meldet denselben Key auf zwei Hosts", () => {
|
|
const k = kontext({
|
|
hosts: [host(), host({ id: "h2", hostname: "pve-02" })],
|
|
});
|
|
const kandidaten = ausRegel(k, "key-mehrfach-gemeldet");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].beschreibung).toContain("pve-01, pve-02");
|
|
});
|
|
|
|
it("meldet unterschiedliche Keys nicht", () => {
|
|
const k = kontext({ hosts: [host(), host({ id: "h2", subKey: "pve1c-bbbb" })] });
|
|
expect(ausRegel(k, "key-mehrfach-gemeldet")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Host ohne Kunde", () => {
|
|
it("meldet nicht zugeordnete Hosts", () => {
|
|
const k = kontext({ hosts: [host({ kundeId: null, kundeName: null })] });
|
|
const kandidaten = ausRegel(k, "host-ohne-kunde");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].schwere).toBe("INFO");
|
|
});
|
|
});
|
|
|
|
describe("Proxmox-Rechnung fällig", () => {
|
|
it("meldet eine bald fällige unbezahlte Rechnung als Warnung", () => {
|
|
const k = kontext({
|
|
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(5) })],
|
|
});
|
|
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].schwere).toBe("WARNUNG");
|
|
});
|
|
|
|
it("meldet eine überfällige Rechnung als Gefahr", () => {
|
|
const k = kontext({
|
|
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(-2) })],
|
|
});
|
|
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
|
|
expect(kandidaten[0].schwere).toBe("GEFAHR");
|
|
expect(kandidaten[0].titel).toBe("Proxmox-Rechnung überfällig");
|
|
});
|
|
|
|
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
|
|
expect(
|
|
ausRegel(
|
|
kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }),
|
|
"proxmox-rechnung-faellig",
|
|
),
|
|
).toHaveLength(0);
|
|
expect(
|
|
ausRegel(
|
|
kontext({ laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(20) })] }),
|
|
"proxmox-rechnung-faellig",
|
|
),
|
|
).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Kundenrechnung", () => {
|
|
it("meldet eine fehlende Kundenrechnung, wenn der Einkauf erfasst ist", () => {
|
|
const k = kontext({ laufzeiten: [laufzeit({ verkaufErstellt: false, verkaufNummer: null })] });
|
|
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(1);
|
|
});
|
|
|
|
it("meldet nichts für Hosts ohne Kunde", () => {
|
|
const k = kontext({
|
|
laufzeiten: [laufzeit({ verkaufErstellt: false, kundeId: null, kundeName: null })],
|
|
});
|
|
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(0);
|
|
});
|
|
|
|
it("meldet eine seit über 30 Tagen unbezahlte Kundenrechnung", () => {
|
|
const k = kontext({
|
|
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-31) })],
|
|
});
|
|
const kandidaten = ausRegel(k, "kundenrechnung-unbezahlt");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].titel).toContain("31 Tagen");
|
|
});
|
|
|
|
it("meldet innerhalb der Frist nicht", () => {
|
|
const k = kontext({
|
|
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-29) })],
|
|
});
|
|
expect(ausRegel(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Cluster-Knoten ohne Agent", () => {
|
|
it("meldet einen Knoten, der im Cluster steht, sich aber nicht meldet", () => {
|
|
const k = kontext({
|
|
hosts: [
|
|
host({
|
|
id: "h1",
|
|
hostname: "pve-node-01",
|
|
clusterName: "rz5",
|
|
clusterMitglieder: ["pve-node-01", "pve-node-02", "pve-node-03"],
|
|
}),
|
|
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: [] }),
|
|
],
|
|
});
|
|
|
|
const kandidaten = ausRegel(k, "cluster-knoten-ohne-agent");
|
|
expect(kandidaten).toHaveLength(1);
|
|
expect(kandidaten[0].beschreibung).toContain("pve-node-03");
|
|
expect(kandidaten[0].fingerprint).toBe("cluster-knoten-ohne-agent:rz5:pve-node-03");
|
|
});
|
|
|
|
it("meldet nichts, wenn alle Knoten melden", () => {
|
|
const mitglieder = ["pve-node-01", "pve-node-02"];
|
|
const k = kontext({
|
|
hosts: [
|
|
host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }),
|
|
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }),
|
|
],
|
|
});
|
|
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
|
|
});
|
|
|
|
it("beachtet Groß- und Kleinschreibung nicht", () => {
|
|
const k = kontext({
|
|
hosts: [
|
|
host({ id: "h1", hostname: "PVE-Node-01", clusterName: "rz5", clusterMitglieder: ["pve-node-01"] }),
|
|
],
|
|
});
|
|
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
|
|
});
|
|
|
|
it("meldet nichts für Hosts ohne Cluster", () => {
|
|
const k = kontext({ hosts: [host({ clusterName: null, clusterMitglieder: [] })] });
|
|
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(0);
|
|
});
|
|
|
|
it("meldet jeden fehlenden Knoten nur einmal, auch wenn mehrere ihn melden", () => {
|
|
const mitglieder = ["pve-node-01", "pve-node-02", "pve-node-03"];
|
|
const k = kontext({
|
|
hosts: [
|
|
host({ id: "h1", hostname: "pve-node-01", clusterName: "rz5", clusterMitglieder: mitglieder }),
|
|
host({ id: "h2", hostname: "pve-node-02", clusterName: "rz5", clusterMitglieder: mitglieder }),
|
|
],
|
|
});
|
|
expect(ausRegel(k, "cluster-knoten-ohne-agent")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it("überspringt abgeschaltete Regeln", () => {
|
|
const k = kontext({
|
|
hosts: [host({ kundeId: null, kundeName: null })],
|
|
einstellungen: new Map([["host-ohne-kunde", { aktiv: false, schwellwerte: {} }]]),
|
|
});
|
|
expect(ausRegel(k, "host-ohne-kunde")).toHaveLength(0);
|
|
});
|
|
|
|
it("erzeugt für jeden Fall einen eindeutigen Fingerabdruck", () => {
|
|
const k = kontext({
|
|
hosts: [
|
|
host({ subStatus: "EXPIRED", sockets: 2, kundeId: null, kundeName: null }),
|
|
host({ id: "h2", hostname: "pve-02", subStatus: "NOTFOUND" }),
|
|
],
|
|
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(1), verkaufErstellt: false })],
|
|
});
|
|
|
|
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
|
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);
|
|
});
|
|
});
|
|
});
|