feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
This commit was merged in pull request #25.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import type { Abteilung, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Abteilungen (#011).
|
||||
*
|
||||
* Eine Abteilung ist mehr als eine Beschriftung: an ihr haengen die Ziel-OU und
|
||||
* die Standardgruppen, also genau das, was die spaeteren PowerShell-Befehle in
|
||||
* ein Verzeichnis schreiben (#012). Ein Tippfehler in der Ziel-OU legt ein Konto
|
||||
* an der falschen Stelle an — deshalb gehoert das Pflegen zu `verwaltung` und
|
||||
* nicht zur taeglichen Technikerarbeit.
|
||||
*
|
||||
* Der Name ist **je Kunde** eindeutig (`@@unique([kundeId, name])`); im Modus
|
||||
* INTERN ist das die eine eigene Organisation.
|
||||
*/
|
||||
|
||||
export class AbteilungsFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "AbteilungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type AbteilungsEingabe = {
|
||||
readonly name: string;
|
||||
readonly zielOu?: string | null;
|
||||
readonly standardGruppen?: readonly string[];
|
||||
readonly mailDomain?: string | null;
|
||||
readonly notizen?: string | null;
|
||||
readonly kundeId?: string | null;
|
||||
};
|
||||
|
||||
function leerAlsNull(wert: string | null | undefined): string | null {
|
||||
const sauber = wert?.trim();
|
||||
return sauber ? sauber : null;
|
||||
}
|
||||
|
||||
function namePruefen(roh: string): string {
|
||||
const name = roh.trim();
|
||||
if (!name) throw new AbteilungsFehler("Eine Abteilung braucht einen Namen.");
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Mail-Domain ist keine Adresse.
|
||||
*
|
||||
* Geprueft wird nur grob: kein `@`, mindestens ein Punkt. Strenger waere eine
|
||||
* Zusage, die wir nicht halten koennen — gueltige Domains gibt es in mehr Formen,
|
||||
* als ein Ausdruck sinnvoll abbildet.
|
||||
*/
|
||||
function mailDomainPruefen(wert: string | null): string | null {
|
||||
if (wert === null) return null;
|
||||
const domain = wert.replace(/^@/u, "");
|
||||
if (domain.includes("@") || !domain.includes(".")) {
|
||||
throw new AbteilungsFehler(
|
||||
`„${wert}“ sieht nicht wie eine Mail-Domain aus. Erwartet wird etwa „firma.de“, keine Adresse.`,
|
||||
);
|
||||
}
|
||||
return domain.toLowerCase();
|
||||
}
|
||||
|
||||
/** Leere Einträge und Doppelungen raus, Reihenfolge bleibt. */
|
||||
function gruppenSaeubern(gruppen: readonly string[] | undefined): string[] {
|
||||
const gesehen = new Set<string>();
|
||||
const sauber: string[] = [];
|
||||
for (const gruppe of gruppen ?? []) {
|
||||
const wert = gruppe.trim();
|
||||
if (!wert || gesehen.has(wert)) continue;
|
||||
gesehen.add(wert);
|
||||
sauber.push(wert);
|
||||
}
|
||||
return sauber;
|
||||
}
|
||||
|
||||
export async function abteilungAnlegen(
|
||||
eingabe: AbteilungsEingabe,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Abteilung> {
|
||||
const name = namePruefen(eingabe.name);
|
||||
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
|
||||
|
||||
const vorhanden = await prisma.abteilung.findUnique({ where: { kundeId_name: { kundeId, name } } });
|
||||
if (vorhanden) {
|
||||
// Die Datenbank wuerde es ohnehin abweisen; hier steht der Satz, den ein
|
||||
// Mensch lesen kann.
|
||||
throw new AbteilungsFehler(`Die Abteilung „${name}“ gibt es schon.`);
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const abteilung = await tx.abteilung.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name,
|
||||
zielOu: leerAlsNull(eingabe.zielOu),
|
||||
standardGruppen: gruppenSaeubern(eingabe.standardGruppen),
|
||||
mailDomain: mailDomainPruefen(leerAlsNull(eingabe.mailDomain)),
|
||||
notizen: leerAlsNull(eingabe.notizen),
|
||||
},
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Abteilung",
|
||||
entitaetId: abteilung.id,
|
||||
aktion: "Abteilung angelegt",
|
||||
benutzerId: benutzer.id,
|
||||
nachher: { name: abteilung.name, zielOu: abteilung.zielOu },
|
||||
},
|
||||
});
|
||||
return abteilung;
|
||||
});
|
||||
}
|
||||
|
||||
export async function abteilungBearbeiten(
|
||||
abteilungId: string,
|
||||
aenderung: Partial<AbteilungsEingabe>,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Abteilung> {
|
||||
const vorher = await prisma.abteilung.findUnique({ where: { id: abteilungId } });
|
||||
if (!vorher) throw new AbteilungsFehler("Diese Abteilung gibt es nicht (mehr).");
|
||||
await kundeZugriffPruefen(vorher.kundeId, benutzer);
|
||||
|
||||
const daten: Prisma.AbteilungUpdateInput = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
if (aenderung.name !== undefined) {
|
||||
const name = namePruefen(aenderung.name);
|
||||
if (name !== vorher.name) {
|
||||
const andere = await prisma.abteilung.findUnique({
|
||||
where: { kundeId_name: { kundeId: vorher.kundeId, name } },
|
||||
});
|
||||
if (andere) throw new AbteilungsFehler(`Die Abteilung „${name}“ gibt es schon.`);
|
||||
daten.name = name;
|
||||
geaendert.push("Name");
|
||||
}
|
||||
}
|
||||
if (aenderung.zielOu !== undefined) {
|
||||
const zielOu = leerAlsNull(aenderung.zielOu);
|
||||
if (zielOu !== vorher.zielOu) {
|
||||
daten.zielOu = zielOu;
|
||||
geaendert.push("Ziel-OU");
|
||||
}
|
||||
}
|
||||
if (aenderung.mailDomain !== undefined) {
|
||||
const mailDomain = mailDomainPruefen(leerAlsNull(aenderung.mailDomain));
|
||||
if (mailDomain !== vorher.mailDomain) {
|
||||
daten.mailDomain = mailDomain;
|
||||
geaendert.push("Mail-Domain");
|
||||
}
|
||||
}
|
||||
if (aenderung.notizen !== undefined) {
|
||||
const notizen = leerAlsNull(aenderung.notizen);
|
||||
if (notizen !== vorher.notizen) {
|
||||
daten.notizen = notizen;
|
||||
geaendert.push("Notizen");
|
||||
}
|
||||
}
|
||||
if (aenderung.standardGruppen !== undefined) {
|
||||
const gruppen = gruppenSaeubern(aenderung.standardGruppen);
|
||||
if (gruppen.join("\u0000") !== vorher.standardGruppen.join("\u0000")) {
|
||||
daten.standardGruppen = gruppen;
|
||||
geaendert.push("Standardgruppen");
|
||||
}
|
||||
}
|
||||
|
||||
if (geaendert.length === 0) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Abteilung",
|
||||
entitaetId: abteilungId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
benutzerId: benutzer.id,
|
||||
vorher: { name: vorher.name, zielOu: vorher.zielOu, standardGruppen: vorher.standardGruppen },
|
||||
nachher: { name: nachher.name, zielOu: nachher.zielOu, standardGruppen: nachher.standardGruppen },
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archivieren statt loeschen.
|
||||
*
|
||||
* An einer Abteilung haengen Laeufe, und ein archivierter Lauf muss auch in zehn
|
||||
* Jahren noch sagen koennen, fuer welche Abteilung er lief. Der Fremdschluessel
|
||||
* steht deshalb auf `Restrict`, und es gibt hier keinen Loeschweg.
|
||||
*/
|
||||
export async function abteilungArchivieren(
|
||||
abteilungId: string,
|
||||
archiviert: boolean,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Abteilung> {
|
||||
const vorher = await prisma.abteilung.findUnique({ where: { id: abteilungId } });
|
||||
if (!vorher) throw new AbteilungsFehler("Diese Abteilung gibt es nicht (mehr).");
|
||||
await kundeZugriffPruefen(vorher.kundeId, benutzer);
|
||||
if (vorher.archiviert === archiviert) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Abteilung",
|
||||
entitaetId: abteilungId,
|
||||
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
|
||||
benutzerId: benutzer.id,
|
||||
nachher: { archiviert },
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { Prisma, type Geraet } from "@/generated/prisma/client";
|
||||
import { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
|
||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Geraete und ihre Stammdaten (#011).
|
||||
*
|
||||
* Ein Geraet ist nicht nur ein PC: ein Serverschrank, ein Raum oder eine
|
||||
* Firewall bekommen genauso einen Aufkleber. Drei Regeln tragen das Ganze:
|
||||
*
|
||||
* 1. Der `tagCode` wird beim Anlegen erzeugt, nie geaendert und nie
|
||||
* wiederverwendet. Er landet zusaetzlich in `TagCodeVergabe`, damit er auch
|
||||
* nach dem Loeschen des Geraets vergeben bleibt.
|
||||
* 2. Ein **registriertes** Geraet braucht einen Hostname oder eine
|
||||
* Bezeichnung. Ein vorgedruckter, noch nicht zugeordneter Tag
|
||||
* (`registriertAm = null`) darf beides leer haben.
|
||||
* 3. Geloescht wird nur, solange kein Lauf am Geraet haengt. Sonst archiviert.
|
||||
*
|
||||
* Alle drei stehen zusaetzlich in der Datenbank (CHECK `Geraet_tagcode_form`,
|
||||
* CHECK `Geraet_registriert_braucht_namen`, Fremdschluessel auf `Restrict`).
|
||||
* Hier stehen sie fuer die verstaendliche Meldung.
|
||||
*/
|
||||
|
||||
export class GeraeteFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "GeraeteFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type NeuesGeraet = {
|
||||
readonly hostname?: string | null;
|
||||
readonly bezeichnung?: string | null;
|
||||
readonly seriennummer?: string | null;
|
||||
readonly inventarnummer?: string | null;
|
||||
readonly typ?: GeraeteTyp;
|
||||
readonly modell?: string | null;
|
||||
readonly notizen?: string | null;
|
||||
/** Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen. */
|
||||
readonly mitarbeiterId?: string | null;
|
||||
/** Nur im Modus DIENSTLEISTUNG beachtet; im Modus INTERN verworfen (#008). */
|
||||
readonly kundeId?: string | null;
|
||||
/**
|
||||
* `false` legt einen vorgedruckten Tag ohne Zuordnung an – dann sind Hostname
|
||||
* und Bezeichnung erlaubt leer. Standard ist `true`, weil jedes von Hand oder
|
||||
* per CSV angelegte Geraet ein echtes Objekt beschreibt.
|
||||
*/
|
||||
readonly registrieren?: boolean;
|
||||
};
|
||||
|
||||
export type GeraeteAenderung = {
|
||||
readonly hostname?: string | null;
|
||||
readonly bezeichnung?: string | null;
|
||||
readonly seriennummer?: string | null;
|
||||
readonly inventarnummer?: string | null;
|
||||
readonly typ?: GeraeteTyp;
|
||||
readonly modell?: string | null;
|
||||
readonly notizen?: string | null;
|
||||
readonly mitarbeiterId?: string | null;
|
||||
/**
|
||||
* Nimmt einen mitgeschickten `tagCode` nur an, um ihn abzuweisen, wenn er
|
||||
* abweicht. Ohne dieses Feld liefe ein Aenderungsversuch ins Leere und saehe
|
||||
* fuer den Aufrufer wie ein Erfolg aus.
|
||||
*/
|
||||
readonly tagCode?: string;
|
||||
/** Macht aus einem vorgedruckten Tag ein registriertes Geraet. */
|
||||
readonly registrieren?: boolean;
|
||||
};
|
||||
|
||||
/** Leer und „nur Leerraum“ sind dasselbe wie „nicht gesetzt“. */
|
||||
function text(wert: string | null | undefined): string | null {
|
||||
return wert?.trim() || null;
|
||||
}
|
||||
|
||||
/** Wie das Geraet in einer Meldung heisst. Der tagCode ist immer da. */
|
||||
function geraetName(geraet: {
|
||||
readonly hostname: string | null;
|
||||
readonly bezeichnung: string | null;
|
||||
readonly tagCode: string;
|
||||
}): string {
|
||||
return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Pflichtregel aus dem Auftrag, an einer Stelle.
|
||||
*
|
||||
* Geprueft wird der **Zustand danach**, nicht die Eingabe: wer den Hostname
|
||||
* leert, ohne eine Bezeichnung zu setzen, faellt genauso hierauf wie wer ohne
|
||||
* beides anlegt.
|
||||
*/
|
||||
function namenPflichtPruefen(
|
||||
hostname: string | null,
|
||||
bezeichnung: string | null,
|
||||
registriert: boolean,
|
||||
): void {
|
||||
if (registriert && !hostname && !bezeichnung) {
|
||||
throw new GeraeteFehler(
|
||||
"Ein registriertes Gerät braucht einen Hostname oder eine Bezeichnung — " +
|
||||
"sonst steht es in jeder Liste nur als Aufkleber.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Besitzer muss zum selben Kunden gehoeren.
|
||||
*
|
||||
* Die Datenbank erzwingt das ueber den zusammengesetzten Fremdschluessel
|
||||
* `Geraet_besitzer_gleicher_kunde`. Hier steht es fuer die Meldung — „Foreign key
|
||||
* constraint violated“ hilft niemandem.
|
||||
*
|
||||
* Ein **deaktivierter** Mitarbeiter wird bewusst nicht abgewiesen: beim
|
||||
* Offboarding bleibt das Notebook der Person zugeordnet, bis es zurueck ist.
|
||||
*/
|
||||
async function besitzerPruefen(mitarbeiterId: string, kundeId: string): Promise<void> {
|
||||
const person = await prisma.mitarbeiter.findUnique({
|
||||
where: { id: mitarbeiterId },
|
||||
select: { id: true, kundeId: true, anzeigename: true },
|
||||
});
|
||||
if (!person) throw new GeraeteFehler("Diese Person gibt es nicht.");
|
||||
if (person.kundeId !== kundeId) {
|
||||
throw new GeraeteFehler(
|
||||
`„${person.anzeigename}“ gehört zu einem anderen Kunden und kann dieses Gerät nicht besitzen.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht einen noch freien `tagCode`.
|
||||
*
|
||||
* Gefragt wird in beiden Buechern: `TagCodeVergabe` ist das Verzeichnis aller je
|
||||
* vergebenen Codes, `Geraet.tagCode` der eindeutige Index, der im Zweifel
|
||||
* entscheidet. Ein Code, der nur in einem von beiden steht, ist trotzdem
|
||||
* vergeben.
|
||||
*/
|
||||
async function freienTagCodeZiehen(): Promise<string> {
|
||||
return tagCodeEindeutigErzeugen(async (code) => {
|
||||
const [vergabe, geraet] = await Promise.all([
|
||||
prisma.tagCodeVergabe.findUnique({ where: { tagCode: code }, select: { tagCode: true } }),
|
||||
prisma.geraet.findUnique({ where: { tagCode: code }, select: { id: true } }),
|
||||
]);
|
||||
return vergabe !== null || geraet !== null;
|
||||
});
|
||||
}
|
||||
|
||||
type SchreibAuftrag = {
|
||||
readonly eingabe: NeuesGeraet;
|
||||
readonly kundeId: string;
|
||||
readonly durchBenutzerId: string;
|
||||
readonly aktion: string;
|
||||
/** Kommt in `Protokoll.nachher` mit – beim Import die Zeilennummer. */
|
||||
readonly zusatz?: Readonly<Record<string, string | number>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Das Anlegen selbst, ohne Mandantenermittlung.
|
||||
*
|
||||
* Getrennt, weil der CSV-Import den Kunden **einmal** ermittelt und dann Zeile
|
||||
* fuer Zeile schreibt. Zweimal denselben Weg zu bauen waere der sichere Weg,
|
||||
* dass eine Regel nur in einem von beiden landet.
|
||||
*/
|
||||
async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
|
||||
const { eingabe, kundeId, durchBenutzerId } = auftrag;
|
||||
const registriert = eingabe.registrieren ?? true;
|
||||
const hostname = text(eingabe.hostname);
|
||||
const bezeichnung = text(eingabe.bezeichnung);
|
||||
namenPflichtPruefen(hostname, bezeichnung, registriert);
|
||||
|
||||
const mitarbeiterId = text(eingabe.mitarbeiterId);
|
||||
if (mitarbeiterId) await besitzerPruefen(mitarbeiterId, kundeId);
|
||||
|
||||
const tagCode = await freienTagCodeZiehen();
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Beides in einer Transaktion: ein Geraet mit einem Code, der nicht im
|
||||
// Vergabebuch steht, waere nach dem Loeschen des Geraets wieder frei – und
|
||||
// ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.
|
||||
await tx.tagCodeVergabe.create({ data: { tagCode } });
|
||||
const geraet = await tx.geraet.create({
|
||||
data: {
|
||||
kundeId,
|
||||
tagCode,
|
||||
hostname,
|
||||
bezeichnung,
|
||||
seriennummer: text(eingabe.seriennummer),
|
||||
inventarnummer: text(eingabe.inventarnummer),
|
||||
typ: eingabe.typ ?? GeraeteTyp.SONSTIGES,
|
||||
modell: text(eingabe.modell),
|
||||
notizen: text(eingabe.notizen),
|
||||
mitarbeiterId,
|
||||
registriertAm: registriert ? new Date() : null,
|
||||
},
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraet.id,
|
||||
aktion: auftrag.aktion,
|
||||
benutzerId: durchBenutzerId,
|
||||
nachher: {
|
||||
tagCode: geraet.tagCode,
|
||||
hostname: geraet.hostname,
|
||||
bezeichnung: geraet.bezeichnung,
|
||||
typ: geraet.typ,
|
||||
...(auftrag.zusatz ?? {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return geraet;
|
||||
});
|
||||
} catch (fehler) {
|
||||
// Zwei Anfragen haben in derselben Sekunde denselben Code gezogen. Bei 32^6
|
||||
// Moeglichkeiten praktisch unmoeglich, aber wenn doch, dann lieber mit einer
|
||||
// Meldung, die zum Wiederholen auffordert, als mit dem rohen Indexfehler.
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||
throw new GeraeteFehler("Der erzeugte Tag-Code war schon vergeben. Bitte noch einmal versuchen.");
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Legt ein Geraet an und erzeugt seinen `tagCode`. */
|
||||
export async function geraetAnlegen(eingabe: NeuesGeraet, benutzer: HandelnderBenutzer): Promise<Geraet> {
|
||||
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
|
||||
return geraetSchreiben({
|
||||
eingabe,
|
||||
kundeId,
|
||||
durchBenutzerId: benutzer.id,
|
||||
aktion: "Gerät angelegt",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aendert die Stammdaten eines Geraets.
|
||||
*
|
||||
* Der Kunde bleibt, wie er ist: ein Geraet zu einem anderen Kunden zu schieben
|
||||
* wuerde die Laeufe, die daran haengen, mandantenfremd machen. Wer sich vertan
|
||||
* hat, legt das Geraet beim richtigen Kunden neu an — der alte Tag-Code bleibt
|
||||
* dann vergeben, und genau das ist richtig.
|
||||
*/
|
||||
export async function geraetBearbeiten(
|
||||
geraetId: string,
|
||||
aenderung: GeraeteAenderung,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Geraet> {
|
||||
const vorher = await prisma.geraet.findUnique({ where: { id: geraetId } });
|
||||
if (!vorher) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
|
||||
await kundeZugriffPruefen(vorher.kundeId, benutzer);
|
||||
|
||||
if (aenderung.tagCode !== undefined && aenderung.tagCode.trim() !== vorher.tagCode) {
|
||||
throw new GeraeteFehler(
|
||||
`Der Tag-Code ${vorher.tagCode} bleibt, wie er ist. Er steht auf einem Aufkleber, ` +
|
||||
"der schon am Gerät klebt — und wird nie geändert und nie neu vergeben.",
|
||||
);
|
||||
}
|
||||
|
||||
const daten: Prisma.GeraetUpdateInput = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
const felder = [
|
||||
["hostname", "Hostname", aenderung.hostname],
|
||||
["bezeichnung", "Bezeichnung", aenderung.bezeichnung],
|
||||
["seriennummer", "Seriennummer", aenderung.seriennummer],
|
||||
["inventarnummer", "Inventarnummer", aenderung.inventarnummer],
|
||||
["modell", "Modell", aenderung.modell],
|
||||
["notizen", "Notizen", aenderung.notizen],
|
||||
] as const;
|
||||
|
||||
for (const [feld, beschriftung, roh] of felder) {
|
||||
if (roh === undefined) continue;
|
||||
const wert = text(roh);
|
||||
if (wert !== vorher[feld]) {
|
||||
daten[feld] = wert;
|
||||
geaendert.push(beschriftung);
|
||||
}
|
||||
}
|
||||
|
||||
if (aenderung.typ !== undefined && aenderung.typ !== vorher.typ) {
|
||||
daten.typ = aenderung.typ;
|
||||
geaendert.push("Typ");
|
||||
}
|
||||
|
||||
if (aenderung.mitarbeiterId !== undefined) {
|
||||
const wert = text(aenderung.mitarbeiterId);
|
||||
if (wert !== vorher.mitarbeiterId) {
|
||||
if (wert) await besitzerPruefen(wert, vorher.kundeId);
|
||||
daten.mitarbeiter = wert ? { connect: { id: wert } } : { disconnect: true };
|
||||
geaendert.push("Besitzer");
|
||||
}
|
||||
}
|
||||
|
||||
// Registrieren geht nur in eine Richtung: ein Geraet, das einmal gescannt und
|
||||
// benannt wurde, wieder zum leeren Aufkleber zu machen, hiesse seine Geschichte
|
||||
// zu verlieren.
|
||||
const registriertNachher = vorher.registriertAm !== null || aenderung.registrieren === true;
|
||||
if (aenderung.registrieren === true && vorher.registriertAm === null) {
|
||||
daten.registriertAm = new Date();
|
||||
geaendert.push("Registrierung");
|
||||
}
|
||||
|
||||
namenPflichtPruefen(
|
||||
aenderung.hostname === undefined ? vorher.hostname : text(aenderung.hostname),
|
||||
aenderung.bezeichnung === undefined ? vorher.bezeichnung : text(aenderung.bezeichnung),
|
||||
registriertNachher,
|
||||
);
|
||||
|
||||
if (geaendert.length === 0) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraetId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
benutzerId: benutzer.id,
|
||||
// Nur das, was sich geaendert hat – ein Protokoll, das jedes Mal alle
|
||||
// Felder wiederholt, liest niemand mehr.
|
||||
vorher: protokollAuszug(vorher, geaendert),
|
||||
nachher: protokollAuszug(nachher, geaendert),
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
|
||||
/** Die Felder zu den genannten Beschriftungen – fuer `Protokoll.vorher`/`nachher`. */
|
||||
function protokollAuszug(geraet: Geraet, geaendert: readonly string[]): Prisma.InputJsonValue {
|
||||
const auszug: Record<string, string | null> = { tagCode: geraet.tagCode };
|
||||
const abbildung: Readonly<Record<string, string | null>> = {
|
||||
Hostname: geraet.hostname,
|
||||
Bezeichnung: geraet.bezeichnung,
|
||||
Seriennummer: geraet.seriennummer,
|
||||
Inventarnummer: geraet.inventarnummer,
|
||||
Modell: geraet.modell,
|
||||
Notizen: geraet.notizen,
|
||||
Typ: geraet.typ,
|
||||
Besitzer: geraet.mitarbeiterId,
|
||||
Registrierung: geraet.registriertAm?.toISOString() ?? null,
|
||||
};
|
||||
for (const beschriftung of geaendert) {
|
||||
if (beschriftung in abbildung) auszug[beschriftung] = abbildung[beschriftung] ?? null;
|
||||
}
|
||||
return auszug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archivieren statt Loeschen.
|
||||
*
|
||||
* Ein archiviertes Geraet erscheint nicht mehr in der Auswahl, seine Laeufe aber
|
||||
* weiter im Archiv. Der `tagCode` bleibt vergeben.
|
||||
*/
|
||||
export async function geraetArchivieren(
|
||||
geraetId: string,
|
||||
archiviert: boolean,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<void> {
|
||||
const geraet = await prisma.geraet.findUnique({
|
||||
where: { id: geraetId },
|
||||
select: { id: true, kundeId: true, archiviert: true, hostname: true, bezeichnung: true, tagCode: true },
|
||||
});
|
||||
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
|
||||
await kundeZugriffPruefen(geraet.kundeId, benutzer);
|
||||
if (geraet.archiviert === archiviert) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraetId,
|
||||
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben",
|
||||
benutzerId: benutzer.id,
|
||||
vorher: { archiviert: geraet.archiviert },
|
||||
nachher: { archiviert, tagCode: geraet.tagCode },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht ein Geraet – nur, solange kein Lauf daran haengt.
|
||||
*
|
||||
* Der `tagCode` bleibt in `TagCodeVergabe` stehen und wird nie wieder vergeben.
|
||||
* Das ist der Zweck dieses Buches: ohne es waere der Code nach dem Loeschen frei,
|
||||
* und ein uebrig gebliebener Aufkleber zeigte irgendwann auf ein fremdes Objekt.
|
||||
*/
|
||||
export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenutzer): Promise<void> {
|
||||
const geraet = await prisma.geraet.findUnique({
|
||||
where: { id: geraetId },
|
||||
select: {
|
||||
id: true,
|
||||
kundeId: true,
|
||||
tagCode: true,
|
||||
hostname: true,
|
||||
bezeichnung: true,
|
||||
typ: true,
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
});
|
||||
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
|
||||
await kundeZugriffPruefen(geraet.kundeId, benutzer);
|
||||
|
||||
if (geraet._count.laeufe > 0) {
|
||||
const anzahl = geraet._count.laeufe;
|
||||
throw new GeraeteFehler(
|
||||
`An „${geraetName(geraet)}“ ${anzahl === 1 ? "hängt ein Lauf" : `hängen ${anzahl} Läufe`}. ` +
|
||||
"Das Gerät lässt sich deshalb nur archivieren — ein Lauf muss für immer nachvollziehbar bleiben.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.geraet.delete({ where: { id: geraetId } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraetId,
|
||||
aktion: "Gerät gelöscht",
|
||||
benutzerId: benutzer.id,
|
||||
vorher: {
|
||||
tagCode: geraet.tagCode,
|
||||
hostname: geraet.hostname,
|
||||
bezeichnung: geraet.bezeichnung,
|
||||
typ: geraet.typ,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (fehler) {
|
||||
// Zwischen Zaehlung und Loeschen kann ein Lauf entstanden sein; dann greift
|
||||
// der Fremdschluessel auf Restrict.
|
||||
if (
|
||||
fehler instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(fehler.code === "P2003" || fehler.code === "P2014")
|
||||
) {
|
||||
throw new GeraeteFehler(
|
||||
`An „${geraetName(geraet)}“ hängt inzwischen ein Lauf. Das Gerät lässt sich nur archivieren.`,
|
||||
);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CSV-Import ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obergrenze je Datei.
|
||||
*
|
||||
* Ohne Grenze belegt eine versehentlich exportierte 50-MB-Tabelle die Datenbank
|
||||
* mit Zeilen, die niemand wollte, und der Import laeuft minutenlang. 500 ist
|
||||
* grosszuegig fuer einen Bestand, der von Hand gepflegt wird, und die Oberflaeche
|
||||
* nennt die Zahl, **bevor** jemand dagegen laeuft.
|
||||
*/
|
||||
export const GERAETE_IMPORT_HOECHSTZEILEN = 500;
|
||||
|
||||
/**
|
||||
* Die Spalten aus dem Auftrag – englisch, wie dort genannt – und jeweils die
|
||||
* deutsche Entsprechung. Gross- und Kleinschreibung, Leerraum, Unterstriche und
|
||||
* Bindestriche sind unerheblich, dafuer sorgt `spaltenName` in src/lib/csv.ts.
|
||||
*/
|
||||
export const GERAETE_IMPORT_SPALTEN = {
|
||||
hostname: ["hostname"],
|
||||
bezeichnung: ["label", "bezeichnung"],
|
||||
seriennummer: ["serialNumber", "seriennummer"],
|
||||
inventarnummer: ["inventoryNumber", "inventarnummer"],
|
||||
typ: ["type", "typ"],
|
||||
modell: ["model", "modell"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Woerter, die auf einen `GeraeteTyp` fuehren.
|
||||
*
|
||||
* Die Enum-Werte selbst kommen aus dem Enum, damit ein neuer Typ nicht in zwei
|
||||
* Listen eingetragen werden muss. Dazu die Woerter, die in echten Exporten
|
||||
* stehen — englisch und deutsch.
|
||||
*/
|
||||
const TYP_WOERTER: ReadonlyMap<string, GeraeteTyp> = new Map<string, GeraeteTyp>([
|
||||
...Object.values(GeraeteTyp).map((typ) => [typ.toLowerCase(), typ] as const),
|
||||
["laptop", GeraeteTyp.NOTEBOOK],
|
||||
["nb", GeraeteTyp.NOTEBOOK],
|
||||
["desktop", GeraeteTyp.PC],
|
||||
["workstation", GeraeteTyp.PC],
|
||||
["printer", GeraeteTyp.DRUCKER],
|
||||
["room", GeraeteTyp.RAUM],
|
||||
["other", GeraeteTyp.SONSTIGES],
|
||||
["sonstige", GeraeteTyp.SONSTIGES],
|
||||
]);
|
||||
|
||||
/** Die erlaubten Typen als Aufzaehlung – fuer die Fehlermeldung und die Oberflaeche. */
|
||||
export const GERAETE_TYPEN: readonly GeraeteTyp[] = Object.values(GeraeteTyp);
|
||||
|
||||
export type ImportAbweisung = {
|
||||
/** Zeilennummer in der Datei, wie sie ein Editor anzeigt. */
|
||||
readonly zeile: number;
|
||||
readonly grund: string;
|
||||
};
|
||||
|
||||
export type ImportErgebnis = {
|
||||
/** Datenzeilen in der Datei, ohne Kopfzeile und ohne Leerzeilen. */
|
||||
readonly gesamt: number;
|
||||
readonly angelegt: number;
|
||||
readonly abgewiesen: readonly ImportAbweisung[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Liest einen Typ aus der Spalte `type`.
|
||||
*
|
||||
* Ein unbekannter Wert ist ein Zeilenfehler und **kein** stiller Standardwert:
|
||||
* „Tablet“ als SONSTIGES durchzuwinken heisst, den Bestand still falsch zu
|
||||
* fuellen. Eine leere Zelle ist etwas anderes — dort steht nichts, also gilt
|
||||
* SONSTIGES.
|
||||
*/
|
||||
function typLesen(roh: string): GeraeteTyp {
|
||||
if (!roh) return GeraeteTyp.SONSTIGES;
|
||||
const typ = TYP_WOERTER.get(roh.trim().toLowerCase());
|
||||
if (!typ) {
|
||||
throw new GeraeteFehler(
|
||||
`„${roh}“ ist kein bekannter Gerätetyp. Erlaubt sind: ${GERAETE_TYPEN.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return typ;
|
||||
}
|
||||
|
||||
/** Eine Zeile der Datei zu einer Geraeteeingabe. Wirft bei einem Zeilenfehler. */
|
||||
function zeileLesen(zeile: CsvZeile): NeuesGeraet {
|
||||
const hostname = wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.hostname);
|
||||
const bezeichnung = wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.bezeichnung);
|
||||
if (!hostname && !bezeichnung) {
|
||||
throw new GeraeteFehler("Weder ein Hostname noch eine Bezeichnung steht in dieser Zeile.");
|
||||
}
|
||||
return {
|
||||
hostname,
|
||||
bezeichnung,
|
||||
seriennummer: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.seriennummer),
|
||||
inventarnummer: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.inventarnummer),
|
||||
typ: typLesen(wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.typ)),
|
||||
modell: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.modell),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt eine CSV-Datei mit Geraeten ein.
|
||||
*
|
||||
* **Je Zeile ein Ergebnis.** Eine fehlerhafte Zeile bricht den Import nicht ab;
|
||||
* sie wird mit Zeilennummer und Grund gemeldet, die uebrigen werden angelegt.
|
||||
* Deshalb hat jede Zeile ihre eigene Transaktion: eine gemeinsame haette am Ende
|
||||
* alles zurueckgerollt, was vor dem Fehler gelungen war.
|
||||
*
|
||||
* Der `tagCode` wird je Zeile erzeugt, genau wie beim Anlegen von Hand.
|
||||
*/
|
||||
export async function geraeteImportieren(
|
||||
csvText: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
kundeIdAusEingabe?: string | null,
|
||||
): Promise<ImportErgebnis> {
|
||||
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
|
||||
|
||||
let tabelle;
|
||||
try {
|
||||
tabelle = csvZerlegen(csvText, { hoechstZeilen: GERAETE_IMPORT_HOECHSTZEILEN });
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof CsvFehler) throw new GeraeteFehler(fehler.message);
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
// Ohne eine der Namensspalten koennte keine einzige Zeile gelingen. Das als
|
||||
// 500 gleichlautende Zeilenfehler zu melden waere richtig, aber nutzlos.
|
||||
const alleNamen = Object.values(GERAETE_IMPORT_SPALTEN).flat();
|
||||
if (!hatSpalte(tabelle, ...GERAETE_IMPORT_SPALTEN.hostname, ...GERAETE_IMPORT_SPALTEN.bezeichnung)) {
|
||||
throw new GeraeteFehler(
|
||||
"In der Kopfzeile fehlen „hostname“ und „label“ — eine davon muss es geben. " +
|
||||
`Gelesen wurden diese Spalten: ${alleNamen.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const abgewiesen: ImportAbweisung[] = [];
|
||||
let angelegt = 0;
|
||||
|
||||
/*
|
||||
* Doppelte Hostnamen in derselben Datei faengt diese Menge ab. Dieselbe Datei
|
||||
* zweimal einzulesen verdoppelte sonst den Bestand — und weil ein tagCode nie
|
||||
* zurueckkommt, waeren die Aufkleber dafuer schon gedruckt.
|
||||
*/
|
||||
const gesehene = new Set<string>();
|
||||
|
||||
for (const zeile of tabelle.zeilen) {
|
||||
if (zeile.fehler !== undefined) {
|
||||
abgewiesen.push({ zeile: zeile.nummer, grund: zeile.fehler });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const eingabe = zeileLesen(zeile);
|
||||
const hostname = eingabe.hostname?.trim().toLowerCase();
|
||||
if (hostname) {
|
||||
if (gesehene.has(hostname)) {
|
||||
abgewiesen.push({
|
||||
zeile: zeile.nummer,
|
||||
grund: `„${eingabe.hostname?.trim()}“ steht in dieser Datei schon weiter oben.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const schonDa = await prisma.geraet.findFirst({
|
||||
where: { kundeId, hostname: { equals: eingabe.hostname?.trim(), mode: "insensitive" } },
|
||||
select: { tagCode: true },
|
||||
});
|
||||
if (schonDa) {
|
||||
abgewiesen.push({
|
||||
zeile: zeile.nummer,
|
||||
grund: `„${eingabe.hostname?.trim()}“ gibt es schon — Tag-Code ${schonDa.tagCode}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
gesehene.add(hostname);
|
||||
}
|
||||
|
||||
await geraetSchreiben({
|
||||
eingabe,
|
||||
kundeId,
|
||||
durchBenutzerId: benutzer.id,
|
||||
aktion: "Gerät aus CSV angelegt",
|
||||
zusatz: { zeile: zeile.nummer },
|
||||
});
|
||||
angelegt += 1;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof GeraeteFehler) {
|
||||
abgewiesen.push({ zeile: zeile.nummer, grund: fehler.message });
|
||||
continue;
|
||||
}
|
||||
// Alles Uebrige – ein Verbindungsabbruch etwa – ist kein Zeilenproblem und
|
||||
// wuerde in den folgenden Zeilen genauso auftreten.
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
return { gesamt: tabelle.zeilen.length, angelegt, abgewiesen };
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
|
||||
import { Herkunft } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Mitarbeiter (#011).
|
||||
*
|
||||
* Die Personen, an denen die Historie haengt. Zwei Regeln tragen das:
|
||||
*
|
||||
* 1. **Nichts wird geloescht.** Ein Mitarbeiter wird auf `aktiv = false`
|
||||
* gesetzt. Ein archivierter Lauf zeigt auf ihn, und dieser Verweis muss in
|
||||
* zehn Jahren noch aufloesbar sein — deshalb steht der Fremdschluessel auf
|
||||
* `Restrict` und es gibt hier gar keinen Loeschweg.
|
||||
* 2. **Was aus dem Verzeichnis kommt, wird nicht von Hand ueberschrieben.** Bei
|
||||
* `herkunft = ABGLEICH` sind die abgeglichenen Felder gesperrt: der naechste
|
||||
* Abgleich wuerde die Aenderung ohnehin zurueckdrehen, und ein Wert, der
|
||||
* zwischen zwei Laeufen hin- und herspringt, ist schlimmer als ein falscher.
|
||||
* Was **nicht** aus dem Verzeichnis kommt, bleibt aenderbar (#016 fuellt die
|
||||
* Gegenseite).
|
||||
*/
|
||||
|
||||
export class MitarbeiterFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "MitarbeiterFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Felder, die der Verzeichnisabgleich fuellt.
|
||||
*
|
||||
* Als Daten und nicht als Kette von `if`s, damit die Sperre und die Oberflaeche
|
||||
* aus derselben Liste lesen — sonst zeigt das Formular ein Feld als aenderbar,
|
||||
* das der Dienst danach abweist.
|
||||
*/
|
||||
export const ABGEGLICHENE_FELDER = [
|
||||
"vorname",
|
||||
"nachname",
|
||||
"anzeigename",
|
||||
"mail",
|
||||
"upn",
|
||||
"sam",
|
||||
"abteilung",
|
||||
"titel",
|
||||
] as const;
|
||||
|
||||
export type AbgeglichenesFeld = (typeof ABGEGLICHENE_FELDER)[number];
|
||||
|
||||
export type MitarbeiterEingabe = {
|
||||
readonly vorname: string;
|
||||
readonly nachname: string;
|
||||
/** Leer: wird aus Vor- und Nachname gebildet. */
|
||||
readonly anzeigename?: string | null;
|
||||
readonly mail?: string | null;
|
||||
readonly upn?: string | null;
|
||||
readonly sam?: string | null;
|
||||
readonly abteilung?: string | null;
|
||||
readonly titel?: string | null;
|
||||
readonly kundeId?: string | null;
|
||||
};
|
||||
|
||||
function pflicht(wert: string, was: string): string {
|
||||
const sauber = wert.trim();
|
||||
if (!sauber) throw new MitarbeiterFehler(`${was} darf nicht leer sein.`);
|
||||
return sauber;
|
||||
}
|
||||
|
||||
function leerAlsNull(wert: string | null | undefined): string | null {
|
||||
const sauber = wert?.trim();
|
||||
return sauber ? sauber : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `sam` ist im Active Directory auf 20 Zeichen begrenzt.
|
||||
*
|
||||
* Die Regel steht hier, weil ein zu langer Wert erst beim `New-ADUser` im
|
||||
* Gesicht des Technikers explodieren wuerde — und dann vor dem Kunden.
|
||||
*/
|
||||
function samPruefen(wert: string | null): string | null {
|
||||
if (wert && wert.length > 20) {
|
||||
throw new MitarbeiterFehler(
|
||||
`„${wert}“ ist ${wert.length} Zeichen lang. Ein sAMAccountName fasst höchstens 20.`,
|
||||
);
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
|
||||
function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachname: string): string {
|
||||
const gewuenscht = leerAlsNull(eingabe.anzeigename);
|
||||
return gewuenscht ?? `${vorname} ${nachname}`.trim();
|
||||
}
|
||||
|
||||
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
|
||||
export async function mitarbeiterAnlegen(
|
||||
eingabe: MitarbeiterEingabe,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Mitarbeiter> {
|
||||
const vorname = pflicht(eingabe.vorname, "Der Vorname");
|
||||
const nachname = pflicht(eingabe.nachname, "Der Nachname");
|
||||
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const mitarbeiter = await tx.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId,
|
||||
vorname,
|
||||
nachname,
|
||||
anzeigename: anzeigenameBilden(eingabe, vorname, nachname),
|
||||
mail: leerAlsNull(eingabe.mail),
|
||||
upn: leerAlsNull(eingabe.upn),
|
||||
sam: samPruefen(leerAlsNull(eingabe.sam)),
|
||||
abteilung: leerAlsNull(eingabe.abteilung),
|
||||
titel: leerAlsNull(eingabe.titel),
|
||||
herkunft: Herkunft.MANUELL,
|
||||
},
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiter.id,
|
||||
aktion: "Mitarbeiter angelegt",
|
||||
benutzerId: benutzer.id,
|
||||
nachher: { anzeigename: mitarbeiter.anzeigename, herkunft: mitarbeiter.herkunft },
|
||||
},
|
||||
});
|
||||
return mitarbeiter;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aendert die Stammdaten.
|
||||
*
|
||||
* Bei `herkunft = ABGLEICH` sind die abgeglichenen Felder gesperrt — abgewiesen
|
||||
* wird aber nur, was sich wirklich aendern soll. Ein Formular, das alle Felder
|
||||
* mitschickt, darf deshalb weiterhin die freien Felder aendern, ohne an einem
|
||||
* unveraenderten gesperrten Feld zu scheitern.
|
||||
*/
|
||||
export async function mitarbeiterBearbeiten(
|
||||
mitarbeiterId: string,
|
||||
aenderung: Partial<MitarbeiterEingabe>,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Mitarbeiter> {
|
||||
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
|
||||
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
|
||||
await kundeZugriffPruefen(vorher.kundeId, benutzer);
|
||||
|
||||
const daten: Record<string, string | null> = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
function uebernehmen(feld: AbgeglichenesFeld | "anzeigename", neu: string | null) {
|
||||
if (neu === vorher![feld]) return;
|
||||
if (vorher!.herkunft === Herkunft.ABGLEICH && (ABGEGLICHENE_FELDER as readonly string[]).includes(feld)) {
|
||||
throw new MitarbeiterFehler(
|
||||
`„${vorher!.anzeigename}“ kommt aus dem Verzeichnis. ${feldName(feld)} wird dort gepflegt und ` +
|
||||
"beim nächsten Abgleich ohnehin zurückgesetzt.",
|
||||
);
|
||||
}
|
||||
daten[feld] = neu;
|
||||
geaendert.push(feldName(feld));
|
||||
}
|
||||
|
||||
if (aenderung.vorname !== undefined) uebernehmen("vorname", pflicht(aenderung.vorname, "Der Vorname"));
|
||||
if (aenderung.nachname !== undefined) uebernehmen("nachname", pflicht(aenderung.nachname, "Der Nachname"));
|
||||
if (aenderung.anzeigename !== undefined) {
|
||||
const gewuenscht = leerAlsNull(aenderung.anzeigename);
|
||||
uebernehmen(
|
||||
"anzeigename",
|
||||
gewuenscht ??
|
||||
`${(daten["vorname"] as string) ?? vorher.vorname} ${(daten["nachname"] as string) ?? vorher.nachname}`.trim(),
|
||||
);
|
||||
}
|
||||
if (aenderung.mail !== undefined) uebernehmen("mail", leerAlsNull(aenderung.mail));
|
||||
if (aenderung.upn !== undefined) uebernehmen("upn", leerAlsNull(aenderung.upn));
|
||||
if (aenderung.sam !== undefined) uebernehmen("sam", samPruefen(leerAlsNull(aenderung.sam)));
|
||||
if (aenderung.abteilung !== undefined) uebernehmen("abteilung", leerAlsNull(aenderung.abteilung));
|
||||
if (aenderung.titel !== undefined) uebernehmen("titel", leerAlsNull(aenderung.titel));
|
||||
|
||||
if (geaendert.length === 0) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiterId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
benutzerId: benutzer.id,
|
||||
vorher: auszug(vorher, Object.keys(daten)),
|
||||
nachher: auszug(nachher, Object.keys(daten)),
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deaktivieren und wieder aktivieren.
|
||||
*
|
||||
* Es gibt keinen Loeschweg — auch nicht fuer eine Person ohne Laeufe. Waere
|
||||
* Loeschen manchmal moeglich, muesste jeder Aufrufer die Bedingung kennen, und
|
||||
* beim Offboarding entscheidet sich niemand bewusst zwischen zwei Wegen.
|
||||
*/
|
||||
export async function mitarbeiterAktivSetzen(
|
||||
mitarbeiterId: string,
|
||||
aktiv: boolean,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Mitarbeiter> {
|
||||
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
|
||||
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
|
||||
await kundeZugriffPruefen(vorher.kundeId, benutzer);
|
||||
if (vorher.aktiv === aktiv) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiterId,
|
||||
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
|
||||
benutzerId: benutzer.id,
|
||||
vorher: { aktiv: vorher.aktiv },
|
||||
nachher: { aktiv },
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
|
||||
const FELD_NAMEN: Record<string, string> = {
|
||||
vorname: "Vorname",
|
||||
nachname: "Nachname",
|
||||
anzeigename: "Anzeigename",
|
||||
mail: "Mail-Adresse",
|
||||
upn: "UPN",
|
||||
sam: "sAMAccountName",
|
||||
abteilung: "Abteilung",
|
||||
titel: "Titel",
|
||||
};
|
||||
|
||||
function feldName(feld: string): string {
|
||||
return FELD_NAMEN[feld] ?? feld;
|
||||
}
|
||||
|
||||
/** Nur die geaenderten Felder ins Protokoll – ein ganzer Datensatz waere Rauschen. */
|
||||
function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJsonObject {
|
||||
const ausschnitt: Record<string, string | null> = {};
|
||||
for (const feld of felder) {
|
||||
ausschnitt[feld] = (zeile as unknown as Record<string, string | null>)[feld] ?? null;
|
||||
}
|
||||
return ausschnitt;
|
||||
}
|
||||
Reference in New Issue
Block a user