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>
This commit is contained in:
2026-10-03 20:57:07 +02:00
co-authored by Claude Opus 5
parent f076f49631
commit 83320f0443
22 changed files with 738 additions and 15 deletions
+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);
});
});