feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
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:
co-authored by
Claude Opus 5
parent
e3d80e5982
commit
86d55917f9
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user