feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+12 -1
View File
@@ -80,6 +80,15 @@ export async function abteilungsListe(
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
* richtig.
*
* Eine **archivierte** Abteilung gilt als gepflegt: der Name ist je Kunde
* eindeutig, ein zweites Anlegen scheitert. Wer sie zurueck will, holt sie in der
* Liste darunter wieder hervor.
*
* Kunden, die archiviert sind, kommen nicht vor (#016). Die Oberflaeche macht aus
* jedem Eintrag hier einen Knopf, der die Abteilung anlegt — und `kundeErmitteln`
* weist einen archivierten Kunden ab. Ein Vorschlag, der beim Anklicken nur eine
* Fehlermeldung ergibt, ist ein Versprechen, das der Code nicht haelt.
*/
export async function ungepflegteAbteilungen(
benutzer: HandelnderBenutzer,
@@ -89,7 +98,9 @@ export async function ungepflegteAbteilungen(
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
where: {
AND: [{ aktiv: true }, { abteilung: { not: null } }, { kunde: { archiviert: false } }, ...mandant],
},
_count: { _all: true },
}),
]);
+131
View File
@@ -0,0 +1,131 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { betriebsmodus, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { type QuellenSicht, quellenSichtBauen } from "@/server/services/verzeichnis";
/**
* Lesemodelle der Verzeichnisquellen (#016).
*
* Zwei Dinge wendet dieses Modul selbst an, damit kein Aufrufer sie vergessen
* kann:
*
* 1. **Im Modus DIENSTLEISTUNG gibt es keine Quellen.** Nicht „keine
* sichtbaren“, sondern keine: der Abgleich gilt nur fuer die eigene
* Organisation. Eine Quelle, die aus einer frueheren Zeit im Modus INTERN
* noch in der Tabelle steht, wird deshalb hier nicht gezeigt — genauso wenig,
* wie sich der Abgleich dann starten liesse.
* 2. **Das Bind-Passwort kommt nicht mit.** Die Zeile wird ueber
* `quellenSichtBauen` in `QuellenSicht` uebersetzt, die kein Feld dafuer hat.
* Ein `select` mit einer Feldliste waere die zweite Stelle, an der jemand ein
* Feld vergessen oder zu viel aufnehmen kann.
*/
export type { QuellenSicht };
export async function quellenListe(benutzer: HandelnderBenutzer): Promise<readonly QuellenSicht[]> {
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return [];
// Im Modus INTERN gibt es genau eine Organisation, und `sichtbareKundenIds`
// liefert dort `undefined`. Die Bedingung steht trotzdem hier: sie ist die
// Mandantensicht dieses Lesemodells, und sie wuerde fehlen, sobald sich die
// Regel oben einmal aendert.
const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.verzeichnisquelle.findMany({
where: sichtbar === undefined ? {} : { kundeId: { in: [...sichtbar] } },
orderBy: [{ name: "asc" }],
});
return zeilen.map(quellenSichtBauen);
}
export async function quelleHolen(
id: string,
benutzer: HandelnderBenutzer,
): Promise<QuellenSicht | undefined> {
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return undefined;
const zeile = await prisma.verzeichnisquelle.findUnique({ where: { id } });
if (!zeile) return undefined;
const sichtbar = await sichtbareKundenIds(benutzer);
// Eine fremde Quelle ist „nicht vorhanden“ und nicht „verboten“: die Auskunft,
// dass es sie gibt, ist selbst schon eine.
if (sichtbar !== undefined && !sichtbar.includes(zeile.kundeId)) return undefined;
return quellenSichtBauen(zeile);
}
/**
* Eine Zeile des Abgleich-Logs.
*
* Die Zahlen stehen im `Protokoll` und nicht in einer eigenen Tabelle: jeder Lauf
* schreibt dort ohnehin einen Eintrag, und ein zweiter Ort fuer dieselbe Auskunft
* liefe auseinander. `durch` ist `null`, wenn der Zeitplan den Lauf angestossen
* hat — dann gibt es keinen Menschen, der es getan hat.
*/
export type AbgleichLogZeile = {
readonly id: string;
readonly zeitpunkt: Date;
readonly gescheitert: boolean;
readonly gelesen: number;
readonly angelegt: number;
readonly uebernommen: number;
readonly aktualisiert: number;
readonly deaktiviert: number;
readonly uebersprungen: number;
readonly fehler: string | null;
readonly durch: string | null;
};
/** Wie viele Laeufe die Seite zeigt. Mehr liest niemand, und alle waeren unbegrenzt viele. */
export const LOG_ZEILEN = 10;
/** Eine Zahl aus dem Json-Feld, oder 0 — aeltere Eintraege kennen neue Spalten nicht. */
function zahl(nachher: unknown, name: string): number {
if (typeof nachher !== "object" || nachher === null) return 0;
const wert = (nachher as Record<string, unknown>)[name];
return typeof wert === "number" && Number.isFinite(wert) ? wert : 0;
}
function textOderNull(nachher: unknown, name: string): string | null {
if (typeof nachher !== "object" || nachher === null) return null;
const wert = (nachher as Record<string, unknown>)[name];
return typeof wert === "string" && wert.trim() ? wert : null;
}
/**
* Die letzten Abgleich-Laeufe einer Quelle.
*
* Geht ueber `quelleHolen` und damit ueber dieselbe Mandanten- und Modussicht:
* eine fremde oder im Modus DIENSTLEISTUNG liegende Quelle hat kein Log, und die
* Antwort ist leer und nicht verboten.
*/
export async function abgleichLog(
quelleId: string,
benutzer: HandelnderBenutzer,
grenze: number = LOG_ZEILEN,
): Promise<readonly AbgleichLogZeile[]> {
const quelle = await quelleHolen(quelleId, benutzer);
if (!quelle) return [];
const zeilen = await prisma.protokoll.findMany({
where: {
entitaet: "Verzeichnisquelle",
entitaetId: quelleId,
aktion: { in: ["Verzeichnis abgeglichen", "Verzeichnisabgleich fehlgeschlagen"] },
},
orderBy: { erstelltAm: "desc" },
take: Math.max(1, Math.trunc(grenze)),
include: { benutzer: { select: { name: true, email: true } } },
});
return zeilen.map((zeile) => ({
id: zeile.id,
zeitpunkt: zeile.erstelltAm,
gescheitert: zeile.aktion === "Verzeichnisabgleich fehlgeschlagen",
gelesen: zahl(zeile.nachher, "gelesen"),
angelegt: zahl(zeile.nachher, "angelegt"),
uebernommen: zahl(zeile.nachher, "uebernommen"),
aktualisiert: zahl(zeile.nachher, "aktualisiert"),
deaktiviert: zahl(zeile.nachher, "deaktiviert"),
uebersprungen: zahl(zeile.nachher, "uebersprungen"),
fehler: textOderNull(zeile.nachher, "fehler"),
// Der Name, sonst die Mailadresse: ein Benutzer ohne Namen ist moeglich.
durch: zeile.benutzer ? (zeile.benutzer.name ?? zeile.benutzer.email) : null,
}));
}