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
+45
View File
@@ -7,6 +7,8 @@ import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBearbeiten,
laufzeitenZusammenfuehren,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
@@ -173,3 +175,46 @@ export async function laufzeitAnlegenAktion(
return "Laufzeit angelegt.";
});
}
export async function laufzeitBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("laufzeitId")?.toString();
const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start"));
const ende = datum(formular.get("ende"));
const notizen = formular.get("notizen")?.toString().trim() || null;
if (!id || !hostId || !key || !start || !ende) {
return { fehler: "Bitte Key, Beginn und Ende angeben." };
}
return ausfuehren(async () => {
await laufzeitBearbeiten(id, { key, start, ende, notizen });
seitenNeuLaden(hostId);
return "Laufzeit geändert.";
});
}
export async function laufzeitenZusammenfuehrenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("fachlichSchreiben");
const zielId = formular.get("zielId")?.toString();
const quelleId = formular.get("quelleId")?.toString();
const hostId = formular.get("hostId")?.toString();
if (!zielId || !quelleId || !hostId) return { fehler: "Bitte eine Laufzeit zum Zusammenführen wählen." };
return ausfuehren(async () => {
await laufzeitenZusammenfuehren(zielId, quelleId);
seitenNeuLaden(hostId);
return "Laufzeiten zusammengeführt.";
});
}
+101
View File
@@ -324,3 +324,104 @@ export async function laufzeitAnlegen(daten: {
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
return laufzeit;
}
/** Ändert Key, Zeitraum oder Notiz einer Laufzeit (#043). */
export async function laufzeitBearbeiten(
id: string,
daten: { key: string; start: Date; ende: Date; notizen?: string | null },
) {
const laufzeit = await prisma.laufzeit.findUnique({ where: { id } });
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (daten.ende <= daten.start) throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen.");
// Je Host und Startdatum darf es nur eine Laufzeit geben – sonst entstünden zwei Lizenzjahre
// für denselben Zeitraum, und die Rechnungen ließen sich nicht mehr zuordnen.
const kollision = await prisma.laufzeit.findUnique({
where: { hostId_start: { hostId: laufzeit.hostId, start: daten.start } },
});
if (kollision && kollision.id !== id) {
throw new RechnungsFehler(
"Für dieses Startdatum gibt es an diesem Host schon eine Laufzeit. Beide lassen sich zusammenführen.",
);
}
const geaendert = await prisma.laufzeit.update({
where: { id },
data: {
key: daten.key,
start: daten.start,
ende: daten.ende,
notizen: daten.notizen ?? null,
// Von Hand geändert heißt: der Vorschlag ist angenommen.
status: "BESTAETIGT",
},
});
await protokolliere("laufzeit.geaendert", "Laufzeit", id, {
key: daten.key,
start: daten.start,
ende: daten.ende,
});
return geaendert;
}
/**
* Führt zwei Laufzeiten desselben Hosts zusammen (#043).
*
* Gedacht für den Fall, dass neben einer von Hand erfassten Laufzeit ein Vorschlag des Agenten
* steht – etwa weil das gemeldete Ablaufdatum einen Tag abweicht. Die Rechnungspositionen der
* aufgelösten Laufzeit wandern mit, sofern das Ziel dort noch nichts hat; andernfalls bricht der
* Vorgang ab. Zwei Einkaufsrechnungen auf einem Lizenzjahr wären nicht mehr zuzuordnen.
*/
export async function laufzeitenZusammenfuehren(zielId: string, quelleId: string) {
if (zielId === quelleId) throw new RechnungsFehler("Das ist dieselbe Laufzeit.");
return prisma.$transaction(async (tx) => {
const [ziel, quelle] = await Promise.all([
tx.laufzeit.findUnique({ where: { id: zielId } }),
tx.laufzeit.findUnique({ where: { id: quelleId } }),
]);
if (!ziel || !quelle) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (ziel.hostId !== quelle.hostId) {
throw new RechnungsFehler("Zusammenführen geht nur innerhalb desselben Hosts.");
}
if (ziel.proxmoxPositionId && quelle.proxmoxPositionId) {
throw new RechnungsFehler(
"Beide Laufzeiten haben eine Proxmox-Rechnung. Bitte zuerst die falsche Rechnung entfernen.",
);
}
if (ziel.kundenPositionId && quelle.kundenPositionId) {
throw new RechnungsFehler(
"Beide Laufzeiten haben eine Kundenrechnung. Bitte zuerst die falsche Rechnung entfernen.",
);
}
// Zuerst muss die Quelle ihre Positionen loslassen: Eine Position gehört zu genau einer
// Laufzeit, und die Datenbank wacht darüber. Erst danach darf das Ziel sie übernehmen.
await tx.laufzeit.update({
where: { id: quelleId },
data: { proxmoxPositionId: null, kundenPositionId: null },
});
await tx.laufzeit.update({
where: { id: zielId },
data: {
proxmoxPositionId: ziel.proxmoxPositionId ?? quelle.proxmoxPositionId,
kundenPositionId: ziel.kundenPositionId ?? quelle.kundenPositionId,
status: "BESTAETIGT",
notizen: [ziel.notizen, quelle.notizen].filter(Boolean).join(" · ") || null,
},
});
await tx.laufzeit.delete({ where: { id: quelleId } });
await protokolliere("laufzeit.zusammengefuehrt", "Laufzeit", zielId, {
quelle: quelleId,
quelleStart: quelle.start,
});
return ziel;
});
}