feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user