Merge branch 'phase/043-kunden-laufzeiten'
Prüfung / pruefung (push) Canceled after 0s

This commit is contained in:
2026-10-03 20:57:07 +02:00
22 changed files with 738 additions and 15 deletions
+8
View File
@@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.25.0] – 2026-10-03
### Hinzugefügt
- **Laufzeit von Hand anlegen** – für Hosts, die (noch) kein Ablaufdatum melden, und für Nachträge (#043)
- **Laufzeit bearbeiten**: Key, Beginn, Ende und Notiz lassen sich nachträglich ändern (#043)
- **Laufzeiten zusammenführen**, wenn neben einer erfassten Laufzeit ein Vorschlag mit abweichendem Datum steht; die Rechnungen wandern mit (#043)
- Kundenansicht: Laufzeit und Host verlinkt, dazu Marge und Rabatt je Zeile (#043)
## [0.24.1] – 2026-10-03
### Behoben
+6 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 22 h 53 min
**Gesamt:** 23 h 58 min
| Issue | Zeit |
|---|---|
@@ -48,6 +48,7 @@
| #040 Schlagworte sichtbar | 0 h 40 min |
| #041 Doppelte Hosts | 0 h 50 min |
| #042 QDevice im Cluster | 0 h 20 min |
| #043 Laufzeiten bearbeiten | 1 h 05 min |
| Meilenstein | Zeit |
|---|---|
@@ -70,7 +71,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.24.1
**Version:** 0.25.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -216,6 +217,9 @@
- QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice
- Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen
- 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
### 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)
+16
View File
@@ -279,6 +279,22 @@ Positionen und wird automatisch nachgeführt.
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
### Woher Laufzeiten kommen
Eine Laufzeit ist ein Lizenzjahr eines Hosts. Es gibt zwei Wege:
1. **Vom Agent vorgeschlagen.** Meldet ein Host ein Ablaufdatum, legt ProxLedger eine Laufzeit von
diesem Datum bis ein Jahr später an – zunächst als *Vorschlag*. Unter *Rechnungen → Vorschläge*
bestätigst oder verwirfst du ihn.
2. **Von Hand angelegt** über *Rechnungen → Laufzeit von Hand anlegen*. Nötig, wenn ein Host noch kein
Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein zurückliegendes Lizenzjahr
nachgetragen wird.
Jede Laufzeit lässt sich später ändern (*Laufzeit bearbeiten* in der Detailansicht): Key, Beginn, Ende
und Notiz. Stehen für denselben Host zwei Laufzeiten, die dasselbe Lizenzjahr meinen – etwa weil das
gemeldete Datum um einen Tag abweicht –, lassen sie sich **zusammenführen**. Die aufgelöste Laufzeit
gibt ihre Rechnungen an die verbleibende ab.
### Der übliche Ablauf
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+42
View File
@@ -0,0 +1,42 @@
# #043 Laufzeiten anlegen, bearbeiten und zusammenführen
**Status:** erledigt
**Meilenstein:** v0.25.0
**Labels:** backend, ui
**Geschätzt:** 4 h **Tatsächlich:** 1 h 05 min
## Auslöser
Drei Rückmeldungen aus dem Einsatz:
1. „In der Kundenübersicht wäre es auch cool, die Laufzeit / Rechnung zu überarbeiten.“
2. „Wie werden so Laufzeiten erfasst?“
3. „Auch die Laufzeit im Nachhinein zu bearbeiten geht nicht. Oder mit einer vorhandenen zu
verknüpfen oder zu vereinen.“
## Ausgangslage
Laufzeiten entstanden ausschließlich als **Vorschlag** aus dem Agenten-Report: Meldet ein Host ein
Ablaufdatum, legt ProxLedger eine Laufzeit von diesem Datum bis ein Jahr später an. Bestätigen oder
verwerfen war möglich – mehr nicht:
- **Anlegen von Hand** gab es als Server-Aktion, aber **nirgends in der Oberfläche**.
- **Ändern** war unmöglich. Weicht das gemeldete Datum von der Bestellung ab, blieb es falsch.
- **Zusammenführen** gab es nicht, obwohl der Fall zwangsläufig auftritt: Neben einer erfassten
Laufzeit legt der Agent eine zweite an, wenn das Ablaufdatum um einen Tag abweicht.
- Aus der **Kundenansicht** führte kein Weg in die Laufzeit.
## Behebung
- Formular *Laufzeit von Hand anlegen* auf der Rechnungsseite; der gemeldete Key wird vorgeschlagen,
das Ende aus dem Beginn vorbelegt (ein Jahr minus ein Tag – dieselbe Rechnung wie beim Vorschlag).
- *Laufzeit bearbeiten* im Laufzeit-Detail: Key, Beginn, Ende, Notiz. Eine Änderung bestätigt den
Vorschlag zugleich.
- *Zusammenführen* mit einer anderen Laufzeit desselben Hosts. Die aufgelöste Laufzeit gibt ihre
Rechnungen ab, sofern das Ziel dort nichts hat; haben beide eine Rechnung derselben Art, bricht der
Vorgang mit einer Erklärung ab.
- Kundenansicht: Laufzeit und Host sind verlinkt, Marge und Rabatt stehen dabei.
## Entscheidungen
- **Kein stilles Überschreiben beim Zusammenführen.** Zwei Einkaufsrechnungen auf einem Lizenzjahr
wären nicht mehr zuzuordnen – lieber ein Abbruch mit Hinweis.
- **Beim Ändern bleibt die Regel „ein Startdatum je Host“.** Kollidiert das neue Datum, verweist die
Meldung aufs Zusammenführen.
- Beim Zusammenführen muss die Quelle ihre Positionen zuerst loslassen: Eine Rechnungsposition gehört
zu genau einer Laufzeit, und die Datenbank wacht darüber. Erst dann darf das Ziel sie übernehmen.
+2 -1
View File
@@ -46,4 +46,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
| [#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 |
| | **Summe** | | | **169 h** | **22 h 53 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** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"scripts": {
"dev": "next dev",
+42 -8
View File
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Laufzeit</th>
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
<th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{laufzeiten.map((laufzeit) => (
<tr key={laufzeit.id}>
<td>
{/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
Kundenansicht in eine Sackgasse (#043). */}
<td className="pl-karten-kopf">
<Link
href={`/rechnungen/${laufzeit.id}`}
className="fw-semibold text-decoration-none"
>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</Link>
{laufzeit.vorschlag ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>{laufzeit.hostname}</td>
<td>
<td data-label="Host">
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
{laufzeit.hostname}
</Link>
</td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
<div>
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
</div>
</td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
<td>
<td data-label="Einkauf">
{formatEuro(laufzeit.einkauf?.nettoBetrag)}
{laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? (
<div className="small text-body-secondary">
{formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "}
Rabatt
</div>
) : null}
</td>
<td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
<div>
<VerkaufBadge
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
/>
</div>
</td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td data-label="Verkauf">{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td
data-label="Marge"
className={
laufzeit.einkauf && laufzeit.verkauf
? (laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag >= 0
? "pl-text-success"
: "pl-text-danger"
: undefined
}
>
{laufzeit.einkauf && laufzeit.verkauf
? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag)
: "–"}
</td>
</tr>
))}
{laufzeiten.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
<td colSpan={7} className="text-center text-body-secondary py-4">
Noch keine Laufzeiten erfasst.
</td>
</tr>
+35 -1
View File
@@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten";
import { prisma } from "@/lib/db";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -33,7 +35,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([
einstellungen(),
faelligkeitsVorschlag(),
prisma.laufzeit.findMany({
where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } },
orderBy: { start: "desc" },
select: {
id: true,
key: true,
start: true,
ende: true,
proxmoxPositionId: true,
kundenPositionId: true,
},
}),
]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
@@ -127,6 +144,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
{marge === null ? "–" : formatEuro(marge)}
</dd>
</dl>
<hr />
<LaufzeitBearbeiten
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
schluessel={laufzeit.key}
start={laufzeit.start.toISOString().slice(0, 10)}
ende={laufzeit.ende.toISOString().slice(0, 10)}
notizen={laufzeit.notizen}
nachbarn={nachbarn.map((nachbar) => ({
id: nachbar.id,
zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`,
key: nachbar.key,
hatEinkauf: nachbar.proxmoxPositionId !== null,
hatVerkauf: nachbar.kundenPositionId !== null,
}))}
/>
</Card>
{einkauf && einkauf.positionen.length > 1 ? (
+20 -1
View File
@@ -8,6 +8,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen";
import {
anzahlVorschlaege,
laufzeitJahre,
@@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
q: einzeln(roh.q),
};
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
const [{ zeilen, summen }, jahre, kunden, vorschlaege, hostsFuerLaufzeit] = await Promise.all([
rechnungsListe(filter),
laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(),
prisma.host.findMany({
where: { archiviertAm: null },
orderBy: { hostname: "asc" },
select: { id: true, hostname: true, subKey: true, kunde: { select: { name: true } } },
}),
]);
const heute = new Date();
@@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div>
</div>
{/* Laufzeiten entstehen sonst aus den Agenten-Reports; von Hand braucht man es für
Hosts ohne gemeldetes Ablaufdatum oder für Nachträge (#043). */}
<Card titel="Laufzeit von Hand anlegen" rahmen="keiner">
<LaufzeitAnlegen
hosts={hostsFuerLaufzeit.map((host) => ({
id: host.id,
hostname: host.hostname,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
}))}
/>
</Card>
<Card
titel="Laufzeiten und Rechnungen"
rahmen="primary"
@@ -0,0 +1,131 @@
"use client";
import { useActionState, useState } from "react";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import { laufzeitAnlegenAktion } from "@/server/actions/rechnungen";
const LEER: AktionsErgebnis = {};
/** Ein Jahr minus einen Tag – dieselbe Rechnung wie beim Vorschlag aus dem Agenten-Report. */
function endeAus(start: string): string {
if (!start) return "";
const datum = new Date(`${start}T00:00:00Z`);
datum.setUTCFullYear(datum.getUTCFullYear() + 1);
datum.setUTCDate(datum.getUTCDate() - 1);
return datum.toISOString().slice(0, 10);
}
/**
* Laufzeit von Hand anlegen (#043).
*
* Der Normalfall ist der Vorschlag aus dem Agenten-Report. Von Hand braucht man es, wenn ein Host
* (noch) kein Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein
* zurückliegendes Lizenzjahr nachgetragen werden soll.
*/
export function LaufzeitAnlegen({
hosts,
}: {
hosts: { id: string; hostname: string; kundeName: string | null; subKey: string | null }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(laufzeitAnlegenAktion, LEER);
const [hostId, setHostId] = useState("");
const [key, setKey] = useState("");
const [start, setStart] = useState("");
const [ende, setEnde] = useState("");
const gewaehlt = hosts.find((host) => host.id === hostId);
return (
<form action={aktion} className="row g-3 align-items-start">
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="lz-host">
Host
</label>
<select
className="form-select"
id="lz-host"
name="hostId"
value={hostId}
onChange={(e) => {
setHostId(e.target.value);
// Den gemeldeten Key vorschlagen – abtippen ist hier die häufigste Fehlerquelle.
const host = hosts.find((h) => h.id === e.target.value);
if (host?.subKey) setKey(host.subKey);
}}
required
>
<option value="">– bitte wählen –</option>
{hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.hostname}
{host.kundeName ? ` · ${host.kundeName}` : " · nicht zugeordnet"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="lz-key">
Subscription-Key
</label>
<input
className="form-control pl-mono"
id="lz-key"
name="key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="pve2s-…"
required
/>
{gewaehlt?.subKey ? (
<div className="form-text">Gemeldet: {gewaehlt.subKey}</div>
) : (
<div className="form-text">Dieser Host meldet keinen Key – Nummer aus der Bestellung eintragen.</div>
)}
</div>
<div className="col-6 col-md-2">
<label className="form-label" htmlFor="lz-start">
Beginn
</label>
<input
type="date"
className="form-control"
id="lz-start"
name="start"
value={start}
onChange={(e) => {
setStart(e.target.value);
setEnde(endeAus(e.target.value));
}}
required
/>
</div>
<div className="col-6 col-md-2">
<label className="form-label" htmlFor="lz-ende">
Ende
</label>
<input
type="date"
className="form-control"
id="lz-ende"
name="ende"
value={ende}
onChange={(e) => setEnde(e.target.value)}
required
/>
<div className="form-text">Ein Jahr, vorbelegt.</div>
</div>
<div className="col-12">
<button type="submit" className="btn btn-outline-primary" disabled={laeuft}>
{laeuft ? "Wird angelegt …" : "Laufzeit anlegen"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useActionState, useState } from "react";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import {
laufzeitBearbeitenAktion,
laufzeitenZusammenfuehrenAktion,
} from "@/server/actions/rechnungen";
const LEER: AktionsErgebnis = {};
export interface NachbarLaufzeit {
id: string;
zeitraum: string;
key: string;
hatEinkauf: boolean;
hatVerkauf: boolean;
}
/**
* Laufzeit nachträglich ändern und mit einer anderen desselben Hosts zusammenführen (#043).
*
* Nötig, weil Laufzeiten meist aus einem Agenten-Report entstehen: Weicht das gemeldete
* Ablaufdatum von der Bestellung ab, steht die Laufzeit falsch da – oder gleich doppelt.
*/
export function LaufzeitBearbeiten({
laufzeitId,
hostId,
// Nicht `key` nennen: Das ist in React reserviert und käme hier nie an.
schluessel,
start,
ende,
notizen,
nachbarn,
}: {
laufzeitId: string;
hostId: string;
schluessel: string;
start: string;
ende: string;
notizen: string | null;
nachbarn: NachbarLaufzeit[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(laufzeitBearbeitenAktion, LEER);
const [zusammen, zusammenAktion, fuehrtZusammen] = useActionState(laufzeitenZusammenfuehrenAktion, LEER);
const [offen, setOffen] = useState(false);
return (
<>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setOffen(!offen)}
aria-expanded={offen}
>
<i className="bi bi-pencil me-1" aria-hidden="true" />
{offen ? "Bearbeiten schließen" : "Laufzeit bearbeiten"}
</button>
{offen ? (
<div className="mt-3">
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
<form action={aktion} className="row g-3 align-items-start">
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="lzb-key">
Subscription-Key
</label>
<input
className="form-control pl-mono"
id="lzb-key"
name="key"
defaultValue={schluessel}
required
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="lzb-start">
Beginn
</label>
<input type="date" className="form-control" id="lzb-start" name="start" defaultValue={start} required />
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="lzb-ende">
Ende
</label>
<input type="date" className="form-control" id="lzb-ende" name="ende" defaultValue={ende} required />
</div>
<div className="col-12">
<label className="form-label" htmlFor="lzb-notizen">
Notiz
</label>
<textarea
className="form-control"
id="lzb-notizen"
name="notizen"
rows={2}
defaultValue={notizen ?? ""}
/>
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Änderung speichern"}
</button>
</div>
</form>
{nachbarn.length > 0 ? (
<div className="mt-4 pt-3 border-top">
<h4 className="h6">Mit einer anderen Laufzeit zusammenführen</h4>
<p className="small text-body-secondary">
Die gewählte Laufzeit wird aufgelöst; ihre Rechnungen wandern hierher, sofern hier noch
keine stehen. Nützlich, wenn der Agent neben einer erfassten Laufzeit einen Vorschlag
mit abweichendem Datum angelegt hat.
</p>
{zusammen.fehler ? <div className="alert alert-danger py-2">{zusammen.fehler}</div> : null}
{zusammen.erfolg ? <div className="alert alert-success py-2">{zusammen.erfolg}</div> : null}
<form action={zusammenAktion} className="row g-2 align-items-end">
<input type="hidden" name="zielId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<div className="col-12 col-md-8">
<label className="form-label" htmlFor="lzb-quelle">
Diese Laufzeit auflösen
</label>
<select className="form-select" id="lzb-quelle" name="quelleId" required>
<option value="">– bitte wählen –</option>
{nachbarn.map((nachbar) => (
<option key={nachbar.id} value={nachbar.id}>
{nachbar.zeitraum} · {nachbar.key}
{nachbar.hatEinkauf ? " · mit Einkauf" : ""}
{nachbar.hatVerkauf ? " · mit Verkauf" : ""}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<button type="submit" className="btn btn-outline-danger" disabled={fuehrtZusammen}>
{fuehrtZusammen ? "Wird zusammengeführt …" : "Zusammenführen"}
</button>
</div>
</form>
</div>
) : null}
</div>
) : null}
</>
);
}
+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;
});
}
+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);
});
});