Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b745910afa | ||
|
|
840e902b26 | ||
|
|
5e5028c53d | ||
|
|
83320f0443 |
@@ -17,6 +17,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.26.0] – 2026-10-04
|
||||
|
||||
### Hinzugefügt
|
||||
- Kennzeichen **Eigenbedarf** am Kunden: für eigene Hosts entfallen Kundenrechnung, die zugehörigen Warnungen, die Aufgabe „Kundenrechnung erstellen“ und die Marge – die Proxmox-Rechnung wird weiterhin geführt (#044)
|
||||
|
||||
## [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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 22 h 53 min
|
||||
**Gesamt:** 24 h 38 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -48,6 +48,8 @@
|
||||
| #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 |
|
||||
| #044 Eigenbedarf | 0 h 40 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -70,7 +72,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.26.0
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -216,6 +218,12 @@
|
||||
- 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
|
||||
|
||||
- Eigenbedarf (#044): Eigene Hosts brauchen einen Kunden, sonst fehlen sie in jeder Auswertung – als gewöhnlicher Kunde erzeugen sie aber Aufgaben, die es nicht gibt, und eine negative Marge
|
||||
- Die Einkaufsseite bleibt vollständig: Eigene Hosts kosten echtes Geld
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -237,6 +237,16 @@ mit Marge. Die vier Kästen oben fassen Hosts, Einkauf, Verkauf und Marge zusamm
|
||||
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
|
||||
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
|
||||
|
||||
### Eigene Hosts
|
||||
|
||||
Auch die eigenen Hosts gehören einem Kunden zugeordnet – sonst fehlen sie in jeder Auswertung und
|
||||
lösen die Warnung „Host ohne Kunde“ aus. Lege dafür dich selbst als Kunden an und setze im
|
||||
Kundenformular den Haken **Eigenbedarf**.
|
||||
|
||||
Für solche Kunden entfällt alles, was mit Weiterberechnung zu tun hat: keine Kundenrechnung, keine
|
||||
Warnungen darüber, keine Aufgabe „Kundenrechnung erstellen“ und keine Marge. Die Proxmox-Rechnung
|
||||
wird weiterhin geführt und erinnert ans Bezahlen – die Kosten fallen ja an.
|
||||
|
||||
---
|
||||
|
||||
## 6. Laufzeiten und Rechnungen
|
||||
@@ -279,6 +289,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.
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 178 KiB |
@@ -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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# #044 Eigene Hosts ohne Weiterberechnung
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.26.0
|
||||
**Labels:** backend, ui, alerts
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||
|
||||
## Auslöser
|
||||
„Ich habe mich auch selbst als Kunden angelegt, da ich ja auch Hosts habe. Aber mir selbst schreibe
|
||||
ich ja keine E-Mails.“
|
||||
|
||||
## Das Problem
|
||||
Die eigenen Hosts brauchen einen Kunden – sonst stehen sie unter *Nicht zugeordnet* und lösen die
|
||||
Warnung „Host ohne Kunde“ aus. Als gewöhnlicher Kunde erzeugen sie aber lauter Aufgaben, die es nicht
|
||||
gibt: „Kundenrechnung erstellen“, „Kundenrechnung unbezahlt“, dazu eine negative Marge in jeder
|
||||
Summe, weil dem Einkauf kein Verkauf gegenübersteht.
|
||||
|
||||
## Behebung
|
||||
Kennzeichen **Eigenbedarf** am Kunden. Für dessen Hosts gilt:
|
||||
|
||||
| | Verhalten |
|
||||
|---|---|
|
||||
| Proxmox-Rechnung | unverändert – die Kosten fallen ja an |
|
||||
| Kundenrechnung | entfällt; das Formular wird durch eine Erklärung ersetzt |
|
||||
| Warnungen „Kundenrechnung fehlt / unbezahlt“ | entfallen |
|
||||
| Warnung „Kunde hat bezahlt – Einkauf offen“ | entfällt |
|
||||
| Aufgabe „Kundenrechnung erstellen“ | entfällt |
|
||||
| Marge | bleibt leer statt negativ |
|
||||
| Summen | Einkauf zählt mit, Verkauf und Marge nicht |
|
||||
|
||||
Dazu ein Abzeichen in der Kundenliste und auf der Laufzeit.
|
||||
|
||||
## Entscheidung
|
||||
Die Einkaufsseite bleibt vollständig erhalten. Eigene Hosts kosten echtes Geld, und die Rechnung von
|
||||
Proxmox will bezahlt werden – nur die Weiterberechnung entfällt.
|
||||
|
||||
Die Alternative wäre gewesen, eigene Hosts ohne Kunden zu lassen und die Warnung „Host ohne Kunde“
|
||||
abzuschalten. Dann fehlten sie aber in jeder Auswertung nach Kunde, und der Einkauf ließe sich nicht
|
||||
mehr zuordnen.
|
||||
@@ -46,4 +46,6 @@ 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 |
|
||||
| [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min |
|
||||
| | **Summe** | | | **175 h** | **24 h 38 min** |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.24.1",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Eigenbedarf: Kunden, die man selbst ist (#044).
|
||||
-- Für sie gibt es keine Kundenrechnung – und damit auch keine Warnungen darüber und keine Marge.
|
||||
ALTER TABLE "Kunde" ADD COLUMN "eigenbedarf" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -61,6 +61,9 @@ model Kunde {
|
||||
ansprechpartner String?
|
||||
email String?
|
||||
telefon String?
|
||||
/// Eigenbedarf: eigene Hosts, die nicht weiterberechnet werden. Für sie entfallen
|
||||
/// Kundenrechnung, die zugehörigen Warnungen und die Marge (#044).
|
||||
eigenbedarf Boolean @default(false)
|
||||
notizen String?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
email: kunde.email,
|
||||
telefon: kunde.telefon,
|
||||
eigenbedarf: kunde.eigenbedarf,
|
||||
notizen: kunde.notizen,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
{/* 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>
|
||||
|
||||
@@ -60,6 +60,9 @@ export default async function KundenSeite() {
|
||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||
{kunde.name}
|
||||
</Link>
|
||||
{kunde.eigenbedarf ? (
|
||||
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
|
||||
) : null}
|
||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||
</td>
|
||||
<td className="pl-mono" data-label="Kundennummer">
|
||||
|
||||
@@ -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,11 +35,27 @@ 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);
|
||||
|
||||
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
|
||||
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
||||
|
||||
return (
|
||||
@@ -127,6 +145,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 ? (
|
||||
@@ -192,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
|
||||
<Card
|
||||
titel="Kundenrechnung (Verkauf)"
|
||||
rahmen={verkauf?.erstellt ? "info" : "warning"}
|
||||
rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
|
||||
werkzeuge={
|
||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
||||
eigenbedarf ? (
|
||||
<span className="badge pl-bg-neutral">Eigenbedarf</span>
|
||||
) : (
|
||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{eigenbedarf ? (
|
||||
<p className="mb-0 text-body-secondary">
|
||||
<strong>{laufzeit.host.kunde?.name}</strong> ist als Eigenbedarf gekennzeichnet – diese
|
||||
Hosts werden nicht weiterberechnet. Deshalb gibt es hier keine Kundenrechnung, keine
|
||||
Warnung darüber und keine Marge. Die Proxmox-Rechnung wird weiterhin geführt.
|
||||
</p>
|
||||
) : (
|
||||
<KundenrechnungFormular
|
||||
laufzeitId={laufzeit.id}
|
||||
hostId={laufzeit.host.id}
|
||||
@@ -213,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
anhangName: verkauf?.anhang?.dateiname ?? null,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface KundeFormularWerte {
|
||||
ansprechpartner: string | null;
|
||||
email: string | null;
|
||||
telefon: string | null;
|
||||
eigenbedarf: boolean;
|
||||
notizen: string | null;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,26 @@ export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
|
||||
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input pl-touch-target"
|
||||
type="checkbox"
|
||||
id="eigenbedarf"
|
||||
name="eigenbedarf"
|
||||
value="an"
|
||||
defaultChecked={werte?.eigenbedarf ?? false}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="eigenbedarf">
|
||||
Eigenbedarf – diese Hosts werden nicht weiterberechnet
|
||||
</label>
|
||||
<div className="form-text">
|
||||
Für solche Kunden entfällt die Kundenrechnung: keine Warnungen darüber, keine Marge. Die
|
||||
Einkaufsrechnung von Proxmox wird weiterhin geführt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="notizen">
|
||||
Notizen
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const kundeSchema = z.object({
|
||||
ansprechpartner: z.string().nullable(),
|
||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||
telefon: z.string().nullable(),
|
||||
eigenbedarf: z.boolean().default(false),
|
||||
notizen: z.string().nullable(),
|
||||
});
|
||||
|
||||
@@ -38,6 +39,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||
email: leerZuNull(formular.get("email")),
|
||||
telefon: leerZuNull(formular.get("telefon")),
|
||||
eigenbedarf: formular.get("eigenbedarf") === "an",
|
||||
notizen: leerZuNull(formular.get("notizen")),
|
||||
});
|
||||
|
||||
|
||||
@@ -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.";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,13 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
||||
prisma.laufzeit.findMany({
|
||||
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
||||
include: {
|
||||
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
|
||||
host: {
|
||||
select: {
|
||||
hostname: true,
|
||||
kundeId: true,
|
||||
kunde: { select: { name: true, eigenbedarf: true } },
|
||||
},
|
||||
},
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
@@ -51,6 +57,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
||||
hostname: laufzeit.host.hostname,
|
||||
kundeId: laufzeit.host.kundeId,
|
||||
kundeName: laufzeit.host.kunde?.name ?? null,
|
||||
kundeEigenbedarf: laufzeit.host.kunde?.eigenbedarf ?? false,
|
||||
key: laufzeit.key,
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface LaufzeitDaten {
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
kundeId: string | null;
|
||||
/** Eigene Hosts werden nicht weiterberechnet – die Verkaufsseite entfällt (#044). */
|
||||
kundeEigenbedarf: boolean;
|
||||
kundeName: string | null;
|
||||
key: string;
|
||||
start: Date;
|
||||
@@ -359,7 +361,10 @@ const kundenrechnungFehlt: Regel = {
|
||||
standardSchwellwerte: {},
|
||||
pruefe(kontext) {
|
||||
return kontext.laufzeiten
|
||||
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
|
||||
.filter(
|
||||
(laufzeit) =>
|
||||
laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt && !laufzeit.kundeEigenbedarf,
|
||||
)
|
||||
.filter((laufzeit) => laufzeit.kundeId !== null)
|
||||
.map((laufzeit) => ({
|
||||
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
|
||||
@@ -384,6 +389,7 @@ const kundenrechnungUnbezahlt: Regel = {
|
||||
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
|
||||
|
||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||
if (laufzeit.kundeEigenbedarf) return [];
|
||||
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
|
||||
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
|
||||
if (offenSeit < tage) return [];
|
||||
@@ -463,6 +469,8 @@ const kundeBezahltEinkaufOffen: Regel = {
|
||||
standardSchwellwerte: {},
|
||||
pruefe(kontext) {
|
||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||
// Beim Eigenbedarf gibt es keine Kundenzahlung, auf die man warten würde.
|
||||
if (laufzeit.kundeEigenbedarf) return [];
|
||||
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
|
||||
|
||||
return [
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function offeneAufgaben(grenze = 12) {
|
||||
},
|
||||
orderBy: { start: "asc" },
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true, kunde: { select: { name: true } } } },
|
||||
host: { select: { id: true, hostname: true, kunde: { select: { name: true, eigenbedarf: true } } } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
@@ -111,7 +111,8 @@ export async function offeneAufgaben(grenze = 12) {
|
||||
});
|
||||
}
|
||||
|
||||
if (einkauf && !laufzeit.kundenPosition?.rechnung.erstellt) {
|
||||
// Für Eigenbedarf gibt es keine Kundenrechnung zu schreiben (#044).
|
||||
if (einkauf && !laufzeit.host.kunde?.eigenbedarf && !laufzeit.kundenPosition?.rechnung.erstellt) {
|
||||
aufgaben.push({
|
||||
id: `${laufzeit.id}-verkauf`,
|
||||
titel: "Kundenrechnung erstellen",
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
...laufzeitSicht,
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPosition: { isNot: null },
|
||||
// Eigenbedarf wird nicht weiterberechnet – hier ist nichts offen (#044).
|
||||
host: { kunde: { eigenbedarf: false } },
|
||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function kundenListe() {
|
||||
return {
|
||||
id: kunde.id,
|
||||
name: kunde.name,
|
||||
eigenbedarf: kunde.eigenbedarf,
|
||||
kundennummer: kunde.kundennummer,
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
hosts: kunde._count.hosts,
|
||||
|
||||
@@ -67,7 +67,13 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
where,
|
||||
orderBy: [{ start: "desc" }],
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
kunde: { select: { id: true, name: true, eigenbedarf: true } },
|
||||
},
|
||||
},
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
@@ -82,6 +88,8 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||
: null;
|
||||
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||
// Eigene Hosts werden nicht weiterberechnet; eine Marge daraus wäre eine erfundene Zahl (#044).
|
||||
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
|
||||
|
||||
return {
|
||||
id: laufzeit.id,
|
||||
@@ -101,21 +109,26 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
||||
verkaufBetrag: verkauf,
|
||||
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
|
||||
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
|
||||
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
|
||||
eigenbedarf,
|
||||
marge: !eigenbedarf && einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Eigenbedarf zählt beim Einkauf mit – es sind echte Kosten –, bleibt bei Verkauf und Marge
|
||||
// aber außen vor.
|
||||
const summen = zeilen.reduce(
|
||||
(summe, zeile) => ({
|
||||
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
|
||||
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
|
||||
einkaufVerkauft: summe.einkaufVerkauft + (zeile.eigenbedarf ? 0 : (zeile.einkaufBetrag ?? 0)),
|
||||
eigenbedarf: summe.eigenbedarf + (zeile.eigenbedarf ? (zeile.einkaufBetrag ?? 0) : 0),
|
||||
}),
|
||||
{ einkauf: 0, verkauf: 0 },
|
||||
{ einkauf: 0, verkauf: 0, einkaufVerkauft: 0, eigenbedarf: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
zeilen,
|
||||
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
|
||||
summen: { ...summen, marge: summen.verkauf - summen.einkaufVerkauft },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,7 +158,7 @@ export async function laufzeitDetail(id: string) {
|
||||
id: true,
|
||||
hostname: true,
|
||||
produkt: true,
|
||||
kunde: { select: { id: true, name: true } },
|
||||
kunde: { select: { id: true, name: true, eigenbedarf: true } },
|
||||
},
|
||||
},
|
||||
proxmoxPosition: {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { alleRegelnPruefen, type LaufzeitDaten, type RegelKontext } from "@/server/alerts/regeln";
|
||||
|
||||
/** Eigenbedarf: eigene Hosts ohne Weiterberechnung (#044). */
|
||||
|
||||
const JETZT = new Date("2026-10-04T12:00:00Z");
|
||||
|
||||
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
|
||||
return {
|
||||
id: "l1",
|
||||
hostId: "h1",
|
||||
hostname: "hv-ste-pve-01",
|
||||
kundeId: "k1",
|
||||
kundeEigenbedarf: false,
|
||||
kundeName: "Florian Wilde IT SysOp",
|
||||
key: "pve2s-aaaa",
|
||||
start: new Date("2026-06-03"),
|
||||
ende: new Date("2027-06-02"),
|
||||
einkaufNummer: "291017",
|
||||
einkaufBetrag: 990,
|
||||
einkaufBezahlt: true,
|
||||
einkaufFaelligAm: new Date("2026-10-17"),
|
||||
verkaufNummer: null,
|
||||
verkaufBetrag: null,
|
||||
verkaufErstellt: false,
|
||||
verkaufBezahlt: false,
|
||||
verkaufDatum: null,
|
||||
...überschreibungen,
|
||||
};
|
||||
}
|
||||
|
||||
function kontext(laufzeiten: LaufzeitDaten[]): RegelKontext {
|
||||
return { jetzt: JETZT, hosts: [], laufzeiten, einstellungen: new Map() };
|
||||
}
|
||||
|
||||
function regeln(k: RegelKontext, name: string) {
|
||||
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
|
||||
}
|
||||
|
||||
describe("Eigenbedarf", () => {
|
||||
it("mahnt bei einem normalen Kunden die fehlende Kundenrechnung an", () => {
|
||||
expect(regeln(kontext([laufzeit()]), "kundenrechnung-fehlt")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("schweigt beim Eigenbedarf – es gibt niemanden, dem man schreibt", () => {
|
||||
expect(regeln(kontext([laufzeit({ kundeEigenbedarf: true })]), "kundenrechnung-fehlt")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("mahnt beim Eigenbedarf keine unbezahlte Kundenrechnung an", () => {
|
||||
const k = kontext([
|
||||
laufzeit({
|
||||
kundeEigenbedarf: true,
|
||||
verkaufErstellt: true,
|
||||
verkaufDatum: new Date("2026-01-01"),
|
||||
verkaufBezahlt: false,
|
||||
}),
|
||||
]);
|
||||
expect(regeln(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("meldet beim Eigenbedarf nicht „Kunde hat bezahlt“", () => {
|
||||
const k = kontext([
|
||||
laufzeit({ kundeEigenbedarf: true, verkaufBezahlt: true, einkaufBezahlt: false }),
|
||||
]);
|
||||
expect(regeln(k, "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lässt die Einkaufsseite unberührt", () => {
|
||||
// Die Proxmox-Rechnung muss auch für eigene Hosts bezahlt werden.
|
||||
const k = kontext([
|
||||
laufzeit({
|
||||
kundeEigenbedarf: true,
|
||||
einkaufBezahlt: false,
|
||||
einkaufFaelligAm: new Date("2026-10-06"),
|
||||
}),
|
||||
]);
|
||||
expect(regeln(k, "proxmox-rechnung-faellig")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten
|
||||
hostname: "pve-01",
|
||||
kundeId: "k1",
|
||||
kundeName: "Brandl Elektrotechnik GmbH",
|
||||
kundeEigenbedarf: false,
|
||||
key: "pve1c-aaaa",
|
||||
start: inTagen(-100),
|
||||
ende: inTagen(265),
|
||||
|
||||