Eine echte Proxmox-Rechnung zeigt je Subscription-Key eine Zeile mit Listenpreis und eine mit "Client Discount". Unser Modell hing den Rabatt an die Rechnung - bei einer Sammelrechnung ueber zwei Hosts liesse er sich damit keinem Host zuordnen, und die Marge je Host waere falsch. Schlimmer: Er wurde nirgends abgezogen. Jetzt haengt der Rabatt an der Position, der Rechnungsbetrag ist die Summe nach Abzug, und Marge wie Summen rechnen mit dem tatsaechlich gezahlten Betrag. Die Migration verteilt vorhandene Rabatte anteilig auf die Positionen und rechnet die Summen neu - nichts geht verloren. Dazu ein eigener Steuersatz fuer den Einkauf mit Vorgabe 0: Proxmox rechnet gegenueber Unternehmen mit USt-IdNr. ueber Reverse Charge ab. Neue zwoelfte Warnregel aus Florians Ablauf: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen - der Moment, an dem man selbst zahlt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
import { prisma } from "@/lib/db";
|
||
import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
||
|
||
/** Abfragen rund um Kunden. Beträge kommen als Decimal aus der Datenbank und werden hier zu Zahlen. */
|
||
|
||
export async function kundenListe() {
|
||
const nurKunde = await sichtbarerKunde();
|
||
|
||
const kunden = await prisma.kunde.findMany({
|
||
where: nurKunde ? { id: nurKunde } : {},
|
||
orderBy: { name: "asc" },
|
||
include: {
|
||
standorte: true,
|
||
hosts: {
|
||
select: { id: true, subStatus: true, subNextduedate: true },
|
||
},
|
||
_count: { select: { hosts: true } },
|
||
},
|
||
});
|
||
|
||
const offeneAlerts = await prisma.alert.groupBy({
|
||
by: ["kundeId"],
|
||
where: { erledigtAm: null, kundeId: { not: null } },
|
||
_count: { _all: true },
|
||
});
|
||
|
||
const alertsProKunde = new Map(offeneAlerts.map((e) => [e.kundeId, e._count._all]));
|
||
|
||
return kunden.map((kunde) => {
|
||
const ablaufDaten = kunde.hosts
|
||
.map((h) => h.subNextduedate)
|
||
.filter((d): d is Date => d !== null)
|
||
.sort((a, b) => a.getTime() - b.getTime());
|
||
|
||
return {
|
||
id: kunde.id,
|
||
name: kunde.name,
|
||
kundennummer: kunde.kundennummer,
|
||
ansprechpartner: kunde.ansprechpartner,
|
||
hosts: kunde._count.hosts,
|
||
aktiv: kunde.hosts.filter((h) => h.subStatus === "ACTIVE").length,
|
||
naechsterAblauf: ablaufDaten[0] ?? null,
|
||
offeneAlerts: alertsProKunde.get(kunde.id) ?? 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
export async function kundeMitDetails(id: string) {
|
||
const nurKunde = await sichtbarerKunde();
|
||
if (nurKunde && nurKunde !== id) return null;
|
||
|
||
return prisma.kunde.findUnique({
|
||
where: { id },
|
||
include: {
|
||
standorte: { orderBy: { name: "asc" } },
|
||
hosts: { orderBy: { hostname: "asc" } },
|
||
alerts: { where: { erledigtAm: null }, orderBy: { geoeffnetAm: "desc" } },
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */
|
||
export async function laufzeitenVonKunde(kundeId: string) {
|
||
const nurKunde = await sichtbarerKunde();
|
||
if (nurKunde && nurKunde !== kundeId) return [];
|
||
const einkaufSichtbar = await darfEinkaufSehen();
|
||
|
||
const laufzeiten = await prisma.laufzeit.findMany({
|
||
where: { host: { kundeId } },
|
||
orderBy: { start: "desc" },
|
||
include: {
|
||
host: { select: { id: true, hostname: true } },
|
||
proxmoxPosition: { include: { rechnung: true } },
|
||
kundenPosition: { include: { rechnung: true } },
|
||
},
|
||
});
|
||
|
||
return laufzeiten.map((laufzeit) => ({
|
||
id: laufzeit.id,
|
||
hostId: laufzeit.hostId,
|
||
hostname: laufzeit.host.hostname,
|
||
key: laufzeit.key,
|
||
start: laufzeit.start,
|
||
ende: laufzeit.ende,
|
||
vorschlag: laufzeit.status === "VORSCHLAG",
|
||
einkauf:
|
||
einkaufSichtbar && laufzeit.proxmoxPosition
|
||
? {
|
||
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
||
// Nach Abzug des Rabatts – siehe #037.
|
||
nettoBetrag:
|
||
Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0),
|
||
listenpreis: Number(laufzeit.proxmoxPosition.nettoBetrag),
|
||
rabatt: Number(laufzeit.proxmoxPosition.rabatt ?? 0),
|
||
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
|
||
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
||
}
|
||
: null,
|
||
verkauf: laufzeit.kundenPosition
|
||
? {
|
||
nummer: laufzeit.kundenPosition.rechnung.nummer,
|
||
nettoBetrag: Number(laufzeit.kundenPosition.nettoBetrag),
|
||
erstellt: laufzeit.kundenPosition.rechnung.erstellt,
|
||
bezahlt: laufzeit.kundenPosition.rechnung.bezahlt,
|
||
}
|
||
: null,
|
||
}));
|
||
}
|
||
|
||
export type KundenLaufzeit = Awaited<ReturnType<typeof laufzeitenVonKunde>>[number];
|
||
|
||
export function summen(laufzeiten: KundenLaufzeit[]) {
|
||
const einkauf = laufzeiten.reduce((s, l) => s + (l.einkauf?.nettoBetrag ?? 0), 0);
|
||
const verkauf = laufzeiten.reduce((s, l) => s + (l.verkauf?.nettoBetrag ?? 0), 0);
|
||
return { einkauf, verkauf, marge: verkauf - einkauf };
|
||
}
|
||
|
||
export function summenProJahr(laufzeiten: KundenLaufzeit[]) {
|
||
const proJahr = new Map<string, { einkauf: number; verkauf: number }>();
|
||
for (const laufzeit of laufzeiten) {
|
||
const jahr = String(laufzeit.start.getUTCFullYear());
|
||
const eintrag = proJahr.get(jahr) ?? { einkauf: 0, verkauf: 0 };
|
||
eintrag.einkauf += laufzeit.einkauf?.nettoBetrag ?? 0;
|
||
eintrag.verkauf += laufzeit.verkauf?.nettoBetrag ?? 0;
|
||
proJahr.set(jahr, eintrag);
|
||
}
|
||
return [...proJahr.entries()]
|
||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||
.map(([jahr, werte]) => ({ jahr, ...werte, marge: werte.verkauf - werte.einkauf }));
|
||
}
|
||
|
||
/** Offene Rechnungspunkte je Kunde: unbezahlter Einkauf bzw. fehlende Kundenrechnung. */
|
||
export async function offeneRechnungspunkteProKunde() {
|
||
const laufzeiten = await prisma.laufzeit.findMany({
|
||
where: { status: "BESTAETIGT", host: { kundeId: { not: null } } },
|
||
select: {
|
||
host: { select: { kundeId: true } },
|
||
proxmoxPosition: { select: { rechnung: { select: { bezahlt: true } } } },
|
||
kundenPosition: { select: { rechnung: { select: { erstellt: true } } } },
|
||
},
|
||
});
|
||
|
||
const proKunde = new Map<string, { offen: number; gesamt: number }>();
|
||
for (const laufzeit of laufzeiten) {
|
||
const kundeId = laufzeit.host.kundeId;
|
||
if (!kundeId) continue;
|
||
const eintrag = proKunde.get(kundeId) ?? { offen: 0, gesamt: 0 };
|
||
eintrag.gesamt += 2;
|
||
if (!laufzeit.proxmoxPosition?.rechnung.bezahlt) eintrag.offen += 1;
|
||
if (!laufzeit.kundenPosition?.rechnung.erstellt) eintrag.offen += 1;
|
||
proKunde.set(kundeId, eintrag);
|
||
}
|
||
return proKunde;
|
||
}
|