BauDoc Bahn: Baustellen- und Dokumentenverwaltung für EIU-Projekte
Erstimport. Fachliche Grundlage ist die EIU-Ablagestruktur des Auftraggebers (2-EIU-Ablagestruktur.xlsx): 1.106 Zeilen, aufbereitet zu 159 Ordnern und 944 Soll-Unterlagen mit Fachbereichsrelevanz, Aufbewahrungsfrist, Medium und Übergabezeitpunkt. Module - Dokumentationsstand: Soll-Ist je Projekt, Fristen aus dem Übergabezeitpunkt und den Projektterminen, Ampel, CSV-Rückexport im Format der Ursprungsdatei - Dokumentenmanagement mit Versionen, Freigabelauf, SHA-256-Prüfsummen - Bautagebuch nach Ril 809.0301, nach Abschluss gesperrt - Meldungen und Mängel (Behinderung, Bedenken, Mangel, AvL, Unfall) - Verträge, Nachträge, Aufmaße, Stundenlohnzettel mit zeilenweiser Anerkennung - Rechnungsprüfung: automatischer Abgleich gegen Vertrag, anerkanntes Aufmaß, bestätigte Stundenzettel und Vorrechnungen; getrennte Bescheinigung sachlich richtig / rechnerisch richtig / Zahlungsfreigabe - Kommunikation mit Projektkanälen, Erwähnungen und E-Mail-Benachrichtigung - Revisionssicheres Protokoll aller Vorgänge Rechte Rollen nach dem Vorgabeprozess Bauüberwachung (PL, BHV, BÜB, FBÜ, kaufmännische Steuerung, Planung, Auftragnehmer, Prüfer, Betreiber), Zuordnung je Projekt. Auftragnehmer sehen ausschließlich Daten der eigenen Firma; diese Schranke hängt allein an der Rolle. Welche Bereiche ihnen überhaupt angezeigt werden, entscheidet der Bauherr je Projekt über Schalter in den Einstellungen. Betrieb Docker Compose mit Anwendung, PostgreSQL und Mailpit. Keine Named Volumes – alle persistenten Daten liegen als Bind-Mount unter data/ im Compose- Verzeichnis, damit ein Backup ein Kopiervorgang bleibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import "server-only";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "./db";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
const COOKIE = "baudoc_session";
|
||||
const SESSION_DAYS = 12;
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
companyType: string | null;
|
||||
};
|
||||
|
||||
export async function hashPassword(pw: string) {
|
||||
return bcrypt.hash(pw, 11);
|
||||
}
|
||||
|
||||
export async function verifyPassword(pw: string, hash: string) {
|
||||
return bcrypt.compare(pw, hash);
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.trim().toLowerCase() },
|
||||
include: { company: true },
|
||||
});
|
||||
if (!user || !user.active) return { ok: false as const, error: "Anmeldung fehlgeschlagen." };
|
||||
if (!(await verifyPassword(password, user.passwordHash)))
|
||||
return { ok: false as const, error: "Anmeldung fehlgeschlagen." };
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 864e5);
|
||||
const h = await headers();
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
userAgent: h.get("user-agent")?.slice(0, 250) ?? null,
|
||||
ip: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
|
||||
},
|
||||
});
|
||||
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||
await prisma.auditLog.create({
|
||||
data: { userId: user.id, aktion: "LOGIN", entitaet: "User", entitaetId: user.id },
|
||||
});
|
||||
|
||||
const store = await cookies();
|
||||
store.set(COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production" && (process.env.APP_URL ?? "").startsWith("https"),
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const store = await cookies();
|
||||
const token = store.get(COOKIE)?.value;
|
||||
if (token) await prisma.session.deleteMany({ where: { token } });
|
||||
store.delete(COOKIE);
|
||||
}
|
||||
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token },
|
||||
include: { user: { include: { company: true } } },
|
||||
});
|
||||
if (!session || session.expiresAt < new Date() || !session.user.active) return null;
|
||||
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
isAdmin: session.user.isAdmin,
|
||||
companyId: session.user.companyId,
|
||||
companyName: session.user.company?.name ?? null,
|
||||
companyType: session.user.company?.type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireUser(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/anmelden");
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Rollen des Nutzers im Projekt (Admins gelten überall als ADMIN). */
|
||||
export async function rolesInProject(userId: string, projectId: string): Promise<Role[]> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } });
|
||||
const memberships = await prisma.projectMember.findMany({
|
||||
where: { userId, projectId },
|
||||
select: { role: true },
|
||||
});
|
||||
const roles = memberships.map((m) => m.role);
|
||||
if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
|
||||
return roles;
|
||||
}
|
||||
|
||||
export async function requireProjectAccess(projectId: string) {
|
||||
const user = await requireUser();
|
||||
const roles = await rolesInProject(user.id, projectId);
|
||||
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
|
||||
return { user, roles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugriff prüfen und zugleich das Projekt samt seiner Einstellungen laden.
|
||||
* Seiten sollten diese Variante verwenden, damit die projektbezogenen
|
||||
* Sichtbarkeitsschalter nirgends vergessen werden.
|
||||
*/
|
||||
export async function projektKontext(projectId: string) {
|
||||
const user = await requireUser();
|
||||
const roles = await rolesInProject(user.id, projectId);
|
||||
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
|
||||
|
||||
const projekt = await prisma.project.findUnique({ where: { id: projectId } });
|
||||
if (!projekt) redirect("/projekte?fehler=nicht-gefunden");
|
||||
|
||||
const { optionenAus } = await import("./projekte");
|
||||
const { isExternalOnly } = await import("./rbac");
|
||||
|
||||
return {
|
||||
user,
|
||||
roles,
|
||||
projekt,
|
||||
optionen: optionenAus(projekt),
|
||||
/** true = darf ausschließlich Daten der eigenen Firma sehen */
|
||||
nurEigeneFirma: isExternalOnly(roles),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export const eur = (v: Prisma.Decimal | number | null | undefined) =>
|
||||
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(Number(v ?? 0));
|
||||
|
||||
export const zahl = (v: Prisma.Decimal | number | null | undefined, frac = 2) =>
|
||||
new Intl.NumberFormat("de-DE", { minimumFractionDigits: frac, maximumFractionDigits: frac }).format(
|
||||
Number(v ?? 0),
|
||||
);
|
||||
|
||||
export const datum = (d: Date | string | null | undefined) =>
|
||||
d ? new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date(d)) : "—";
|
||||
|
||||
export const datumZeit = (d: Date | string | null | undefined) =>
|
||||
d ? new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(d)) : "—";
|
||||
|
||||
export const datumKurz = (d: Date | string | null | undefined) =>
|
||||
d ? new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }).format(new Date(d)) : "—";
|
||||
|
||||
export function relativ(d: Date | string): string {
|
||||
const t = new Date(d).getTime();
|
||||
const diff = Date.now() - t;
|
||||
const min = Math.round(diff / 60000);
|
||||
if (min < 1) return "gerade eben";
|
||||
if (min < 60) return `vor ${min} Min.`;
|
||||
const h = Math.round(min / 60);
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
const tage = Math.round(h / 24);
|
||||
if (tage < 7) return `vor ${tage} Tg.`;
|
||||
return datum(d);
|
||||
}
|
||||
|
||||
export function tageBis(d: Date | null): number | null {
|
||||
if (!d) return null;
|
||||
return Math.ceil((new Date(d).getTime() - Date.now()) / 864e5);
|
||||
}
|
||||
|
||||
/** Enum-Werte wie SACHLICH_RICHTIG lesbar machen. */
|
||||
export function enumLabel(v: string): string {
|
||||
return v
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0) + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Project, Uebergabezeitpunkt } from "@prisma/client";
|
||||
|
||||
export const UEBERGABE_LABEL: Record<Uebergabezeitpunkt, string> = {
|
||||
INBETRIEBNAHME: "zur Inbetriebnahme",
|
||||
BAUAKTE_II_IBN: "Bauakte Teil II (IBN-Akte)",
|
||||
IBN_PLUS_3M: "3 Monate nach IBN",
|
||||
IBN_PLUS_6M: "6 Monate nach IBN",
|
||||
BAUAKTE_I_BAUENDE_6M: "Bauakte Teil I – 6 Monate nach Bauende",
|
||||
PROJEKTABSCHLUSS: "zum Projektabschluss",
|
||||
};
|
||||
|
||||
function plusMonths(d: Date, m: number) {
|
||||
const x = new Date(d);
|
||||
x.setMonth(x.getMonth() + m);
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet den Übergabezeitpunkt aus der Ablagestruktur gegen die
|
||||
* Projekttermine und liefert die konkrete Frist.
|
||||
* Es gilt die früheste erreichbare Frist – so entsteht kein Stau am Projektende.
|
||||
*/
|
||||
export function berechneFrist(
|
||||
uebergabe: Uebergabezeitpunkt[],
|
||||
projekt: Pick<Project, "inbetriebnahme" | "bauende" | "projektabschluss" | "bauschlussmeldung">,
|
||||
): Date | null {
|
||||
const kandidaten: Date[] = [];
|
||||
for (const u of uebergabe) {
|
||||
switch (u) {
|
||||
case "INBETRIEBNAHME":
|
||||
case "BAUAKTE_II_IBN":
|
||||
if (projekt.inbetriebnahme) kandidaten.push(projekt.inbetriebnahme);
|
||||
break;
|
||||
case "IBN_PLUS_3M":
|
||||
if (projekt.inbetriebnahme) kandidaten.push(plusMonths(projekt.inbetriebnahme, 3));
|
||||
break;
|
||||
case "IBN_PLUS_6M":
|
||||
if (projekt.inbetriebnahme) kandidaten.push(plusMonths(projekt.inbetriebnahme, 6));
|
||||
break;
|
||||
case "BAUAKTE_I_BAUENDE_6M":
|
||||
if (projekt.bauende) kandidaten.push(plusMonths(projekt.bauende, 6));
|
||||
break;
|
||||
case "PROJEKTABSCHLUSS":
|
||||
if (projekt.projektabschluss) kandidaten.push(projekt.projektabschluss);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (kandidaten.length === 0) return null;
|
||||
return new Date(Math.min(...kandidaten.map((d) => d.getTime())));
|
||||
}
|
||||
|
||||
/** Aufbewahrungsende gemäß Ablagestruktur (Jahre nach Bauschlussmeldung). */
|
||||
export function berechneAufbewahrung(
|
||||
jahre: string | null,
|
||||
nutzungsdauer: boolean,
|
||||
bauschlussmeldung: Date | null,
|
||||
): { bis: Date | null; hinweis: string } {
|
||||
if (nutzungsdauer) return { bis: null, hinweis: "Nutzungsdauer der Anlage" };
|
||||
if (!jahre) return { bis: null, hinweis: "nicht festgelegt" };
|
||||
const m = jahre.match(/(\d+)/);
|
||||
if (!m) return { bis: null, hinweis: jahre };
|
||||
const n = parseInt(m[1], 10);
|
||||
if (!bauschlussmeldung) return { bis: null, hinweis: `${n} Jahre nach Bauschlussmeldung` };
|
||||
const bis = new Date(bauschlussmeldung);
|
||||
bis.setFullYear(bis.getFullYear() + n);
|
||||
return { bis, hinweis: `${n} Jahre nach Bauschlussmeldung` };
|
||||
}
|
||||
|
||||
export type Ampel = "GRUEN" | "GELB" | "ROT" | "GRAU";
|
||||
|
||||
export function fristAmpel(frist: Date | null, erledigt: boolean, heute = new Date()): Ampel {
|
||||
if (erledigt) return "GRUEN";
|
||||
if (!frist) return "GRAU";
|
||||
const tage = Math.floor((frist.getTime() - heute.getTime()) / 864e5);
|
||||
if (tage < 0) return "ROT";
|
||||
if (tage <= 30) return "GELB";
|
||||
return "GRUEN";
|
||||
}
|
||||
|
||||
export const FACHBEREICHE_NETZ = [
|
||||
"Oberbau",
|
||||
"Tiefbau",
|
||||
"Konstruktiver Ingenieurbau",
|
||||
"Hochbau",
|
||||
"Elektrotechnik 50 Hz",
|
||||
"LST",
|
||||
"Tk (inkl. HOA, FBOA)",
|
||||
"Oberleitung 16,7 Hz",
|
||||
"Maschinentechnik",
|
||||
];
|
||||
|
||||
export const FACHBEREICHE_BAHNHOF = [
|
||||
"Hoch-, Tief- und Ingenieurbau/Verkehrsanlagen inkl. baulichem Brandschutz",
|
||||
"HLS (KG 410/420/430/435)",
|
||||
"Starkstromanlagen 50 Hz, Erdung (KG 440)",
|
||||
"Blitzschutz (KG 446)",
|
||||
"TK-Anlagen (KG 450)",
|
||||
"Fördertechnik (KG 460)",
|
||||
"Gebäudeautomation (KG 480)",
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
import "server-only";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
let transport: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransport() {
|
||||
if (transport) return transport;
|
||||
transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || "mail",
|
||||
port: Number(process.env.SMTP_PORT) || 1025,
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || "" }
|
||||
: undefined,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
return transport;
|
||||
}
|
||||
|
||||
export async function sendeMail(opts: {
|
||||
an: string | string[];
|
||||
betreff: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}) {
|
||||
try {
|
||||
await getTransport().sendMail({
|
||||
from: process.env.SMTP_FROM || "BauDoc Bahn <noreply@baudoc.local>",
|
||||
to: Array.isArray(opts.an) ? opts.an.join(", ") : opts.an,
|
||||
subject: opts.betreff,
|
||||
text: opts.text,
|
||||
html: opts.html ?? htmlRahmen(opts.betreff, opts.text),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[mail] Versand fehlgeschlagen:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function htmlRahmen(titel: string, text: string, link?: string) {
|
||||
const url = process.env.APP_URL || "http://localhost:3400";
|
||||
return `<!doctype html><html><body style="margin:0;background:#f4f5f7;font-family:Segoe UI,Arial,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:28px 12px">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:10px;overflow:hidden;border:1px solid #e2e5ea">
|
||||
<tr><td style="background:#131821;padding:16px 22px;color:#fff;font-size:15px;font-weight:600">
|
||||
BauDoc Bahn <span style="color:#8A94A6;font-weight:400">· Baustellen- und Dokumentenmanagement</span></td></tr>
|
||||
<tr><td style="padding:22px">
|
||||
<h1 style="margin:0 0 12px;font-size:17px;color:#131821">${escapeHtml(titel)}</h1>
|
||||
<div style="font-size:14px;line-height:1.6;color:#3a4252;white-space:pre-wrap">${escapeHtml(text)}</div>
|
||||
${link ? `<p style="margin:22px 0 0"><a href="${url}${link}" style="background:#EC0016;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:14px;display:inline-block">Im System öffnen</a></p>` : ""}
|
||||
</td></tr>
|
||||
<tr><td style="padding:14px 22px;background:#f8f9fb;color:#8A94A6;font-size:12px;border-top:1px solid #e2e5ea">
|
||||
Automatische Nachricht. Benachrichtigungen lassen sich im Profil einstellen.</td></tr>
|
||||
</table></td></tr></table></body></html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
import { prisma } from "./db";
|
||||
import { sendeMail, htmlRahmen } from "./mail";
|
||||
import type { NotificationType } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Eine Benachrichtigung landet immer im System (Glocke) und – je nach
|
||||
* Einstellung des Empfängers – zusätzlich per E-Mail. Damit ersetzt der
|
||||
* interne Nachrichtenverkehr den E-Mail-Pingpong, ohne dass jemand etwas
|
||||
* verpasst, der nur sein Postfach liest.
|
||||
*/
|
||||
export async function benachrichtige(opts: {
|
||||
userIds: string[];
|
||||
typ: NotificationType;
|
||||
titel: string;
|
||||
text?: string;
|
||||
link?: string;
|
||||
projectId?: string;
|
||||
ausgeloestVon?: string;
|
||||
}) {
|
||||
const empfaenger = [...new Set(opts.userIds)].filter((id) => id !== opts.ausgeloestVon);
|
||||
if (empfaenger.length === 0) return;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: empfaenger }, active: true },
|
||||
select: {
|
||||
id: true, email: true, name: true, notifyMode: true,
|
||||
notifyMentions: true, notifyAssignments: true, notifyDeadlines: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.notification.createMany({
|
||||
data: users.map((u) => ({
|
||||
userId: u.id,
|
||||
typ: opts.typ,
|
||||
titel: opts.titel,
|
||||
text: opts.text ?? null,
|
||||
link: opts.link ?? null,
|
||||
projectId: opts.projectId ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
const sofort = users.filter((u) => {
|
||||
if (u.notifyMode !== "SOFORT") return false;
|
||||
if (opts.typ === "ERWAEHNUNG" && !u.notifyMentions) return false;
|
||||
if (opts.typ === "ZUWEISUNG" && !u.notifyAssignments) return false;
|
||||
if (opts.typ === "FRIST" && !u.notifyDeadlines) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
sofort.map((u) =>
|
||||
sendeMail({
|
||||
an: u.email,
|
||||
betreff: opts.titel,
|
||||
text: opts.text ?? opts.titel,
|
||||
html: htmlRahmen(opts.titel, opts.text ?? "", opts.link),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @-Erwähnungen aus einem Nachrichtentext auflösen. */
|
||||
export async function findeErwaehnungen(text: string, projectId?: string) {
|
||||
const namen = [...text.matchAll(/@([\wäöüÄÖÜß.-]{2,40})/g)].map((m) => m[1].toLowerCase());
|
||||
if (namen.length === 0) return [];
|
||||
|
||||
const kandidaten = await prisma.user.findMany({
|
||||
where: projectId ? { memberships: { some: { projectId } } } : { active: true },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
|
||||
const treffer = new Set<string>();
|
||||
for (const k of kandidaten) {
|
||||
const handle = k.email.split("@")[0].toLowerCase();
|
||||
const nachname = k.name.split(" ").slice(-1)[0].toLowerCase();
|
||||
if (namen.some((n) => n === handle || n === nachname || k.name.toLowerCase().startsWith(n))) {
|
||||
treffer.add(k.id);
|
||||
}
|
||||
}
|
||||
return [...treffer];
|
||||
}
|
||||
|
||||
export async function protokolliere(opts: {
|
||||
userId?: string | null;
|
||||
aktion: string;
|
||||
entitaet: string;
|
||||
entitaetId?: string;
|
||||
projectId?: string;
|
||||
details?: unknown;
|
||||
}) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: opts.userId ?? null,
|
||||
aktion: opts.aktion,
|
||||
entitaet: opts.entitaet,
|
||||
entitaetId: opts.entitaetId ?? null,
|
||||
projectId: opts.projectId ?? null,
|
||||
details: (opts.details ?? undefined) as never,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { prisma } from "./db";
|
||||
import { berechneFrist } from "./fristen";
|
||||
import type { ProjektOptionen } from "./rbac";
|
||||
import type { Project } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Erzeugt für ein Projekt die einschlägigen Soll-Dokumentenpositionen aus der
|
||||
* EIU-Ablagestruktur.
|
||||
*
|
||||
* Einschlägig ist eine Unterlage, wenn sie
|
||||
* 1. für die Bauherrenseite des Projekts gilt (Fahrweg bzw. Personenbahnhöfe) und
|
||||
* 2. mindestens einem der im Projekt gewählten Fachbereiche zugeordnet ist –
|
||||
* oder gar keinem, dann gilt sie generell.
|
||||
*
|
||||
* Die Frist ergibt sich aus dem Übergabezeitpunkt der Unterlage und den
|
||||
* Projektterminen. Bestehende Positionen bleiben unangetastet; ein erneuter
|
||||
* Aufruf ergänzt nur, was durch geänderte Fachbereiche hinzugekommen ist.
|
||||
*/
|
||||
export async function slotsErzeugen(projectId: string): Promise<{ neu: number; gesamt: number }> {
|
||||
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
|
||||
|
||||
const requirements = await prisma.documentRequirement.findMany();
|
||||
const gewaehlt = requirements.filter((r) => {
|
||||
if (istBahnhof ? !r.relevantBahnhof : !r.relevantNetz) return false;
|
||||
if (r.fachbereiche.length === 0) return true;
|
||||
if (projekt.fachbereiche.length === 0) return true;
|
||||
return r.fachbereiche.some((f) => projekt.fachbereiche.includes(f));
|
||||
});
|
||||
|
||||
const ergebnis = await prisma.projectDocumentSlot.createMany({
|
||||
data: gewaehlt.map((r) => ({
|
||||
projectId,
|
||||
requirementId: r.id,
|
||||
frist: berechneFrist(r.uebergabe, projekt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return { neu: ergebnis.count, gesamt: gewaehlt.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Fristen aller offenen Positionen neu – nötig, wenn sich
|
||||
* Projekttermine verschieben. Bereits freigegebene Positionen bleiben unberührt.
|
||||
*/
|
||||
export async function fristenNeuBerechnen(projectId: string): Promise<number> {
|
||||
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||
const slots = await prisma.projectDocumentSlot.findMany({
|
||||
where: { projectId, status: { notIn: ["FREIGEGEBEN", "ENTFAELLT"] } },
|
||||
include: { requirement: { select: { uebergabe: true } } },
|
||||
});
|
||||
|
||||
let geaendert = 0;
|
||||
for (const s of slots) {
|
||||
const frist = berechneFrist(s.requirement.uebergabe, projekt);
|
||||
if (frist?.getTime() !== s.frist?.getTime()) {
|
||||
await prisma.projectDocumentSlot.update({ where: { id: s.id }, data: { frist } });
|
||||
geaendert++;
|
||||
}
|
||||
}
|
||||
return geaendert;
|
||||
}
|
||||
|
||||
/** Meilensteine aus den Projektterminen ableiten. */
|
||||
export function meilensteineAusTerminen(p: {
|
||||
startgespraech?: Date | null;
|
||||
baubeginn?: Date | null;
|
||||
inbetriebnahme?: Date | null;
|
||||
bauende?: Date | null;
|
||||
bauschlussmeldung?: Date | null;
|
||||
projektabschluss?: Date | null;
|
||||
}) {
|
||||
const liste: { typ: never; titel: string; geplant: Date }[] = [];
|
||||
const add = (typ: string, titel: string, d?: Date | null) => {
|
||||
if (d) liste.push({ typ: typ as never, titel, geplant: d });
|
||||
};
|
||||
add("STARTGESPRAECH", "Startgespräch Bauüberwachung", p.startgespraech);
|
||||
add("BAUBEGINN", "Baubeginn / Bauanlaufberatung", p.baubeginn);
|
||||
add("INBETRIEBNAHME", "Inbetriebnahme", p.inbetriebnahme);
|
||||
add("BAUENDE", "Bauende", p.bauende);
|
||||
add("BAUSCHLUSSMELDUNG", "Bauschlussmeldung", p.bauschlussmeldung);
|
||||
add("PROJEKTABSCHLUSS", "Projektabschluss (PA)", p.projektabschluss);
|
||||
return liste;
|
||||
}
|
||||
|
||||
export function optionenAus(projekt: Project): ProjektOptionen {
|
||||
return {
|
||||
anSichtVertrag: projekt.anSichtVertrag,
|
||||
anSichtRechnungen: projekt.anSichtRechnungen,
|
||||
anSichtBautagebuch: projekt.anSichtBautagebuch,
|
||||
anSichtDokumentation: projekt.anSichtDokumentation,
|
||||
anDarfDokumente: projekt.anDarfDokumente,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Soll-Unterlagen bringt welche Fachbereichsauswahl mit sich?
|
||||
* Wird als Bitmaske an das Anlageformular gegeben, damit die Vorschau ohne
|
||||
* Serveranfrage mitzählt.
|
||||
*/
|
||||
export async function fachbereichsMasken(alleFachbereiche: string[]) {
|
||||
const requirements = await prisma.documentRequirement.findMany({
|
||||
select: { relevantNetz: true, relevantBahnhof: true, fachbereiche: true },
|
||||
});
|
||||
const index = new Map(alleFachbereiche.map((f, i) => [f, i]));
|
||||
|
||||
const maske = (fb: string[]) =>
|
||||
fb.reduce((m, f) => (index.has(f) ? m | (1 << index.get(f)!) : m), 0);
|
||||
|
||||
return {
|
||||
netz: requirements.filter((r) => r.relevantNetz).map((r) => maske(r.fachbereiche)),
|
||||
bahnhof: requirements.filter((r) => r.relevantBahnhof).map((r) => maske(r.fachbereiche)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Berechtigungsmatrix. Bewusst als Code und nicht als Datenbanktabelle:
|
||||
* so ist jederzeit nachvollziehbar und im Review prüfbar, wer was darf.
|
||||
* Feinsteuerung pro Projekt erfolgt über die Rollenzuordnung (ProjectMember).
|
||||
*/
|
||||
export const PERMISSIONS = {
|
||||
// Projekt & Stammdaten
|
||||
"projekt.anlegen": ["ADMIN", "PROJEKTLEITER"],
|
||||
"projekt.bearbeiten": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER"],
|
||||
"projekt.mitglieder": ["ADMIN", "PROJEKTLEITER"],
|
||||
|
||||
// Dokumentenmanagement
|
||||
"dokument.lesen": [
|
||||
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||
"FACHBAUUEBERWACHER", "KAUFMANN", "PLANER", "AUFTRAGNEHMER", "PRUEFER",
|
||||
"BETREIBER", "LESER",
|
||||
],
|
||||
"dokument.hochladen": [
|
||||
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||
"FACHBAUUEBERWACHER", "PLANER", "AUFTRAGNEHMER", "KAUFMANN",
|
||||
],
|
||||
"dokument.freigeben": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||
"dokument.loeschen": ["ADMIN", "PROJEKTLEITER"],
|
||||
"dokument.intern": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER", "KAUFMANN"],
|
||||
|
||||
// Ablagestruktur / Soll-Ist
|
||||
"ablage.lesen": [
|
||||
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||
"FACHBAUUEBERWACHER", "KAUFMANN", "PLANER", "PRUEFER", "BETREIBER", "LESER",
|
||||
],
|
||||
"ablage.steuern": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER"],
|
||||
|
||||
// Bautagebuch
|
||||
"bautagebuch.lesen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER", "KAUFMANN", "PRUEFER", "LESER"],
|
||||
"bautagebuch.schreiben": ["ADMIN", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||
"bautagebuch.gegenzeichnen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER"],
|
||||
|
||||
// Meldungen / Mängel
|
||||
"vorgang.anlegen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER", "AUFTRAGNEHMER", "PRUEFER"],
|
||||
"vorgang.bearbeiten": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||
"vorgang.schliessen": ["ADMIN", "PROJEKTLEITER", "BAUUEBERWACHER"],
|
||||
|
||||
// Verträge & Nachträge
|
||||
"vertrag.lesen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "KAUFMANN"],
|
||||
"vertrag.bearbeiten": ["ADMIN", "PROJEKTLEITER", "KAUFMANN"],
|
||||
"nachtrag.pruefen": ["ADMIN", "BAUUEBERWACHER", "PROJEKTLEITER"],
|
||||
|
||||
// Stundenzettel & Aufmaß
|
||||
"stunden.einreichen": ["ADMIN", "AUFTRAGNEHMER"],
|
||||
"stunden.pruefen": ["ADMIN", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||
"aufmass.einreichen": ["ADMIN", "AUFTRAGNEHMER"],
|
||||
"aufmass.pruefen": ["ADMIN", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||
|
||||
// Rechnungsprüfung – bewusste Vier-Augen-Trennung
|
||||
"rechnung.erfassen": ["ADMIN", "KAUFMANN", "AUFTRAGNEHMER"],
|
||||
"rechnung.lesen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "KAUFMANN"],
|
||||
"rechnung.sachlich": ["ADMIN", "BAUUEBERWACHER"], // "sachlich richtig"
|
||||
"rechnung.rechnerisch": ["ADMIN", "KAUFMANN"], // "rechnerisch richtig"
|
||||
"rechnung.freigeben": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER"],
|
||||
|
||||
// Kommunikation
|
||||
"chat.schreiben": [
|
||||
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||
"FACHBAUUEBERWACHER", "KAUFMANN", "PLANER", "AUFTRAGNEHMER", "PRUEFER", "BETREIBER",
|
||||
],
|
||||
"chat.kanal.anlegen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER"],
|
||||
|
||||
// Administration
|
||||
"admin.benutzer": ["ADMIN"],
|
||||
"admin.firmen": ["ADMIN", "PROJEKTLEITER"],
|
||||
"admin.audit": ["ADMIN"],
|
||||
} as const satisfies Record<string, readonly Role[]>;
|
||||
|
||||
export type Permission = keyof typeof PERMISSIONS;
|
||||
|
||||
export function can(roles: Role[], permission: Permission): boolean {
|
||||
const allowed = PERMISSIONS[permission] as readonly Role[];
|
||||
return roles.some((r) => allowed.includes(r));
|
||||
}
|
||||
|
||||
/** Wirft, wenn die Berechtigung fehlt – für Server Actions und API-Routen. */
|
||||
export function assertCan(roles: Role[], permission: Permission) {
|
||||
if (!can(roles, permission)) {
|
||||
throw new Error(`Keine Berechtigung für "${permission}".`);
|
||||
}
|
||||
}
|
||||
|
||||
export const ROLE_LABEL: Record<Role, string> = {
|
||||
ADMIN: "Administration",
|
||||
PROJEKTLEITER: "Projektleitung (PL)",
|
||||
BAUHERRENVERTRETER: "Bauherrenvertretung (BHV)",
|
||||
BAUUEBERWACHER: "Bauüberwacher Bahn (BÜB)",
|
||||
FACHBAUUEBERWACHER: "Fachbauüberwacher (FBÜ)",
|
||||
KAUFMANN: "Kaufmännische Steuerung",
|
||||
PLANER: "Planung",
|
||||
AUFTRAGNEHMER: "Auftragnehmer",
|
||||
PRUEFER: "Prüfer / Gutachter",
|
||||
BETREIBER: "Anlagenbetreiber",
|
||||
LESER: "Lesezugriff",
|
||||
};
|
||||
|
||||
export const ROLE_HINT: Record<Role, string> = {
|
||||
ADMIN: "Vollzugriff inkl. Benutzerverwaltung und Protokoll.",
|
||||
PROJEKTLEITER: "Steuert Projekt, Termine, Freigaben und Rollen.",
|
||||
BAUHERRENVERTRETER: "Vertritt den Bauherrn vor Ort, zeichnet gegen, gibt Rechnungen frei.",
|
||||
BAUUEBERWACHER: "Führt Bautagebuch, prüft Stunden und Aufmaß, bescheinigt sachliche Richtigkeit.",
|
||||
FACHBAUUEBERWACHER: "Wie BÜB, beschränkt auf zugewiesene Fachbereiche.",
|
||||
KAUFMANN: "Verträge, Nachträge, rechnerische Prüfung, Zahlungsfreigabe.",
|
||||
PLANER: "Liefert Planunterlagen in die Ablagestruktur.",
|
||||
AUFTRAGNEHMER: "Sieht ausschließlich eigene Verträge, Stunden, Aufmaße, Rechnungen.",
|
||||
PRUEFER: "Gutachten und Prüfberichte, lesender Zugriff auf Fachunterlagen.",
|
||||
BETREIBER: "Zugriff auf die Betreiberakte (Teil II) nach Übergabe.",
|
||||
LESER: "Nur lesen.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Auftragnehmer und andere Externe sehen ausschließlich Daten der eigenen Firma.
|
||||
*
|
||||
* Wichtig: Diese Funktion steuert die DATENAUSWAHL und darf niemals durch eine
|
||||
* Projekteinstellung aufgeweicht werden. Die Schalter unter `ProjektOptionen`
|
||||
* entscheiden nur, ob ein Bereich überhaupt sichtbar ist – nicht, wessen Daten
|
||||
* darin erscheinen.
|
||||
*/
|
||||
export function isExternalOnly(roles: Role[]): boolean {
|
||||
const privileged: Role[] = [
|
||||
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||
"FACHBAUUEBERWACHER", "KAUFMANN",
|
||||
];
|
||||
return roles.length > 0 && !roles.some((r) => privileged.includes(r));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Projektbezogene Einstellungen
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Je Projekt einstellbar: welche Bereiche für Auftragnehmer sichtbar sind.
|
||||
* Damit entscheidet der Bauherr selbst, wie offen er gegenüber seinen
|
||||
* Baufirmen arbeiten möchte – statt dass es im Code festgeschrieben ist.
|
||||
*/
|
||||
export type ProjektOptionen = {
|
||||
anSichtVertrag: boolean;
|
||||
anSichtRechnungen: boolean;
|
||||
anSichtBautagebuch: boolean;
|
||||
anSichtDokumentation: boolean;
|
||||
anDarfDokumente: boolean;
|
||||
};
|
||||
|
||||
export const OPTION_INFO: {
|
||||
feld: keyof ProjektOptionen;
|
||||
titel: string;
|
||||
hinweis: string;
|
||||
standard: boolean;
|
||||
}[] = [
|
||||
{
|
||||
feld: "anSichtVertrag",
|
||||
titel: "Eigenen Vertrag einsehen",
|
||||
hinweis:
|
||||
"Die Baufirma sieht ihr eigenes Leistungsverzeichnis und die eigenen Nachträge samt Stellungnahme der Bauüberwachung. Verträge anderer Firmen bleiben verborgen.",
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
feld: "anSichtRechnungen",
|
||||
titel: "Stand der eigenen Rechnungen verfolgen",
|
||||
hinweis:
|
||||
"Die Baufirma sieht, wo ihre Rechnung steht und welche Prüfbefunde es gibt. Das erspart Rückfragen – macht Kürzungen aber sofort sichtbar.",
|
||||
standard: true,
|
||||
},
|
||||
{
|
||||
feld: "anSichtBautagebuch",
|
||||
titel: "Bautagebuch lesen",
|
||||
hinweis:
|
||||
"Das Bautagebuch ist die Aufzeichnung des Bauherrn. Leserecht schafft Transparenz, kann bei Streitfällen aber unerwünscht sein.",
|
||||
standard: false,
|
||||
},
|
||||
{
|
||||
feld: "anSichtDokumentation",
|
||||
titel: "Dokumentationsstand einsehen",
|
||||
hinweis:
|
||||
"Zeigt der Baufirma, welche Unterlagen aus der Ablagestruktur noch fehlen. Sinnvoll, wenn sie selbst zuliefern soll.",
|
||||
standard: false,
|
||||
},
|
||||
{
|
||||
feld: "anDarfDokumente",
|
||||
titel: "Unterlagen einstellen",
|
||||
hinweis:
|
||||
"Erlaubt der Baufirma, Nachweise, Prüfprotokolle und Bestandsunterlagen direkt hochzuladen, statt sie per E-Mail zu schicken.",
|
||||
standard: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Welches Recht durch welchen Projektschalter zusätzlich gewährt wird. */
|
||||
const OPTIONAL_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>> = {
|
||||
"vertrag.lesen": "anSichtVertrag",
|
||||
"rechnung.lesen": "anSichtRechnungen",
|
||||
"bautagebuch.lesen": "anSichtBautagebuch",
|
||||
"ablage.lesen": "anSichtDokumentation",
|
||||
};
|
||||
|
||||
/** Rechte, die einem Externen durch einen Projektschalter entzogen werden können. */
|
||||
const ENTZIEHBAR_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>> = {
|
||||
"dokument.hochladen": "anDarfDokumente",
|
||||
};
|
||||
|
||||
/**
|
||||
* Berechtigung unter Berücksichtigung der Projekteinstellungen.
|
||||
* Für Rollen der Bauherrenseite identisch mit `can()`.
|
||||
*/
|
||||
export function canInProject(
|
||||
roles: Role[],
|
||||
permission: Permission,
|
||||
optionen: ProjektOptionen,
|
||||
): boolean {
|
||||
const extern = isExternalOnly(roles);
|
||||
|
||||
if (extern) {
|
||||
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
|
||||
if (entzug && !optionen[entzug]) return false;
|
||||
|
||||
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
|
||||
if (gewaehrung && optionen[gewaehrung]) return true;
|
||||
}
|
||||
|
||||
return can(roles, permission);
|
||||
}
|
||||
|
||||
export function assertCanInProject(
|
||||
roles: Role[],
|
||||
permission: Permission,
|
||||
optionen: ProjektOptionen,
|
||||
) {
|
||||
if (!canInProject(roles, permission, optionen)) {
|
||||
throw new Error(`Keine Berechtigung für "${permission}".`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { prisma } from "./db";
|
||||
import type { FindingKind, FindingSeverity, Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Automatischer Abgleich einer Rechnung gegen ihre Belege.
|
||||
*
|
||||
* Geprüft wird gegen genau die Unterlagen, die die Bauüberwachung ohnehin
|
||||
* führen muss:
|
||||
* • Vertrag / LV-Positionen (Einheitspreise, Auftragssumme)
|
||||
* • anerkannte Aufmaße (Mengen, REB)
|
||||
* • bestätigte Stundenlohnzettel (Stunden, Sätze, Zuschläge)
|
||||
* • beauftragte Nachträge
|
||||
* • bereits abgerechnete Mengen aus früheren Rechnungen
|
||||
*
|
||||
* Das Ergebnis ist ein Prüfbericht mit Befunden – die Entscheidung
|
||||
* ("sachlich richtig") trifft weiterhin ein Mensch.
|
||||
*/
|
||||
|
||||
const EPS = 0.005;
|
||||
|
||||
function n(v: Prisma.Decimal | number | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === "number" ? v : Number(v);
|
||||
}
|
||||
|
||||
const eur = (v: number) =>
|
||||
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(v);
|
||||
const num = (v: number, frac = 3) =>
|
||||
new Intl.NumberFormat("de-DE", { maximumFractionDigits: frac }).format(v);
|
||||
|
||||
type NeuerBefund = {
|
||||
lineId?: string | null;
|
||||
art: FindingKind;
|
||||
schwere: FindingSeverity;
|
||||
meldung: string;
|
||||
erwartet?: string;
|
||||
gefunden?: string;
|
||||
differenz?: number;
|
||||
};
|
||||
|
||||
export type Pruefbericht = {
|
||||
befunde: number;
|
||||
kritisch: number;
|
||||
warnungen: number;
|
||||
anerkanntNetto: number;
|
||||
kuerzungNetto: number;
|
||||
};
|
||||
|
||||
export async function pruefeRechnung(invoiceId: string): Promise<Pruefbericht> {
|
||||
const invoice = await prisma.invoice.findUniqueOrThrow({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
zeilen: { orderBy: { position: "asc" } },
|
||||
contract: { include: { positionen: true, nachtraege: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const befunde: NeuerBefund[] = [];
|
||||
const lineUpdates: { id: string; mengeAnerkannt: number; betragAnerkannt: number }[] = [];
|
||||
|
||||
// ── Bezugsdaten laden ────────────────────────────────────────────
|
||||
const contract = invoice.contract;
|
||||
|
||||
const aufmasse = contract
|
||||
? await prisma.measurementLine.findMany({
|
||||
where: {
|
||||
measurement: {
|
||||
contractId: contract.id,
|
||||
status: { in: ["ANERKANNT", "TEILWEISE_ANERKANNT"] },
|
||||
},
|
||||
},
|
||||
include: { measurement: { select: { nummer: true, status: true } } },
|
||||
})
|
||||
: [];
|
||||
|
||||
/** anerkannte Menge je LV-Position aus allen freigegebenen Aufmaßen */
|
||||
const aufmassMenge = new Map<string, number>();
|
||||
for (const z of aufmasse) {
|
||||
if (!z.contractItemId) continue;
|
||||
const menge = z.mengeAnerkannt !== null ? n(z.mengeAnerkannt) : n(z.menge);
|
||||
aufmassMenge.set(z.contractItemId, (aufmassMenge.get(z.contractItemId) ?? 0) + menge);
|
||||
}
|
||||
|
||||
/** bereits in ANDEREN Rechnungen abgerechnete Mengen je LV-Position */
|
||||
const vorherigeZeilen = contract
|
||||
? await prisma.invoiceLine.findMany({
|
||||
where: {
|
||||
invoiceId: { not: invoice.id },
|
||||
invoice: { contractId: contract.id, status: { not: "ZURUECKGEWIESEN" } },
|
||||
},
|
||||
select: {
|
||||
contractItemId: true,
|
||||
timeSheetId: true,
|
||||
menge: true,
|
||||
mengeAnerkannt: true,
|
||||
invoice: { select: { rechnungsNr: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const bereitsAbgerechnet = new Map<string, number>();
|
||||
const stundenzettelBenutztIn = new Map<string, string>();
|
||||
for (const z of vorherigeZeilen) {
|
||||
if (z.contractItemId) {
|
||||
const m = z.mengeAnerkannt !== null ? n(z.mengeAnerkannt) : n(z.menge);
|
||||
bereitsAbgerechnet.set(z.contractItemId, (bereitsAbgerechnet.get(z.contractItemId) ?? 0) + m);
|
||||
}
|
||||
if (z.timeSheetId) stundenzettelBenutztIn.set(z.timeSheetId, z.invoice.rechnungsNr);
|
||||
}
|
||||
|
||||
const positionByOz = new Map(contract?.positionen.map((p) => [p.oz.trim(), p]) ?? []);
|
||||
const positionById = new Map(contract?.positionen.map((p) => [p.id, p]) ?? []);
|
||||
|
||||
const stundenzettel = contract
|
||||
? await prisma.timeSheet.findMany({
|
||||
where: { contractId: contract.id },
|
||||
include: { zeilen: true },
|
||||
})
|
||||
: [];
|
||||
const zettelById = new Map(stundenzettel.map((t) => [t.id, t]));
|
||||
const zettelByNr = new Map(stundenzettel.map((t) => [t.nummer.trim().toLowerCase(), t]));
|
||||
|
||||
const nachtragById = new Map(contract?.nachtraege.map((c) => [c.id, c]) ?? []);
|
||||
|
||||
// ── Zeilenweise Prüfung ──────────────────────────────────────────
|
||||
if (!contract) {
|
||||
befunde.push({
|
||||
art: "KEIN_VERTRAGSBEZUG",
|
||||
schwere: "KRITISCH",
|
||||
meldung: "Der Rechnung ist kein Vertrag zugeordnet. Eine sachliche Prüfung ist nicht möglich.",
|
||||
});
|
||||
}
|
||||
|
||||
const zettelInDieserRechnung = new Set<string>();
|
||||
|
||||
for (const zeile of invoice.zeilen) {
|
||||
const menge = n(zeile.menge);
|
||||
const ep = n(zeile.einheitspreis);
|
||||
const betrag = n(zeile.betrag);
|
||||
|
||||
// Rechnerische Plausibilität der Zeile
|
||||
if (Math.abs(menge * ep - betrag) > 0.02) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "RECHENFEHLER",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Zeile ${zeile.position}: Menge × Einheitspreis ergibt nicht den ausgewiesenen Betrag.`,
|
||||
erwartet: eur(menge * ep),
|
||||
gefunden: eur(betrag),
|
||||
differenz: betrag - menge * ep,
|
||||
});
|
||||
}
|
||||
|
||||
let mengeAnerkannt = menge;
|
||||
let epAnerkannt = ep;
|
||||
|
||||
// ── LV-Position ────────────────────────────────────────────────
|
||||
if (zeile.art === "LV_POSITION" || zeile.art === "MATERIAL") {
|
||||
const pos =
|
||||
(zeile.contractItemId ? positionById.get(zeile.contractItemId) : undefined) ??
|
||||
(zeile.oz ? positionByOz.get(zeile.oz.trim()) : undefined);
|
||||
|
||||
if (!pos) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "KEIN_VERTRAGSBEZUG",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Zeile ${zeile.position} „${zeile.bezeichnung}“: keine passende LV-Position im Vertrag (OZ ${zeile.oz ?? "—"}).`,
|
||||
gefunden: zeile.oz ?? "ohne OZ",
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
epAnerkannt = 0;
|
||||
} else {
|
||||
const vertragsEp = n(pos.einheitspreis);
|
||||
if (Math.abs(vertragsEp - ep) > EPS) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "PREIS_ABWEICHUNG",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `OZ ${pos.oz}: abgerechneter Einheitspreis weicht vom Vertrag ab.`,
|
||||
erwartet: eur(vertragsEp),
|
||||
gefunden: eur(ep),
|
||||
differenz: (ep - vertragsEp) * menge,
|
||||
});
|
||||
epAnerkannt = vertragsEp;
|
||||
}
|
||||
|
||||
const geprueft = aufmassMenge.get(pos.id);
|
||||
const schonAbgerechnet = bereitsAbgerechnet.get(pos.id) ?? 0;
|
||||
|
||||
if (geprueft === undefined) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "MENGE_OHNE_AUFMASS",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `OZ ${pos.oz}: es liegt kein anerkanntes Aufmaß vor. Menge ${num(menge)} ${zeile.einheit} nicht belegt.`,
|
||||
gefunden: `${num(menge)} ${zeile.einheit}`,
|
||||
differenz: menge * epAnerkannt,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
} else {
|
||||
const verfuegbar = Math.max(0, geprueft - schonAbgerechnet);
|
||||
if (menge - verfuegbar > 0.001) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: verfuegbar === 0 ? "MENGE_OHNE_AUFMASS" : "MENGE_UEBER_AUFMASS",
|
||||
schwere: "KRITISCH",
|
||||
meldung:
|
||||
verfuegbar === 0
|
||||
? `OZ ${pos.oz}: die Leistung ist im Aufmaß enthalten, aber mit 0 anerkannt` +
|
||||
(schonAbgerechnet > 0 ? ` bzw. bereits vollständig abgerechnet.` : " (noch nicht erbracht).")
|
||||
: `OZ ${pos.oz}: abgerechnete Menge übersteigt das anerkannte Aufmaß` +
|
||||
(schonAbgerechnet > 0 ? ` (davon ${num(schonAbgerechnet)} bereits in Vorrechnungen).` : "."),
|
||||
erwartet: `${num(verfuegbar)} ${zeile.einheit}`,
|
||||
gefunden: `${num(menge)} ${zeile.einheit}`,
|
||||
differenz: (menge - verfuegbar) * epAnerkannt,
|
||||
});
|
||||
mengeAnerkannt = verfuegbar;
|
||||
}
|
||||
}
|
||||
|
||||
// Mengenmehrung über LV hinaus ist nicht automatisch unzulässig,
|
||||
// aber immer ein Hinweis (§ 2 Abs. 3 VOB/B).
|
||||
const lvMenge = n(pos.menge);
|
||||
if (lvMenge > 0 && schonAbgerechnet + menge > lvMenge * 1.1) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "HINWEIS",
|
||||
schwere: "WARNUNG",
|
||||
meldung: `OZ ${pos.oz}: Mengenmehrung über 110 % der LV-Menge – Preisanpassung nach § 2 Abs. 3 VOB/B prüfen.`,
|
||||
erwartet: `${num(lvMenge)} ${zeile.einheit} (LV)`,
|
||||
gefunden: `${num(schonAbgerechnet + menge)} ${zeile.einheit} (kumuliert)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stundenlohn ────────────────────────────────────────────────
|
||||
if (zeile.art === "STUNDENLOHN") {
|
||||
const zettel =
|
||||
(zeile.timeSheetId ? zettelById.get(zeile.timeSheetId) : undefined) ??
|
||||
(zeile.oz ? zettelByNr.get(zeile.oz.trim().toLowerCase()) : undefined);
|
||||
|
||||
if (!zettel) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "STUNDEN_OHNE_ZETTEL",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Zeile ${zeile.position}: ${num(menge, 2)} Std. ohne zugeordneten Stundenlohnzettel abgerechnet.`,
|
||||
gefunden: `${num(menge, 2)} Std.`,
|
||||
differenz: betrag,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
} else {
|
||||
if (zettelInDieserRechnung.has(zettel.id)) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "DOPPELABRECHNUNG",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Stundenlohnzettel ${zettel.nummer} wird in dieser Rechnung mehrfach abgerechnet.`,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
}
|
||||
zettelInDieserRechnung.add(zettel.id);
|
||||
|
||||
const frueher = stundenzettelBenutztIn.get(zettel.id);
|
||||
if (frueher) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "DOPPELABRECHNUNG",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Stundenlohnzettel ${zettel.nummer} wurde bereits mit Rechnung ${frueher} abgerechnet.`,
|
||||
differenz: betrag,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
}
|
||||
|
||||
if (zettel.status !== "ANERKANNT" && zettel.status !== "TEILWEISE_ANERKANNT") {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "ZETTEL_NICHT_ANERKANNT",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Stundenlohnzettel ${zettel.nummer} ist nicht von der Bauüberwachung bestätigt (Status: ${zettel.status}).`,
|
||||
erwartet: "ANERKANNT",
|
||||
gefunden: zettel.status,
|
||||
differenz: betrag,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
} else {
|
||||
const anerkannteStunden = zettel.zeilen.reduce(
|
||||
(s, z) => s + (z.stundenAnerkannt !== null ? n(z.stundenAnerkannt) : n(z.stundenGemeldet)),
|
||||
0,
|
||||
);
|
||||
const gemeldeteStunden = zettel.zeilen.reduce((s, z) => s + n(z.stundenGemeldet), 0);
|
||||
|
||||
if (menge - anerkannteStunden > 0.01) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "STUNDEN_UEBER_ZETTEL",
|
||||
schwere: "KRITISCH",
|
||||
meldung:
|
||||
`Zettel ${zettel.nummer}: abgerechnet ${num(menge, 2)} Std., ` +
|
||||
`von der Bauüberwachung anerkannt ${num(anerkannteStunden, 2)} Std.` +
|
||||
(gemeldeteStunden !== anerkannteStunden
|
||||
? ` (gemeldet waren ${num(gemeldeteStunden, 2)} Std.)`
|
||||
: ""),
|
||||
erwartet: `${num(anerkannteStunden, 2)} Std.`,
|
||||
gefunden: `${num(menge, 2)} Std.`,
|
||||
differenz: (menge - anerkannteStunden) * ep,
|
||||
});
|
||||
mengeAnerkannt = Math.min(mengeAnerkannt, anerkannteStunden);
|
||||
}
|
||||
|
||||
// Stundensatz: gewichteter Mittelwert der anerkannten Zeilen
|
||||
const gewichtet = zettel.zeilen.reduce(
|
||||
(acc, z) => {
|
||||
const std = z.stundenAnerkannt !== null ? n(z.stundenAnerkannt) : n(z.stundenGemeldet);
|
||||
const satz = n(z.stundensatz) * (1 + n(z.zuschlagProzent) / 100);
|
||||
return { std: acc.std + std, summe: acc.summe + std * satz };
|
||||
},
|
||||
{ std: 0, summe: 0 },
|
||||
);
|
||||
const sollSatz = gewichtet.std > 0 ? gewichtet.summe / gewichtet.std : 0;
|
||||
|
||||
if (sollSatz > 0 && Math.abs(sollSatz - ep) > 0.01) {
|
||||
const hatZuschlag = zettel.zeilen.some((z) => n(z.zuschlagProzent) > 0);
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: ep > sollSatz && !hatZuschlag ? "ZUSCHLAG_UNBELEGT" : "STUNDENSATZ_ABWEICHUNG",
|
||||
schwere: ep > sollSatz ? "KRITISCH" : "WARNUNG",
|
||||
meldung:
|
||||
`Zettel ${zettel.nummer}: abgerechneter Stundensatz weicht vom bestätigten Satz ab` +
|
||||
(ep > sollSatz && !hatZuschlag ? " – kein Zuschlag auf dem Zettel vermerkt." : "."),
|
||||
erwartet: eur(sollSatz),
|
||||
gefunden: eur(ep),
|
||||
differenz: (ep - sollSatz) * Math.min(menge, mengeAnerkannt),
|
||||
});
|
||||
if (ep > sollSatz) epAnerkannt = sollSatz;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nachtrag ───────────────────────────────────────────────────
|
||||
if (zeile.art === "NACHTRAG") {
|
||||
const nt = zeile.changeOrderId ? nachtragById.get(zeile.changeOrderId) : undefined;
|
||||
if (!nt) {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "NACHTRAG_NICHT_BEAUFTRAGT",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Zeile ${zeile.position}: Nachtragsleistung ohne zugeordneten Nachtrag abgerechnet.`,
|
||||
differenz: betrag,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
} else if (nt.status !== "BEAUFTRAGT") {
|
||||
befunde.push({
|
||||
lineId: zeile.id,
|
||||
art: "NACHTRAG_NICHT_BEAUFTRAGT",
|
||||
schwere: "KRITISCH",
|
||||
meldung: `Nachtrag ${nt.nummer} „${nt.titel}“ ist nicht beauftragt (Status: ${nt.status}).`,
|
||||
erwartet: "BEAUFTRAGT",
|
||||
gefunden: nt.status,
|
||||
differenz: betrag,
|
||||
});
|
||||
mengeAnerkannt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
lineUpdates.push({
|
||||
id: zeile.id,
|
||||
mengeAnerkannt,
|
||||
betragAnerkannt: Math.round(mengeAnerkannt * epAnerkannt * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Rechnungsebene ───────────────────────────────────────────────
|
||||
const summeZeilen = invoice.zeilen.reduce((s, z) => s + n(z.betrag), 0);
|
||||
const netto = n(invoice.nettoBetrag);
|
||||
if (invoice.zeilen.length > 0 && Math.abs(summeZeilen - netto) > 0.02) {
|
||||
befunde.push({
|
||||
art: "RECHENFEHLER",
|
||||
schwere: "KRITISCH",
|
||||
meldung: "Die Summe der Rechnungspositionen entspricht nicht dem ausgewiesenen Nettobetrag.",
|
||||
erwartet: eur(summeZeilen),
|
||||
gefunden: eur(netto),
|
||||
differenz: netto - summeZeilen,
|
||||
});
|
||||
}
|
||||
|
||||
const ustSoll = Math.round(netto * (n(invoice.ustSatz) / 100) * 100) / 100;
|
||||
if (Math.abs(ustSoll - n(invoice.ustBetrag)) > 0.02) {
|
||||
befunde.push({
|
||||
art: "RECHENFEHLER",
|
||||
schwere: "WARNUNG",
|
||||
meldung: `Umsatzsteuer rechnerisch nicht korrekt (${num(n(invoice.ustSatz), 2)} %).`,
|
||||
erwartet: eur(ustSoll),
|
||||
gefunden: eur(n(invoice.ustBetrag)),
|
||||
differenz: n(invoice.ustBetrag) - ustSoll,
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.abs(netto + n(invoice.ustBetrag) - n(invoice.bruttoBetrag)) > 0.02) {
|
||||
befunde.push({
|
||||
art: "RECHENFEHLER",
|
||||
schwere: "WARNUNG",
|
||||
meldung: "Bruttobetrag entspricht nicht Netto zuzüglich Umsatzsteuer.",
|
||||
erwartet: eur(netto + n(invoice.ustBetrag)),
|
||||
gefunden: eur(n(invoice.bruttoBetrag)),
|
||||
});
|
||||
}
|
||||
|
||||
// Auftragssumme inkl. beauftragter Nachträge
|
||||
if (contract) {
|
||||
const nachtragssumme = contract.nachtraege
|
||||
.filter((nt) => nt.status === "BEAUFTRAGT")
|
||||
.reduce((s, nt) => s + n(nt.anerkannteSumme ?? nt.angebotssumme), 0);
|
||||
const rahmen = n(contract.auftragssumme) + nachtragssumme;
|
||||
|
||||
const andere = await prisma.invoice.aggregate({
|
||||
where: {
|
||||
contractId: contract.id,
|
||||
id: { not: invoice.id },
|
||||
status: { notIn: ["ZURUECKGEWIESEN"] },
|
||||
typ: { not: "GUTSCHRIFT" },
|
||||
},
|
||||
_sum: { anerkanntNetto: true, nettoBetrag: true },
|
||||
});
|
||||
const vorher = n(andere._sum.anerkanntNetto) || n(andere._sum.nettoBetrag);
|
||||
const kumuliert = vorher + netto;
|
||||
|
||||
if (rahmen > 0 && kumuliert > rahmen + 0.01) {
|
||||
befunde.push({
|
||||
art: "SUMME_UEBER_AUFTRAG",
|
||||
schwere: "KRITISCH",
|
||||
meldung:
|
||||
"Die kumulierte Abrechnung übersteigt die Auftragssumme inklusive beauftragter Nachträge. " +
|
||||
"Vor Freigabe ist ein Nachtrag erforderlich.",
|
||||
erwartet: eur(rahmen),
|
||||
gefunden: eur(kumuliert),
|
||||
differenz: kumuliert - rahmen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ergebnis speichern ───────────────────────────────────────────
|
||||
const anerkanntNetto = Math.round(lineUpdates.reduce((s, l) => s + l.betragAnerkannt, 0) * 100) / 100;
|
||||
const basis = invoice.zeilen.length > 0 ? summeZeilen : netto;
|
||||
const kuerzung = Math.round(Math.max(0, basis - anerkanntNetto) * 100) / 100;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.invoiceFinding.deleteMany({ where: { invoiceId, erledigt: false } }),
|
||||
...lineUpdates.map((l) =>
|
||||
prisma.invoiceLine.update({
|
||||
where: { id: l.id },
|
||||
data: { mengeAnerkannt: l.mengeAnerkannt, betragAnerkannt: l.betragAnerkannt },
|
||||
}),
|
||||
),
|
||||
prisma.invoiceFinding.createMany({
|
||||
data: befunde.map((b) => ({
|
||||
invoiceId,
|
||||
lineId: b.lineId ?? null,
|
||||
art: b.art,
|
||||
schwere: b.schwere,
|
||||
meldung: b.meldung,
|
||||
erwartet: b.erwartet ?? null,
|
||||
gefunden: b.gefunden ?? null,
|
||||
differenz: b.differenz !== undefined ? Math.round(b.differenz * 100) / 100 : null,
|
||||
})),
|
||||
}),
|
||||
prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
letzterPrueflauf: new Date(),
|
||||
anerkanntNetto: invoice.zeilen.length > 0 ? anerkanntNetto : null,
|
||||
kuerzungNetto: invoice.zeilen.length > 0 ? kuerzung : 0,
|
||||
status: invoice.status === "EINGEGANGEN" ? "IN_PRUEFUNG" : invoice.status,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
befunde: befunde.length,
|
||||
kritisch: befunde.filter((b) => b.schwere === "KRITISCH").length,
|
||||
warnungen: befunde.filter((b) => b.schwere === "WARNUNG").length,
|
||||
anerkanntNetto,
|
||||
kuerzungNetto: kuerzung,
|
||||
};
|
||||
}
|
||||
|
||||
export const FINDING_LABEL: Record<FindingKind, string> = {
|
||||
KEIN_VERTRAGSBEZUG: "Kein Vertragsbezug",
|
||||
PREIS_ABWEICHUNG: "Preisabweichung",
|
||||
MENGE_OHNE_AUFMASS: "Menge ohne Aufmaß",
|
||||
MENGE_UEBER_AUFMASS: "Menge über Aufmaß",
|
||||
STUNDEN_OHNE_ZETTEL: "Stunden ohne Zettel",
|
||||
STUNDEN_UEBER_ZETTEL: "Stunden über Zettel",
|
||||
STUNDENSATZ_ABWEICHUNG: "Stundensatz weicht ab",
|
||||
ZETTEL_NICHT_ANERKANNT: "Zettel nicht bestätigt",
|
||||
DOPPELABRECHNUNG: "Doppelabrechnung",
|
||||
NACHTRAG_NICHT_BEAUFTRAGT: "Nachtrag nicht beauftragt",
|
||||
RECHENFEHLER: "Rechenfehler",
|
||||
SUMME_UEBER_AUFTRAG: "Auftragssumme überschritten",
|
||||
ZUSCHLAG_UNBELEGT: "Zuschlag unbelegt",
|
||||
HINWEIS: "Hinweis",
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import "server-only";
|
||||
import { createHash } from "crypto";
|
||||
import { mkdir, writeFile, readFile, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const ROOT = process.env.STORAGE_DIR || path.join(process.cwd(), "storage");
|
||||
|
||||
/**
|
||||
* Dateien liegen als gewöhnliche Dateien im Compose-Verzeichnis:
|
||||
* storage/<projektnummer>/<jahr>/<hash-präfix>/<id>.<ext>
|
||||
* Damit ist ein Backup ein Kopiervorgang und die Ablage bleibt auch ohne
|
||||
* die Anwendung lesbar.
|
||||
*/
|
||||
export async function speichereDatei(
|
||||
projektNummer: string,
|
||||
dateiname: string,
|
||||
daten: Buffer,
|
||||
): Promise<{ pfad: string; sha256: string; groesse: number }> {
|
||||
const sha256 = createHash("sha256").update(daten).digest("hex");
|
||||
const jahr = String(new Date().getFullYear());
|
||||
const ext = path.extname(dateiname).slice(0, 12) || "";
|
||||
const safeProjekt = projektNummer.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
const relDir = path.join(safeProjekt, jahr, sha256.slice(0, 2));
|
||||
const relPfad = path.join(relDir, `${sha256.slice(0, 32)}${ext}`);
|
||||
const absDir = path.join(ROOT, relDir);
|
||||
|
||||
await mkdir(absDir, { recursive: true });
|
||||
const abs = path.join(ROOT, relPfad);
|
||||
try {
|
||||
await stat(abs); // identische Datei bereits vorhanden – Deduplizierung
|
||||
} catch {
|
||||
await writeFile(abs, daten);
|
||||
}
|
||||
return { pfad: relPfad, sha256, groesse: daten.length };
|
||||
}
|
||||
|
||||
export async function leseDatei(relPfad: string): Promise<Buffer> {
|
||||
const wurzel = path.resolve(ROOT);
|
||||
const abs = path.resolve(wurzel, relPfad);
|
||||
// Pfadausbruch (../) verhindern
|
||||
if (abs !== wurzel && !abs.startsWith(wurzel + path.sep)) throw new Error("Ungültiger Pfad.");
|
||||
return readFile(/*turbopackIgnore: true*/ abs);
|
||||
}
|
||||
|
||||
export function maxUploadBytes(): number {
|
||||
return (Number(process.env.MAX_UPLOAD_MB) || 200) * 1024 * 1024;
|
||||
}
|
||||
|
||||
const ERLAUBT = new Set([
|
||||
"application/pdf", "image/jpeg", "image/png", "image/tiff", "image/webp", "image/heic",
|
||||
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/plain", "text/csv", "application/zip", "application/x-zip-compressed",
|
||||
"application/acad", "image/vnd.dwg", "image/vnd.dxf", "application/octet-stream",
|
||||
"application/xml", "text/xml", "application/json", "application/x-ifc", "model/ifc",
|
||||
]);
|
||||
|
||||
export function mimeErlaubt(mime: string): boolean {
|
||||
return ERLAUBT.has(mime) || mime.startsWith("image/");
|
||||
}
|
||||
Reference in New Issue
Block a user