feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert,
Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf
haengen kann.

- Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName,
  Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes
  Bauteil, weil #013 und #014 sie wieder brauchen
- Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis
  kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die
  Namen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code;
  CSV-Import mit einem Ergebnis je Zeile
- Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und
  Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet

Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein
nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle
Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen
angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer
Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes
Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite:
ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert.

Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die
Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig
loeschen konnte.

610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand.

Schliesst #011

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 12:51:54 +02:00
co-authored by Claude Opus 5
parent e3d80e5982
commit 86d55917f9
81 changed files with 6784 additions and 59 deletions
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env tsx
/**
* Beispiel-Stammdaten fuer die Screenshots (#011).
*
* Personen, Abteilungen und Geraete lassen sich nur mit Inhalt zeigen, und die
* echten Beispieldaten kommen erst mit #017. Bis dahin legt dieses Skript einen
* kleinen, glaubwuerdigen Bestand an — darunter eine Person mit
* `herkunft = ABGLEICH`, damit die gesperrten Stammdaten sichtbar werden.
*
* Es geht durch die echten Dienste, wo es welche gibt: waeren die Regeln anders
* als hier angenommen, schlaegt es fehl, statt eine Lage herzustellen, die es in
* der Anwendung nie gibt. Der abgeglichene Fall entsteht direkt ueber Prisma —
* den Weg dorthin baut erst der Verzeichnisabgleich (#016).
*
* Idempotent. Mit #017 wird das Skript geloescht.
*
* pnpm beispiel:stammdaten
*/
import "dotenv/config";
import { Herkunft } from "../src/generated/prisma/enums.js";
import { prisma } from "../src/lib/db.js";
import { abteilungAnlegen } from "../src/server/services/abteilungen.js";
import { geraetAnlegen } from "../src/server/services/geraete.js";
import { mitarbeiterAnlegen } from "../src/server/services/mitarbeiter.js";
const admin = await prisma.benutzer.findFirst({
where: { rolle: "ADMIN" },
orderBy: { erstelltAm: "asc" },
select: { id: true, rolle: true },
});
if (!admin) {
throw new Error("Es gibt keinen Administrator. Erst pnpm db:seed laufen lassen.");
}
const handelnder = { id: admin.id, rolle: admin.rolle };
const eigene = await prisma.kunde.findFirstOrThrow({ where: { istEigeneOrganisation: true } });
const ABTEILUNGEN = [
{
name: "Buchhaltung",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
mailDomain: "firma.de",
},
{
name: "Vertrieb",
zielOu: "OU=Vertrieb,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Vertrieb", "G-Alle"],
mailDomain: "firma.de",
},
{
name: "IT",
zielOu: "OU=IT,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-IT", "G-Alle", "G-Adminrechte"],
mailDomain: "firma.de",
},
];
const PERSONEN = [
{ vorname: "Maria", nachname: "Huber", sam: "mhuber", abteilung: "Buchhaltung", titel: "Sachbearbeiterin" },
{ vorname: "Jörg", nachname: "Müller-Lüdenscheid", sam: "jmuellerluedensch", abteilung: "Vertrieb" },
{ vorname: "Ali", nachname: "Demir", sam: "ademir", abteilung: "IT", titel: "Systemadministrator" },
{ vorname: "Sonja", nachname: "Brand", sam: "sbrand", abteilung: "Vertrieb" },
];
let neueAbteilungen = 0;
for (const eingabe of ABTEILUNGEN) {
const vorhanden = await prisma.abteilung.findUnique({
where: { kundeId_name: { kundeId: eigene.id, name: eingabe.name } },
});
if (vorhanden) continue;
await abteilungAnlegen(eingabe, handelnder);
neueAbteilungen += 1;
}
let neuePersonen = 0;
for (const person of PERSONEN) {
const vorhanden = await prisma.mitarbeiter.findFirst({ where: { sam: person.sam } });
if (vorhanden) continue;
const mail = `${person.sam}@firma.de`;
await mitarbeiterAnlegen({ ...person, mail, upn: mail }, handelnder);
neuePersonen += 1;
}
/**
* Eine Person aus dem Verzeichnis.
*
* Direkt ueber Prisma, weil der Dienst nur `herkunft = MANUELL` anlegt — den
* anderen Weg baut der Verzeichnisabgleich (#016). Fuer den Screenshot der
* gesperrten Stammdaten braucht es sie trotzdem schon jetzt.
*/
const abgeglichen = await prisma.mitarbeiter.findFirst({ where: { sam: "kwinter" } });
if (!abgeglichen) {
await prisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Katrin",
nachname: "Winter",
anzeigename: "Katrin Winter",
mail: "kwinter@firma.de",
upn: "kwinter@firma.de",
sam: "kwinter",
abteilung: "Einkauf",
titel: "Einkäuferin",
dn: "CN=Katrin Winter,OU=Einkauf,OU=Benutzer,DC=firma,DC=de",
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: new Date(),
},
});
neuePersonen += 1;
}
/**
* Ein paar Geraete, darunter eines ohne Hostname.
*
* `PC-001` und `NB-001` heissen so, weil der Auftrag sie in den Beispieldaten
* nennt; der Serverschrank zeigt den Fall „Bezeichnung statt Hostname“, den die
* Pflichtregel ausdruecklich erlaubt.
*/
const GERAETE = [
{ hostname: "PC-001", typ: "PC" as const, modell: "Dell OptiPlex 7010", besitzer: "mhuber" },
{ hostname: "NB-001", typ: "NOTEBOOK" as const, modell: "Lenovo T14", besitzer: "jmuellerluedensch" },
{ hostname: "SRV-DC01", typ: "SERVER" as const, modell: "Dell PowerEdge R450" },
{ bezeichnung: "Serverschrank Keller", typ: "SONSTIGES" as const },
{ hostname: "PRN-EG-01", typ: "DRUCKER" as const, modell: "Kyocera ECOSYS" },
];
let neueGeraete = 0;
for (const geraet of GERAETE) {
// Seriennummern haben keine Leerzeichen – auch die erfundenen nicht.
const kennzeichen = (geraet.hostname ?? geraet.bezeichnung ?? "").replace(/\s+/gu, "-").toUpperCase();
const vorhanden = await prisma.geraet.findFirst({
where: geraet.hostname ? { hostname: geraet.hostname } : { bezeichnung: geraet.bezeichnung },
});
if (vorhanden) continue;
const besitzer = geraet.besitzer
? await prisma.mitarbeiter.findFirst({ where: { sam: geraet.besitzer }, select: { id: true } })
: null;
await geraetAnlegen(
{
...(geraet.hostname ? { hostname: geraet.hostname } : {}),
...(geraet.bezeichnung ? { bezeichnung: geraet.bezeichnung } : {}),
...(geraet.modell ? { modell: geraet.modell } : {}),
...(besitzer ? { mitarbeiterId: besitzer.id } : {}),
typ: geraet.typ,
seriennummer: `SN-${kennzeichen}`,
},
handelnder,
);
neueGeraete += 1;
}
console.log(
`Beispiel-Stammdaten: ${neueAbteilungen} Abteilungen, ${neuePersonen} Personen und ` +
`${neueGeraete} Geräte neu angelegt.`,
);
await prisma.$disconnect();
+27
View File
@@ -98,6 +98,33 @@ const SEITEN: readonly Seite[] = [
await tab.waitForURL(/\/vorlagen\/version\/[^/]+$/);
},
},
{ name: "mitarbeiter-liste", pfad: "/mitarbeiter" },
{
name: "mitarbeiter-seite",
pfad: "/mitarbeiter",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /Maria Huber/ })
.first()
.click();
await tab.waitForURL(/\/mitarbeiter\/[^/]+$/);
},
},
{
// Die gesperrten Stammdaten einer Person aus dem Verzeichnis – der Fall,
// den man sonst erst nach dem ersten LDAP-Abgleich zu sehen bekaeme.
name: "mitarbeiter-verzeichnis",
pfad: "/mitarbeiter",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /Katrin Winter/ })
.first()
.click();
await tab.waitForURL(/\/mitarbeiter\/[^/]+$/);
},
},
{ name: "geraete-liste", pfad: "/geraete" },
{ name: "abteilungen", pfad: "/admin/abteilungen" },
];
function argument(name: string): string | undefined {