feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)

Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere
Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme,
Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:54:45 +02:00
co-authored by Claude Opus 5
parent 1e1d6c4418
commit 558dfea34c
23 changed files with 2015 additions and 86 deletions
+17 -3
View File
@@ -66,7 +66,19 @@ export async function offeneAufgaben() {
},
});
const aufgaben = [];
const aufgaben: {
id: string;
titel: string;
detail: string;
betrag: number | null;
faelligAm: Date | null;
dringlichkeit: "gefahr" | "warnung";
badgeText: string;
href: string;
start: Date;
ende: Date;
aktion: { laufzeitId: string; hostId: string; feld: "einkaufBezahlt" | "verkaufErstellt" };
}[] = [];
for (const laufzeit of laufzeiten) {
const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`;
const einkauf = laufzeit.proxmoxPosition?.rechnung;
@@ -80,9 +92,10 @@ export async function offeneAufgaben() {
faelligAm: einkauf.faelligAm,
dringlichkeit: "gefahr" as const,
badgeText: "Einkauf",
href: `/hosts/${laufzeit.hostId}`,
href: `/rechnungen/${laufzeit.id}`,
start: laufzeit.start,
ende: laufzeit.ende,
aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "einkaufBezahlt" as const },
});
}
@@ -95,9 +108,10 @@ export async function offeneAufgaben() {
faelligAm: null,
dringlichkeit: "warnung" as const,
badgeText: "Verkauf",
href: "/rechnungen",
href: `/rechnungen/${laufzeit.id}`,
start: laufzeit.start,
ende: laufzeit.ende,
aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "verkaufErstellt" as const },
});
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export interface RechnungsFilter {
status?: "offen" | "erledigt" | "alle" | "vorschlaege";
kunde?: string;
jahr?: string;
q?: string;
}
function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput {
const und: Prisma.LaufzeitWhereInput[] = [];
if (filter.status === "vorschlaege") {
und.push({ status: "VORSCHLAG" });
} else {
und.push({ status: "BESTAETIGT" });
}
if (filter.status === "offen") {
und.push({
OR: [
{ proxmoxPosition: null },
{ proxmoxPosition: { rechnung: { bezahlt: false } } },
{ kundenPosition: null },
{ kundenPosition: { rechnung: { erstellt: false } } },
],
});
}
if (filter.status === "erledigt") {
und.push({ proxmoxPosition: { rechnung: { bezahlt: true } } });
und.push({ kundenPosition: { rechnung: { erstellt: true } } });
}
if (filter.kunde) und.push({ host: { kundeId: filter.kunde } });
if (filter.jahr) {
const jahr = Number(filter.jahr);
und.push({
start: { gte: new Date(Date.UTC(jahr, 0, 1)), lt: new Date(Date.UTC(jahr + 1, 0, 1)) },
});
}
if (filter.q?.trim()) {
const suche = filter.q.trim();
und.push({
OR: [
{ key: { contains: suche, mode: "insensitive" } },
{ host: { hostname: { contains: suche, mode: "insensitive" } } },
{ proxmoxPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
{ kundenPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
],
});
}
return { AND: und };
}
export async function rechnungsListe(filter: RechnungsFilter) {
const laufzeiten = await prisma.laufzeit.findMany({
where: bedingung(filter),
orderBy: [{ start: "desc" }],
include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
});
const zeilen = laufzeiten.map((laufzeit) => {
const einkauf = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
return {
id: laufzeit.id,
hostId: laufzeit.host.id,
hostname: laufzeit.host.hostname,
kunde: laufzeit.host.kunde,
key: laufzeit.key,
start: laufzeit.start,
ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG",
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
einkaufBetrag: einkauf,
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
};
});
const summen = zeilen.reduce(
(summe, zeile) => ({
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
}),
{ einkauf: 0, verkauf: 0 },
);
return {
zeilen,
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
};
}
export type RechnungsZeile = Awaited<ReturnType<typeof rechnungsListe>>["zeilen"][number];
/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */
export async function laufzeitJahre(): Promise<string[]> {
const laufzeiten = await prisma.laufzeit.findMany({ select: { start: true } });
return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) =>
b.localeCompare(a),
);
}
export async function laufzeitDetail(id: string) {
return prisma.laufzeit.findUnique({
where: { id },
include: {
host: {
select: {
id: true,
hostname: true,
produkt: true,
kunde: { select: { id: true, name: true } },
},
},
proxmoxPosition: {
include: {
rechnung: {
include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } },
},
},
},
kundenPosition: {
include: {
rechnung: {
include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } },
},
},
},
},
});
}
export async function anzahlVorschlaege() {
return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } });
}