feat(kunden): Eigenbedarf fuer eigene Hosts (#044)

Eigene Hosts brauchen einen Kunden - sonst stehen sie unter "Nicht
zugeordnet" und fehlen in jeder Auswertung. Als gewoehnlicher Kunde erzeugen
sie aber Aufgaben, die es nicht gibt: Kundenrechnung erstellen, Kundenrechnung
unbezahlt, dazu eine negative Marge in jeder Summe.

Das Kennzeichen Eigenbedarf am Kunden nimmt die Verkaufsseite heraus: kein
Formular fuer die Kundenrechnung, keine Warnungen darueber, keine Aufgabe,
keine Marge - und in den Summen zaehlt der Einkauf mit, Verkauf und Marge
nicht.

Die Einkaufsseite bleibt vollstaendig: Eigene Hosts kosten echtes Geld, und
die Rechnung von Proxmox will bezahlt werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-04 06:54:43 +02:00
co-authored by Claude Opus 5
parent 5e5028c53d
commit 840e902b26
21 changed files with 231 additions and 15 deletions
@@ -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,
}}
/>
+3
View File
@@ -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">
+15 -2
View File
@@ -55,6 +55,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
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 (
@@ -226,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}
@@ -247,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
anhangName: verkauf?.anhang?.dateiname ?? null,
}}
/>
)}
</Card>
</div>
</div>
+21
View File
@@ -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
+2
View File
@@ -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")),
});
+8 -1
View File
@@ -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,
+9 -1
View File
@@ -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 [
+3 -2
View File
@@ -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",
+2
View File
@@ -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 } } }],
},
}),
+1
View File
@@ -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,
+18 -5
View File
@@ -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: {