feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)

Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere
Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme,
Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:54:45 +02:00
co-authored by Claude Opus 5
parent 1e1d6c4418
commit 558dfea34c
23 changed files with 2015 additions and 86 deletions
+316
View File
@@ -0,0 +1,316 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
RechnungsFehler,
verkaufBezahltSetzen,
verkaufErstelltSetzen,
} from "@/server/services/rechnungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Brandl Elektrotechnik GmbH", kundennummer: "K-1001" },
});
const hostA = await testPrisma.host.create({
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE", kundeId: kunde.id },
});
const hostB = await testPrisma.host.create({
data: { agentId: "a-2", hostname: "pve-02", produkt: "PVE", kundeId: kunde.id },
});
const laufzeitA = await testPrisma.laufzeit.create({
data: {
hostId: hostA.id,
key: "pve1c-aaaa",
start: new Date("2026-09-24"),
ende: new Date("2027-09-23"),
status: "VORSCHLAG",
},
});
const laufzeitB = await testPrisma.laufzeit.create({
data: {
hostId: hostB.id,
key: "pve2c-bbbb",
start: new Date("2026-10-08"),
ende: new Date("2027-10-07"),
status: "VORSCHLAG",
},
});
return { kunde, hostA, hostB, laufzeitA, laufzeitB };
}
const EINKAUF = {
nummer: "PX-2026-14790",
datum: new Date("2026-09-10"),
faelligAm: new Date("2026-09-24"),
nettoBetrag: 115,
bezahlt: false,
};
describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
describe("Laufzeiten", () => {
it("bestätigt einen Vorschlag", async () => {
const { laufzeitA } = await aufbau();
await laufzeitBestaetigen(laufzeitA.id);
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: laufzeitA.id } });
expect(danach.status).toBe("BESTAETIGT");
});
it("verwirft einen Vorschlag, aber keine bestätigte Laufzeit", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await laufzeitVerwerfen(laufzeitA.id);
expect(await testPrisma.laufzeit.findUnique({ where: { id: laufzeitA.id } })).toBeNull();
await laufzeitBestaetigen(laufzeitB.id);
await expect(laufzeitVerwerfen(laufzeitB.id)).rejects.toThrow(RechnungsFehler);
});
it("legt eine Laufzeit von Hand an und verhindert doppelte Startdaten", async () => {
const { hostA } = await aufbau();
const daten = {
hostId: hostA.id,
key: "pve1c-manuell",
start: new Date("2028-01-01"),
ende: new Date("2028-12-31"),
};
await laufzeitAnlegen(daten);
await expect(laufzeitAnlegen(daten)).rejects.toThrow(RechnungsFehler);
await expect(
laufzeitAnlegen({ ...daten, start: new Date("2029-01-01"), ende: new Date("2028-12-31") }),
).rejects.toThrow(RechnungsFehler);
});
});
describe("Proxmox-Rechnung", () => {
it("erfasst eine Rechnung und bestätigt die Laufzeit dabei", async () => {
const { laufzeitA } = await aufbau();
const ergebnis = await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
expect(ergebnis.sammelrechnung).toBe(false);
const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({
where: { id: laufzeitA.id },
include: { proxmoxPosition: { include: { rechnung: true } } },
});
expect(laufzeit.status).toBe("BESTAETIGT");
expect(laufzeit.proxmoxPosition?.rechnung.nummer).toBe("PX-2026-14790");
expect(Number(laufzeit.proxmoxPosition?.nettoBetrag)).toBe(115);
expect(laufzeit.proxmoxPosition?.rechnung.faelligAm?.toISOString().slice(0, 10)).toBe("2026-09-24");
});
it("hängt eine zweite Laufzeit an dieselbe Rechnungsnummer an (Sammelrechnung)", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
const zweite = await proxmoxRechnungErfassen(laufzeitB.id, { ...EINKAUF, nettoBetrag: 220 });
expect(zweite.sammelrechnung).toBe(true);
expect(await testPrisma.proxmoxRechnung.count()).toBe(1);
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "PX-2026-14790" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
// Der Rechnungsbetrag ist die Summe der Positionen.
expect(Number(rechnung.nettoBetrag)).toBe(335);
});
it("ändert eine bereits erfasste Rechnung, statt eine zweite Position anzulegen", async () => {
const { laufzeitA } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nettoBetrag: 130, bezahlt: true });
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "PX-2026-14790" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(1);
expect(Number(rechnung.nettoBetrag)).toBe(130);
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
});
it("verlangt eine Rechnungsnummer", async () => {
const { laufzeitA } = await aufbau();
await expect(proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nummer: " " })).rejects.toThrow(
RechnungsFehler,
);
});
});
describe("Kundenrechnung", () => {
it("erfasst eine Kundenrechnung mit Position", async () => {
const { laufzeitA, kunde } = await aufbau();
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0001",
datum: new Date("2026-10-01"),
nettoBetrag: 165,
bezahlt: false,
});
const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({
where: { nummer: "RE-2026-0001" },
include: { positionen: true },
});
expect(rechnung.kundeId).toBe(kunde.id);
expect(rechnung.erstellt).toBe(true);
expect(Number(rechnung.nettoBetrag)).toBe(165);
expect(rechnung.positionen).toHaveLength(1);
});
it("fasst mehrere Laufzeiten desselben Kunden zu einer Rechnung zusammen", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0002",
nettoBetrag: 165,
bezahlt: false,
});
const zweite = await kundenrechnungErfassen(laufzeitB.id, {
erstellt: true,
nummer: "RE-2026-0002",
nettoBetrag: 320,
bezahlt: false,
});
expect(zweite.sammelrechnung).toBe(true);
const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({
where: { nummer: "RE-2026-0002" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
expect(Number(rechnung.nettoBetrag)).toBe(485);
});
it("weist eine Rechnungsnummer ab, die einem anderen Kunden gehört", async () => {
const { laufzeitA } = await aufbau();
const fremderKunde = await testPrisma.kunde.create({
data: { name: "Huber Logistik AG", kundennummer: "K-1002" },
});
const fremderHost = await testPrisma.host.create({
data: { agentId: "a-3", hostname: "pve-huber", produkt: "PVE", kundeId: fremderKunde.id },
});
const fremdeLaufzeit = await testPrisma.laufzeit.create({
data: {
hostId: fremderHost.id,
key: "pve1c-cccc",
start: new Date("2026-11-01"),
ende: new Date("2027-10-31"),
status: "BESTAETIGT",
},
});
await kundenrechnungErfassen(fremdeLaufzeit.id, {
erstellt: true,
nummer: "RE-2026-0003",
nettoBetrag: 100,
bezahlt: false,
});
await expect(
kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0003",
nettoBetrag: 165,
bezahlt: false,
}),
).rejects.toThrow(RechnungsFehler);
});
it("lehnt eine Kundenrechnung für einen Host ohne Kunde ab", async () => {
const host = await testPrisma.host.create({
data: { agentId: "a-ohne", hostname: "pve-ohne-kunde", produkt: "PVE" },
});
const laufzeit = await testPrisma.laufzeit.create({
data: {
hostId: host.id,
key: "pve1c-dddd",
start: new Date("2026-09-01"),
ende: new Date("2027-08-31"),
status: "BESTAETIGT",
},
});
await expect(
kundenrechnungErfassen(laufzeit.id, { erstellt: true, bezahlt: false, nettoBetrag: 10 }),
).rejects.toThrow(RechnungsFehler);
});
});
describe("Häkchen in der Tabelle", () => {
it("setzt „Proxmox bezahlt“ samt Bezahldatum und nimmt es zurück", async () => {
const { laufzeitA } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await einkaufBezahltSetzen(laufzeitA.id, true);
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
await einkaufBezahltSetzen(laufzeitA.id, false);
rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
expect(rechnung.bezahlt).toBe(false);
expect(rechnung.bezahltAm).toBeNull();
});
it("meldet sich, wenn noch keine Proxmox-Rechnung erfasst ist", async () => {
const { laufzeitA } = await aufbau();
await expect(einkaufBezahltSetzen(laufzeitA.id, true)).rejects.toThrow(RechnungsFehler);
});
it("legt beim Häkchen „Kundenrechnung erstellt“ eine Rechnung ohne Nummer an", async () => {
const { laufzeitA } = await aufbau();
await verkaufErstelltSetzen(laufzeitA.id, true);
const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({
where: { id: laufzeitA.id },
include: { kundenPosition: { include: { rechnung: true } } },
});
expect(laufzeit.kundenPosition?.rechnung.erstellt).toBe(true);
expect(laufzeit.kundenPosition?.rechnung.nummer).toBeNull();
// Die Nummer lässt sich später nachtragen, ohne dass eine zweite Rechnung entsteht.
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0009",
nettoBetrag: 165,
bezahlt: false,
});
expect(await testPrisma.kundenrechnung.count()).toBe(1);
const nachgetragen = await testPrisma.kundenrechnung.findFirstOrThrow();
expect(nachgetragen.nummer).toBe("RE-2026-0009");
expect(Number(nachgetragen.nettoBetrag)).toBe(165);
});
it("setzt „Kundenrechnung bezahlt“", async () => {
const { laufzeitA } = await aufbau();
await verkaufErstelltSetzen(laufzeitA.id, true);
await verkaufBezahltSetzen(laufzeitA.id, true);
const rechnung = await testPrisma.kundenrechnung.findFirstOrThrow();
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
});
});
});