feat(mitarbeiter): Vorgesetzter auch fuer Personen ohne Verzeichnis (#43)
This commit was merged in pull request #43.
This commit is contained in:
@@ -9,6 +9,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
|
- Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
|
||||||
|
sich vorher nirgends speichern ließ (#020)
|
||||||
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
|
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
|
||||||
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
|
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
|
||||||
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
|
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
|
||||||
@@ -24,6 +26,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Vorgesetzter auch für von Hand angelegte Personen.** Die Zuordnung lief allein über die Kennung
|
||||||
|
des Verzeichnisses, und die ist nur innerhalb einer Verzeichnisquelle eindeutig: wer eine Person
|
||||||
|
selbst eingetragen hatte, konnte ihr keinen Vorgesetzten geben, und `{{vorgesetzter_sam}}` blieb im
|
||||||
|
Onboarding-Befehl leer — genau dort, wo er gebraucht wird. Jetzt trägt jede Person eine gepflegte
|
||||||
|
Zuordnung, die sich auf ihrer Seite setzen und entfernen lässt; der Abgleich schreibt sie aus dem
|
||||||
|
Verzeichnis mit, und liefert das Verzeichnis keinen Vorgesetzten, bleibt eine von Hand gesetzte
|
||||||
|
Zuordnung stehen. Bestehende Zuordnungen nimmt die Migration mit (#020)
|
||||||
- Mitgelieferte Vorlagen tragen einen **stabilen Schlüssel** (`seedSchluessel`). Der Seed erkennt
|
- Mitgelieferte Vorlagen tragen einen **stabilen Schlüssel** (`seedSchluessel`). Der Seed erkennt
|
||||||
seine sieben Vorlagen daran statt am Namen — wer eine umbenannte, bekam vorher beim nächsten Lauf
|
seine sieben Vorlagen daran statt am Namen — wer eine umbenannte, bekam vorher beim nächsten Lauf
|
||||||
eine zweite, samt doppeltem Schnellstart auf der Geräteseite. Eine bestehende Installation bekommt
|
eine zweite, samt doppeltem Schnellstart auf der Geräteseite. Eine bestehende Installation bekommt
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #020 Vorgesetzter auch für von Hand angelegte Personen
|
# #020 Vorgesetzter auch für von Hand angelegte Personen
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#31](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/31) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
|
**Im Tracker:** [#31](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/31) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend
|
**Labels:** phase-2, backend
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 1 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -26,17 +26,34 @@ Feld — `{{vorgesetzter_sam}}` bleibt dort immer leer.
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Entscheiden: eigene Selbstbeziehung `vorgesetzterId` (sauber, aber zwei Wege zur selben
|
- [x] **Entschieden: eigene Selbstbeziehung `vorgesetzterId`** — und die befürchteten „zwei Wege zur
|
||||||
Auskunft) **oder** `vorgesetzterExterneId` auch im Bereich „ohne Quelle“ auflösen (`quelleId
|
selben Auskunft“ gibt es nicht, weil der Abgleich sie mitschreibt. `vorgesetzterExterneId`
|
||||||
IS NULL`), mit stabilen Kennungen für manuelle Personen
|
bleibt damit wie `abteilung` nur der rohe Wert des Verzeichnisses; gelesen wird allein
|
||||||
- [ ] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
|
`vorgesetzterId`. Die Alternative hätte manuellen Personen eine erfundene `externeId` geben
|
||||||
- [ ] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
|
müssen, und `externeId` heißt „objectGUID aus dem Verzeichnis“ — das wäre eine Lüge im
|
||||||
- [ ] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
|
Datenmodell gewesen
|
||||||
|
- [x] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
|
||||||
|
- [x] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
|
||||||
|
- [x] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
|
||||||
liefert — und keine Schleife zulassen (A ist Vorgesetzter von B ist Vorgesetzter von A)
|
liefert — und keine Schleife zulassen (A ist Vorgesetzter von B ist Vorgesetzter von A)
|
||||||
- [ ] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
|
- [x] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
|
- [x] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
|
||||||
- [ ] Test: die Zuordnung gilt nur innerhalb desselben Kunden
|
- [x] Test: die Zuordnung gilt nur innerhalb desselben Kunden
|
||||||
- [ ] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
|
- [x] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **Die Migration führt bestehende Daten nach.** Ohne das wäre `{{vorgesetzter_sam}}` nach dem
|
||||||
|
Einspielen bei **jeder** Person aus dem Verzeichnis leer gewesen, bis der nächste Abgleich läuft —
|
||||||
|
und zwar still. Dieselbe Falle wie der Seed-Schlüssel in
|
||||||
|
[#021](021-stabiler-schluessel-vorlagen.md): ein Umstieg, der die vorhandenen Daten nicht mitnimmt.
|
||||||
|
An einem nachgebauten Vor-Zustand in der Entwicklungsdatenbank gefahren.
|
||||||
|
- **Der Seed setzt die Zuordnung nur für Personen, die derselbe Lauf angelegt hat.** Eine leere
|
||||||
|
Zuordnung sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde; ein zweiter Lauf
|
||||||
|
darf nichts zurückdrehen.
|
||||||
|
- **Zwei Schleifen-Prüfungen, nicht eine.** Die Datenbank fängt die kürzeste ab
|
||||||
|
(`vorgesetzterId <> id`), längere brauchen den Gang nach oben im Dienst — mit Obergrenze, damit
|
||||||
|
eine schon beschädigte Kette die Suche nicht ewig laufen lässt.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
|
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
|
||||||
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
|
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
|
||||||
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min |
|
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min |
|
||||||
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
|
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | erledigt | v0.2.0 | 3 h | 1 h 20 min |
|
||||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
|
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
|
||||||
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
|
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
|
||||||
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
|
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
|
||||||
@@ -32,4 +32,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
||||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||||
| | **Summe** | | | **153 h** | **37 h 00 min** |
|
| | **Summe** | | | **153 h** | **38 h 20 min** |
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||||
|
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||||
|
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||||
|
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||||
|
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||||
|
--
|
||||||
|
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||||
|
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Vorgesetzter auch fuer von Hand angelegte Personen (#020).
|
||||||
|
--
|
||||||
|
-- Bisher lief die Auskunft allein ueber `vorgesetzterExterneId`, und die ist nur
|
||||||
|
-- innerhalb einer Verzeichnisquelle eindeutig: eine Person ohne Quelle konnte
|
||||||
|
-- keinen Vorgesetzten haben, {{vorgesetzter_sam}} blieb dort immer leer.
|
||||||
|
ALTER TABLE "Mitarbeiter"
|
||||||
|
ADD COLUMN "vorgesetzterId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "Mitarbeiter_vorgesetzterId_idx" ON "Mitarbeiter"("vorgesetzterId");
|
||||||
|
|
||||||
|
ALTER TABLE "Mitarbeiter"
|
||||||
|
ADD CONSTRAINT "Mitarbeiter_vorgesetzterId_fkey"
|
||||||
|
FOREIGN KEY ("vorgesetzterId") REFERENCES "Mitarbeiter" ("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Die Mandantengrenze, wie bei Lauf und Geraet: der Vorgesetzte gehoert zum
|
||||||
|
-- selben Kunden. MATCH SIMPLE ist richtig — ist die Spalte NULL, greift der
|
||||||
|
-- Fremdschluessel nicht, eine Person ohne Vorgesetzten bleibt also erlaubt.
|
||||||
|
ALTER TABLE "Mitarbeiter"
|
||||||
|
ADD CONSTRAINT "Mitarbeiter_vorgesetzter_gleicher_kunde"
|
||||||
|
FOREIGN KEY ("vorgesetzterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Niemand ist sein eigener Vorgesetzter. Das ist die kuerzeste Schleife und die
|
||||||
|
-- einzige, die sich ohne Rekursion abfangen laesst; laengere haelt der Dienst
|
||||||
|
-- auf (siehe mitarbeiter.ts, vorgesetztenSetzen).
|
||||||
|
ALTER TABLE "Mitarbeiter"
|
||||||
|
ADD CONSTRAINT "Mitarbeiter_vorgesetzter_nicht_selbst"
|
||||||
|
CHECK ("vorgesetzterId" IS NULL OR "vorgesetzterId" <> "id");
|
||||||
|
|
||||||
|
-- Nachfuehrung fuer bestehende Installationen.
|
||||||
|
--
|
||||||
|
-- Ohne sie waere `{{vorgesetzter_sam}}` nach dieser Migration bei **jeder**
|
||||||
|
-- Person aus dem Verzeichnis leer, bis der naechste Abgleich laeuft -- und zwar
|
||||||
|
-- still: die Auskunft lief bisher allein ueber `vorgesetzterExterneId`, der neue
|
||||||
|
-- Leseweg nimmt nur `vorgesetzterId`. Dieselbe Falle wie beim Seed-Schluessel
|
||||||
|
-- in #021: ein Umstieg, der die vorhandenen Daten nicht mitnimmt.
|
||||||
|
--
|
||||||
|
-- Die Zuordnung laeuft ueber die `externeId` **derselben** Quelle, genau wie der
|
||||||
|
-- alte Leseweg: die Kennung ist nur dort eindeutig.
|
||||||
|
UPDATE "Mitarbeiter" AS person
|
||||||
|
SET "vorgesetzterId" = chef."id"
|
||||||
|
FROM "Mitarbeiter" AS chef
|
||||||
|
WHERE person."vorgesetzterExterneId" IS NOT NULL
|
||||||
|
AND person."quelleId" IS NOT NULL
|
||||||
|
AND chef."quelleId" = person."quelleId"
|
||||||
|
AND chef."externeId" = person."vorgesetzterExterneId"
|
||||||
|
-- Die Mandantengrenze gilt auch hier; stand je eine Zuordnung ueber Kunden
|
||||||
|
-- hinweg in den Daten, bliebe sie leer statt den Fremdschluessel zu brechen.
|
||||||
|
AND chef."kundeId" = person."kundeId"
|
||||||
|
AND chef."id" <> person."id";
|
||||||
+18
-4
@@ -249,17 +249,30 @@ model Mitarbeiter {
|
|||||||
/// passende Abteilung bleibt trotzdem erhalten.
|
/// passende Abteilung bleibt trotzdem erhalten.
|
||||||
abteilung String?
|
abteilung String?
|
||||||
titel String?
|
titel String?
|
||||||
|
/// Der rohe `manager`-Wert aus dem Verzeichnis, aufgeloest auf die `externeId`
|
||||||
|
/// des Vorgesetzten — je Quelle eindeutig. Wie `abteilung` ist das der Wert
|
||||||
|
/// des Verzeichnisses, nicht die gepflegte Zuordnung; die steht in
|
||||||
|
/// `vorgesetzterId`. Leer bei einer von Hand angelegten Person.
|
||||||
vorgesetzterExterneId String?
|
vorgesetzterExterneId String?
|
||||||
|
/// Die **gepflegte** Zuordnung, und der einzige Leseweg (#020). Der Abgleich
|
||||||
|
/// schreibt sie aus `vorgesetzterExterneId` mit, von Hand gesetzt geht sie
|
||||||
|
/// auch ohne Verzeichnis. Darf nicht auf die Person selbst zeigen (CHECK) und
|
||||||
|
/// nicht auf jemanden eines anderen Kunden (zusammengesetzter Fremdschluessel
|
||||||
|
/// `Mitarbeiter_vorgesetzter_gleicher_kunde`). Laengere Schleifen haelt der
|
||||||
|
/// Dienst auf.
|
||||||
|
vorgesetzterId String?
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
herkunft Herkunft @default(MANUELL)
|
herkunft Herkunft @default(MANUELL)
|
||||||
zuletztGesehenAm DateTime?
|
zuletztGesehenAm DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @default(now()) @updatedAt
|
geaendertAm DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||||
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
|
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
|
||||||
geraete Geraet[]
|
vorgesetzter Mitarbeiter? @relation("Vorgesetzter", fields: [vorgesetzterId], references: [id], onDelete: Restrict)
|
||||||
laeufe Lauf[]
|
untergebene Mitarbeiter[] @relation("Vorgesetzter")
|
||||||
|
geraete Geraet[]
|
||||||
|
laeufe Lauf[]
|
||||||
|
|
||||||
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
|
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
|
||||||
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
|
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
|
||||||
@@ -270,6 +283,7 @@ model Mitarbeiter {
|
|||||||
@@index([abteilung])
|
@@index([abteilung])
|
||||||
@@index([upn])
|
@@index([upn])
|
||||||
@@index([sam])
|
@@index([sam])
|
||||||
|
@@index([vorgesetzterId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
|
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export type PersonenVorgabe = {
|
|||||||
readonly upn: string;
|
readonly upn: string;
|
||||||
readonly abteilung: string;
|
readonly abteilung: string;
|
||||||
readonly titel: string;
|
readonly titel: string;
|
||||||
|
/** UPN des Vorgesetzten – aufgeloest, nachdem alle Personen stehen. */
|
||||||
|
readonly vorgesetzterUpn?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,10 +66,11 @@ export type PersonenVorgabe = {
|
|||||||
* Haus ohne Verzeichnis entstehen.
|
* Haus ohne Verzeichnis entstehen.
|
||||||
*
|
*
|
||||||
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
|
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
|
||||||
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei; **gespeichert wird
|
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei — **und die Zuordnung
|
||||||
* die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId`
|
* wird gespeichert**, seit `vorgesetzterId` es erlaubt (#020). Vorher ging das
|
||||||
* derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides
|
* nicht: die Auskunft lief allein ueber `vorgesetzterExterneId`, und die zeigt
|
||||||
* nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt.
|
* auf die `externeId` derselben Verzeichnisquelle, die eine von Hand angelegte
|
||||||
|
* Person nicht hat.
|
||||||
*/
|
*/
|
||||||
export const PERSONEN: readonly PersonenVorgabe[] = [
|
export const PERSONEN: readonly PersonenVorgabe[] = [
|
||||||
{
|
{
|
||||||
@@ -77,6 +80,7 @@ export const PERSONEN: readonly PersonenVorgabe[] = [
|
|||||||
upn: "m.huber@firma.example",
|
upn: "m.huber@firma.example",
|
||||||
abteilung: "Buchhaltung",
|
abteilung: "Buchhaltung",
|
||||||
titel: "Sachbearbeiterin Rechnungsprüfung",
|
titel: "Sachbearbeiterin Rechnungsprüfung",
|
||||||
|
vorgesetzterUpn: "p.klein@firma.example",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
vorname: "Petra",
|
vorname: "Petra",
|
||||||
@@ -230,6 +234,8 @@ export async function stammdatenSaeen(
|
|||||||
|
|
||||||
let personen = 0;
|
let personen = 0;
|
||||||
const nachUpn = new Map<string, string>();
|
const nachUpn = new Map<string, string>();
|
||||||
|
/** Nur diese bekommen eine Vorgesetzten-Zuordnung – siehe unten. */
|
||||||
|
const neuAngelegt = new Set<string>();
|
||||||
for (const vorgabe of PERSONEN) {
|
for (const vorgabe of PERSONEN) {
|
||||||
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
|
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
|
||||||
if (vorhanden) {
|
if (vorhanden) {
|
||||||
@@ -252,9 +258,27 @@ export async function stammdatenSaeen(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
nachUpn.set(vorgabe.upn, angelegt.id);
|
nachUpn.set(vorgabe.upn, angelegt.id);
|
||||||
|
neuAngelegt.add(vorgabe.upn);
|
||||||
personen += 1;
|
personen += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Vorgesetzten erst jetzt, wenn alle Personen stehen: eine Zuordnung
|
||||||
|
* zeigt auf eine Zeile, die beim Anlegen der ersten noch nicht da war.
|
||||||
|
*
|
||||||
|
* Nur fuer Personen, die **dieser Lauf** angelegt hat. Eine leere Zuordnung
|
||||||
|
* sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde — und ein
|
||||||
|
* zweiter Seed-Lauf darf nichts zurueckdrehen. Dieselbe Regel wie fuer alles
|
||||||
|
* andere hier.
|
||||||
|
*/
|
||||||
|
for (const vorgabe of PERSONEN) {
|
||||||
|
if (!vorgabe.vorgesetzterUpn || !neuAngelegt.has(vorgabe.upn)) continue;
|
||||||
|
const personId = nachUpn.get(vorgabe.upn);
|
||||||
|
const vorgesetzterId = nachUpn.get(vorgabe.vorgesetzterUpn);
|
||||||
|
if (!personId || !vorgesetzterId) continue;
|
||||||
|
await db.mitarbeiter.update({ where: { id: personId }, data: { vorgesetzterId } });
|
||||||
|
}
|
||||||
|
|
||||||
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
|
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
|
||||||
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
|
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
|
||||||
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
|
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import { notFound } from "next/navigation";
|
|||||||
import { ArrowLeft, Laptop } from "lucide-react";
|
import { ArrowLeft, Laptop } from "lucide-react";
|
||||||
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
|
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
|
||||||
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
|
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
|
||||||
|
import { MitarbeiterVorgesetzter } from "@/components/mitarbeiter-vorgesetzter";
|
||||||
import { Herkunft } from "@/generated/prisma/enums";
|
import { Herkunft } from "@/generated/prisma/enums";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { kundenEbeneSichtbar } from "@/server/mandant";
|
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||||
import { geraeteDesMitarbeiters, mitarbeiterHolen, vorgesetzterVon } from "@/server/queries/mitarbeiter";
|
import {
|
||||||
|
geraeteDesMitarbeiters,
|
||||||
|
mitarbeiterHolen,
|
||||||
|
moeglicheVorgesetzte,
|
||||||
|
vorgesetzterVon,
|
||||||
|
} from "@/server/queries/mitarbeiter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Seite einer Person (#011).
|
* Die Seite einer Person (#011).
|
||||||
@@ -26,10 +32,13 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
|
|||||||
if (!person) notFound();
|
if (!person) notFound();
|
||||||
|
|
||||||
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
|
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
|
||||||
const [geraete, kundenEbene, vorgesetzter] = await Promise.all([
|
const [geraete, kundenEbene, vorgesetzter, auswahl] = await Promise.all([
|
||||||
geraeteDesMitarbeiters(person.id, benutzer),
|
geraeteDesMitarbeiters(person.id, benutzer),
|
||||||
kundenEbeneSichtbar(),
|
kundenEbeneSichtbar(),
|
||||||
vorgesetzterVon(person.id, benutzer),
|
vorgesetzterVon(person.id, benutzer),
|
||||||
|
// Nur holen, wer sie auch braucht: fuer einen Leser ist die Liste aller
|
||||||
|
// Kolleginnen und Kollegen eine Abfrage ohne Zweck.
|
||||||
|
darfBearbeiten ? moeglicheVorgesetzte(person.id, benutzer) : [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,8 +69,14 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{darfBearbeiten ? (
|
{darfBearbeiten ? (
|
||||||
<div className="mt-6">
|
<div className="mt-6 flex flex-col gap-6">
|
||||||
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
|
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
|
||||||
|
<MitarbeiterVorgesetzter
|
||||||
|
mitarbeiterId={person.id}
|
||||||
|
vorgesetzterId={vorgesetzter?.id ?? null}
|
||||||
|
auswahl={auswahl}
|
||||||
|
ausDemVerzeichnis={person.herkunft === Herkunft.ABGLEICH}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { vorgesetztenSetzenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorgesetzten setzen oder entfernen (#020).
|
||||||
|
*
|
||||||
|
* Ein Auswahlfeld und ein Knopf, kein eigener Bearbeiten-Modus: die Zuordnung
|
||||||
|
* ist eine einzelne Angabe, und sie zu aendern soll nicht erst ein Formular
|
||||||
|
* aufklappen.
|
||||||
|
*
|
||||||
|
* Die leere Option heisst ausdruecklich „niemand“ und nicht „—“: sie ist eine
|
||||||
|
* Angabe, nicht das Fehlen einer. Wer sie waehlt und speichert, entfernt die
|
||||||
|
* Zuordnung.
|
||||||
|
*/
|
||||||
|
export function MitarbeiterVorgesetzter({
|
||||||
|
mitarbeiterId,
|
||||||
|
vorgesetzterId,
|
||||||
|
auswahl,
|
||||||
|
ausDemVerzeichnis,
|
||||||
|
}: {
|
||||||
|
mitarbeiterId: string;
|
||||||
|
vorgesetzterId: string | null;
|
||||||
|
auswahl: readonly { readonly id: string; readonly anzeigename: string }[];
|
||||||
|
ausDemVerzeichnis: boolean;
|
||||||
|
}) {
|
||||||
|
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorgesetztenSetzenAktion, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form action={aktion} className="flex flex-wrap items-end gap-2">
|
||||||
|
<input type="hidden" name="mitarbeiterId" value={mitarbeiterId} />
|
||||||
|
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm">
|
||||||
|
<span className="text-nebentext">Vorgesetzter</span>
|
||||||
|
<select
|
||||||
|
name="vorgesetzterId"
|
||||||
|
defaultValue={vorgesetzterId ?? ""}
|
||||||
|
className="min-h-11 w-full rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
|
||||||
|
>
|
||||||
|
<option value="">niemand</option>
|
||||||
|
{auswahl.map((person) => (
|
||||||
|
<option key={person.id} value={person.id}>
|
||||||
|
{person.anzeigename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ausDemVerzeichnis ? (
|
||||||
|
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||||
|
Diese Person kommt aus dem Verzeichnis. Liefert es einen Vorgesetzten, setzt der nächste Abgleich
|
||||||
|
die Zuordnung dort wieder nach seinem Stand; liefert es keinen, bleibt die hier gesetzte Zuordnung
|
||||||
|
stehen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.erfolg ? (
|
||||||
|
<p role="status" className="mt-2 text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
mitarbeiterAnlegen,
|
mitarbeiterAnlegen,
|
||||||
mitarbeiterBearbeiten,
|
mitarbeiterBearbeiten,
|
||||||
mitarbeiterImportieren,
|
mitarbeiterImportieren,
|
||||||
|
vorgesetztenSetzen,
|
||||||
type MitarbeiterImportAbweisung,
|
type MitarbeiterImportAbweisung,
|
||||||
} from "@/server/services/mitarbeiter";
|
} from "@/server/services/mitarbeiter";
|
||||||
import type { HandelnderBenutzer } from "@/server/mandant";
|
import type { HandelnderBenutzer } from "@/server/mandant";
|
||||||
@@ -161,6 +162,40 @@ export async function mitarbeiterAktivSetzenAktion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorgesetzten setzen oder entfernen (#020).
|
||||||
|
*
|
||||||
|
* Ein leeres Feld entfernt die Zuordnung — deshalb `optional` und nicht
|
||||||
|
* `min(1)`: „niemand“ ist hier eine Angabe und kein Versehen.
|
||||||
|
*/
|
||||||
|
export async function vorgesetztenSetzenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
const zugang = await angemeldetOderMeldung();
|
||||||
|
if ("fehler" in zugang) return zugang;
|
||||||
|
|
||||||
|
const eingabe = z
|
||||||
|
.object({ mitarbeiterId: z.string().min(1), vorgesetzterId: z.string().optional() })
|
||||||
|
.safeParse({
|
||||||
|
mitarbeiterId: formular.get("mitarbeiterId"),
|
||||||
|
vorgesetzterId: formular.get("vorgesetzterId") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await vorgesetztenSetzen(
|
||||||
|
eingabe.data.mitarbeiterId,
|
||||||
|
eingabe.data.vorgesetzterId ?? null,
|
||||||
|
zugang.benutzer,
|
||||||
|
);
|
||||||
|
revalidatePath(`/mitarbeiter/${eingabe.data.mitarbeiterId}`);
|
||||||
|
return { erfolg: eingabe.data.vorgesetzterId ? "Gespeichert." : "Vorgesetzter entfernt." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Personen aus einer CSV-Datei einspielen (#016).
|
* Personen aus einer CSV-Datei einspielen (#016).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -174,11 +174,15 @@ export async function geraeteDesMitarbeiters(
|
|||||||
/**
|
/**
|
||||||
* Der Vorgesetzte einer Person – oder `undefined`.
|
* Der Vorgesetzte einer Person – oder `undefined`.
|
||||||
*
|
*
|
||||||
* Die Verknuepfung laeuft ueber `vorgesetzterExterneId` und die `externeId`
|
* Gelesen wird **allein** `vorgesetzterId`: die gepflegte Zuordnung, die der
|
||||||
* **derselben Verzeichnisquelle**: die Kennung ist nur innerhalb einer Quelle
|
* Abgleich aus dem Verzeichnis mitschreibt und die sich auch von Hand setzen
|
||||||
* eindeutig, und ohne diese Einschraenkung zoege ein zweites Verzeichnis
|
* laesst (#020). Vorher lief die Auskunft ueber `vorgesetzterExterneId` und die
|
||||||
* fremde Treffer herbei. Gefuellt wird das Feld vom Abgleich (#016); bei einer
|
* `externeId` derselben Quelle — richtig fuer Verzeichnis-Personen, aber eine
|
||||||
* von Hand angelegten Person steht hier nichts, und das ist richtig so.
|
* von Hand angelegte Person konnte so keinen Vorgesetzten haben.
|
||||||
|
*
|
||||||
|
* Die Mandantensicht wird trotzdem noch angewandt, obwohl ein zusammengesetzter
|
||||||
|
* Fremdschluessel dieselbe Grenze haelt: die Rollensicht des Benutzers ist eine
|
||||||
|
* andere Frage als die Struktur der Daten.
|
||||||
*/
|
*/
|
||||||
export async function vorgesetzterVon(
|
export async function vorgesetzterVon(
|
||||||
mitarbeiterId: string,
|
mitarbeiterId: string,
|
||||||
@@ -186,22 +190,45 @@ export async function vorgesetzterVon(
|
|||||||
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
|
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
|
||||||
const person = await prisma.mitarbeiter.findFirst({
|
const person = await prisma.mitarbeiter.findFirst({
|
||||||
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
|
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
|
||||||
select: { quelleId: true, vorgesetzterExterneId: true },
|
select: { vorgesetzterId: true },
|
||||||
});
|
});
|
||||||
if (!person?.vorgesetzterExterneId || !person.quelleId) return undefined;
|
if (!person?.vorgesetzterId) return undefined;
|
||||||
|
|
||||||
const vorgesetzter = await prisma.mitarbeiter.findFirst({
|
const vorgesetzter = await prisma.mitarbeiter.findFirst({
|
||||||
where: {
|
where: { AND: [{ id: person.vorgesetzterId }, ...(await kundenBedingung(benutzer))] },
|
||||||
AND: [
|
|
||||||
{ quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
|
|
||||||
...(await kundenBedingung(benutzer)),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: { id: true, anzeigename: true },
|
select: { id: true, anzeigename: true },
|
||||||
});
|
});
|
||||||
return vorgesetzter ?? undefined;
|
return vorgesetzter ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wen man als Vorgesetzten waehlen kann (#020).
|
||||||
|
*
|
||||||
|
* Nur **aktive** Personen desselben Kunden, und die Person selbst nicht. Die
|
||||||
|
* eigene Zeile und ein fremder Kunde fielen sonst erst im Dienst auf; eine
|
||||||
|
* Auswahlliste, die unmoegliche Eintraege anbietet, ist eine Falle.
|
||||||
|
*
|
||||||
|
* Laengere Schleifen werden hier **nicht** herausgefiltert: dafuer muesste die
|
||||||
|
* Liste je Eintrag die Kette hinauflaufen. Der Dienst weist sie ab, mit einem
|
||||||
|
* Satz, der sagt warum.
|
||||||
|
*/
|
||||||
|
export async function moeglicheVorgesetzte(
|
||||||
|
mitarbeiterId: string,
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
): Promise<readonly { readonly id: string; readonly anzeigename: string }[]> {
|
||||||
|
const person = await prisma.mitarbeiter.findFirst({
|
||||||
|
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
|
||||||
|
select: { kundeId: true },
|
||||||
|
});
|
||||||
|
if (!person) return [];
|
||||||
|
|
||||||
|
return prisma.mitarbeiter.findMany({
|
||||||
|
where: { AND: [{ kundeId: person.kundeId, aktiv: true, id: { not: mitarbeiterId } }] },
|
||||||
|
select: { id: true, anzeigename: true },
|
||||||
|
orderBy: { anzeigename: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Die vorkommenden Abteilungsnamen – fuer den Filter. */
|
/** Die vorkommenden Abteilungsnamen – fuer den Filter. */
|
||||||
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
|
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
|
||||||
const zeilen = await prisma.mitarbeiter.findMany({
|
const zeilen = await prisma.mitarbeiter.findMany({
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
|||||||
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
||||||
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
||||||
vorgesetzterExterneId: null,
|
vorgesetzterExterneId: null,
|
||||||
|
vorgesetzterId: null,
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
herkunft: Herkunft.ABGLEICH,
|
herkunft: Herkunft.ABGLEICH,
|
||||||
zuletztGesehenAm: beginn,
|
zuletztGesehenAm: beginn,
|
||||||
@@ -365,7 +366,19 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
|||||||
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
||||||
// Kennung.
|
// Kennung.
|
||||||
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
||||||
...(vorgesetztenLeeren ? { vorgesetzterExterneId: null } : {}),
|
/*
|
||||||
|
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
|
||||||
|
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
|
||||||
|
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
|
||||||
|
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
|
||||||
|
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
|
||||||
|
*/
|
||||||
|
...(vorgesetztenLeeren
|
||||||
|
? {
|
||||||
|
vorgesetzterExterneId: null,
|
||||||
|
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
||||||
// wiedereingestellte Person behaelt so ihre Laeufe.
|
// wiedereingestellte Person behaelt so ihre Laeufe.
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
@@ -386,12 +399,24 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
|||||||
gelesen = bilanz.gesamtGelesen;
|
gelesen = bilanz.gesamtGelesen;
|
||||||
|
|
||||||
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
||||||
|
//
|
||||||
|
// Geschrieben werden beide Felder: `vorgesetzterExterneId` als roher Wert des
|
||||||
|
// Verzeichnisses, `vorgesetzterId` als die gepflegte Zuordnung, aus der
|
||||||
|
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
|
||||||
|
// Hand angelegte Person konnte keinen Vorgesetzten haben.
|
||||||
for (const offen of offene) {
|
for (const offen of offene) {
|
||||||
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
|
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
|
||||||
if (kennung === offen.vorherigeKennung) continue;
|
if (kennung === offen.vorherigeKennung) continue;
|
||||||
|
const vorgesetzter =
|
||||||
|
kennung === null
|
||||||
|
? null
|
||||||
|
: await prisma.mitarbeiter.findUnique({
|
||||||
|
where: { quelleId_externeId: { quelleId, externeId: kennung } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
await prisma.mitarbeiter.updateMany({
|
await prisma.mitarbeiter.updateMany({
|
||||||
where: { quelleId, externeId: offen.externeId },
|
where: { quelleId, externeId: offen.externeId },
|
||||||
data: { vorgesetzterExterneId: kennung },
|
data: { vorgesetzterExterneId: kennung, vorgesetzterId: vorgesetzter?.id ?? null },
|
||||||
});
|
});
|
||||||
// Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
|
// Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
|
||||||
// aktualisiert.
|
// aktualisiert.
|
||||||
|
|||||||
@@ -212,6 +212,94 @@ export async function mitarbeiterBearbeiten(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt oder entfernt den Vorgesetzten.
|
||||||
|
*
|
||||||
|
* Eigener Weg und nicht Teil von `mitarbeiterBearbeiten`, weil es kein
|
||||||
|
* abgeglichenes Feld im Sinn der Sperre ist: eine Person aus dem Verzeichnis
|
||||||
|
* darf hier geaendert werden. Der naechste Abgleich setzt die Zuordnung
|
||||||
|
* allerdings zurueck, wenn das Verzeichnis einen `manager` liefert — dort ist
|
||||||
|
* sie dann gepflegt. Liefert es keinen, bleibt die Zuordnung von Hand stehen
|
||||||
|
* (#020).
|
||||||
|
*
|
||||||
|
* `vorgesetzterExterneId` bleibt unangetastet: das ist der rohe Wert des
|
||||||
|
* Verzeichnisses, nicht die Zuordnung.
|
||||||
|
*/
|
||||||
|
export async function vorgesetztenSetzen(
|
||||||
|
mitarbeiterId: string,
|
||||||
|
vorgesetzterId: string | null,
|
||||||
|
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 gewuenscht = vorgesetzterId === null || vorgesetzterId === "" ? null : vorgesetzterId;
|
||||||
|
if (gewuenscht === vorher.vorgesetzterId) return vorher;
|
||||||
|
|
||||||
|
let name = "niemand";
|
||||||
|
if (gewuenscht !== null) {
|
||||||
|
if (gewuenscht === mitarbeiterId) {
|
||||||
|
throw new MitarbeiterFehler("Niemand ist sein eigener Vorgesetzter.");
|
||||||
|
}
|
||||||
|
const kuenftig = await prisma.mitarbeiter.findUnique({ where: { id: gewuenscht } });
|
||||||
|
if (!kuenftig) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
|
||||||
|
// Dieselbe Pruefung haelt der zusammengesetzte Fremdschluessel fest; hier
|
||||||
|
// steht sie fuer die Meldung.
|
||||||
|
if (kuenftig.kundeId !== vorher.kundeId) {
|
||||||
|
throw new MitarbeiterFehler("Der Vorgesetzte muss zum selben Kunden gehören.");
|
||||||
|
}
|
||||||
|
await schleifePruefen(mitarbeiterId, gewuenscht);
|
||||||
|
name = kuenftig.anzeigename;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const nachher = await tx.mitarbeiter.update({
|
||||||
|
where: { id: mitarbeiterId },
|
||||||
|
data: { vorgesetzterId: gewuenscht },
|
||||||
|
});
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Mitarbeiter",
|
||||||
|
entitaetId: mitarbeiterId,
|
||||||
|
aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`,
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
vorher: auszug(vorher, ["vorgesetzterId"]),
|
||||||
|
nachher: auszug(nachher, ["vorgesetzterId"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return nachher;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weist eine Schleife ab: A ist Vorgesetzter von B ist Vorgesetzter von A.
|
||||||
|
*
|
||||||
|
* Die Datenbank faengt nur die kuerzeste ab (`vorgesetzterId <> id`); laengere
|
||||||
|
* brauchen einen Gang nach oben. Die Obergrenze ist kein Schoenheitsfehler,
|
||||||
|
* sondern die Notbremse: waere die Kette schon beschaedigt, liefe die Suche
|
||||||
|
* sonst ewig.
|
||||||
|
*/
|
||||||
|
async function schleifePruefen(mitarbeiterId: string, kuenftigerVorgesetzter: string): Promise<void> {
|
||||||
|
const gesehen = new Set<string>([mitarbeiterId]);
|
||||||
|
let aktuell: string | null = kuenftigerVorgesetzter;
|
||||||
|
|
||||||
|
for (let schritt = 0; schritt < 100 && aktuell !== null; schritt += 1) {
|
||||||
|
if (gesehen.has(aktuell)) {
|
||||||
|
throw new MitarbeiterFehler(
|
||||||
|
"Das ergäbe eine Schleife: diese Person ist dem künftigen Vorgesetzten " +
|
||||||
|
"bereits selbst vorgesetzt, direkt oder über weitere Personen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gesehen.add(aktuell);
|
||||||
|
const naechster: { readonly vorgesetzterId: string | null } | null = await prisma.mitarbeiter.findUnique({
|
||||||
|
where: { id: aktuell },
|
||||||
|
select: { vorgesetzterId: true },
|
||||||
|
});
|
||||||
|
aktuell = naechster?.vorgesetzterId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deaktivieren und wieder aktivieren.
|
* Deaktivieren und wieder aktivieren.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<Pl
|
|||||||
titel: true,
|
titel: true,
|
||||||
dn: true,
|
dn: true,
|
||||||
quelleId: true,
|
quelleId: true,
|
||||||
vorgesetzterExterneId: true,
|
vorgesetzterId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
abteilung: { select: { name: true, zielOu: true, standardGruppen: true } },
|
abteilung: { select: { name: true, zielOu: true, standardGruppen: true } },
|
||||||
@@ -207,18 +207,18 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<Pl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Vorgesetzte, ueber `vorgesetzterExterneId` **derselben** Verzeichnisquelle.
|
* Der Vorgesetzte, ueber die gepflegte Zuordnung `vorgesetzterId`.
|
||||||
*
|
*
|
||||||
* Die Kennung ist nur innerhalb einer Quelle eindeutig; ohne diese
|
* Vorher lief das ueber `vorgesetzterExterneId` und die `externeId` derselben
|
||||||
* Einschraenkung zoege ein zweites Verzeichnis fremde Treffer herbei.
|
* Quelle. Dann blieb `{{vorgesetzter_sam}}` bei jeder von Hand angelegten Person
|
||||||
|
* leer — genau im Onboarding, wo der Befehl ihn braucht (#020).
|
||||||
*/
|
*/
|
||||||
async function vorgesetztenLesen(person: {
|
async function vorgesetztenLesen(person: {
|
||||||
readonly quelleId: string | null;
|
readonly vorgesetzterId: string | null;
|
||||||
readonly vorgesetzterExterneId: string | null;
|
|
||||||
}): Promise<{ anzeigename: string; sam: string | null } | null> {
|
}): Promise<{ anzeigename: string; sam: string | null } | null> {
|
||||||
if (!person.vorgesetzterExterneId || !person.quelleId) return null;
|
if (!person.vorgesetzterId) return null;
|
||||||
return prisma.mitarbeiter.findFirst({
|
return prisma.mitarbeiter.findUnique({
|
||||||
where: { quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
|
where: { id: person.vorgesetzterId },
|
||||||
select: { anzeigename: true, sam: true },
|
select: { anzeigename: true, sam: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,6 +408,36 @@ describe.runIf(datenbankVerfuegbar && ldapDa)("Abgleich gegen das Verzeichnis",
|
|||||||
expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN + 1);
|
expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loescht eine von Hand gesetzte Vorgesetzten-Zuordnung nicht", async () => {
|
||||||
|
// `kberger` traegt im Verzeichnis keinen `manager` — mhuber und jweiss tun
|
||||||
|
// es. Wer die Zuordnung fuer kberger von Hand setzt, pflegt etwas, das das
|
||||||
|
// Verzeichnis nicht fuehrt; also hat es sie auch nicht zu loeschen (#020).
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const quelle = await quelleFuerContainer(admin);
|
||||||
|
await abgleichAusfuehren(quelle.id, admin);
|
||||||
|
|
||||||
|
const ohneChef = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } });
|
||||||
|
const vonHand = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "jweiss" } });
|
||||||
|
expect(ohneChef.vorgesetzterExterneId).toBeNull();
|
||||||
|
expect(ohneChef.vorgesetzterId).toBeNull();
|
||||||
|
await testPrisma.mitarbeiter.update({
|
||||||
|
where: { id: ohneChef.id },
|
||||||
|
data: { vorgesetzterId: vonHand.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const zweiter = await abgleichAusfuehren(quelle.id, admin);
|
||||||
|
expect(zweiter.status).toBe("ERFOLG");
|
||||||
|
|
||||||
|
const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { id: ohneChef.id } });
|
||||||
|
expect(nachher.vorgesetzterId).toBe(vonHand.id);
|
||||||
|
|
||||||
|
// Gegenstueck: wo das Verzeichnis einen `manager` fuehrt, bestimmt es — und
|
||||||
|
// zwar in **beiden** Feldern, dem rohen Wert und der Zuordnung.
|
||||||
|
const mitChef = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
|
||||||
|
expect(mitChef.vorgesetzterId).toBe(ohneChef.id);
|
||||||
|
expect(mitChef.vorgesetzterExterneId).toBe(ohneChef.externeId);
|
||||||
|
});
|
||||||
|
|
||||||
it("setzt ein per userAccountControl deaktiviertes Konto auf aktiv = false", async () => {
|
it("setzt ein per userAccountControl deaktiviertes Konto auf aktiv = false", async () => {
|
||||||
const { admin } = await grundbestand();
|
const { admin } = await grundbestand();
|
||||||
const quelle = await quelleFuerContainer(admin);
|
const quelle = await quelleFuerContainer(admin);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
"Lauf_abteilung_gleicher_kunde",
|
"Lauf_abteilung_gleicher_kunde",
|
||||||
"Lauf_geraet_gleicher_kunde",
|
"Lauf_geraet_gleicher_kunde",
|
||||||
"Lauf_mitarbeiter_gleicher_kunde",
|
"Lauf_mitarbeiter_gleicher_kunde",
|
||||||
|
"Mitarbeiter_vorgesetzter_gleicher_kunde",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
"Lauf_archiviert_mit_zeitpunkt",
|
"Lauf_archiviert_mit_zeitpunkt",
|
||||||
"Lauf_begonnen_mit_zeitpunkt",
|
"Lauf_begonnen_mit_zeitpunkt",
|
||||||
"Lauf_mitarbeiter_eindeutig",
|
"Lauf_mitarbeiter_eindeutig",
|
||||||
|
"Mitarbeiter_vorgesetzter_nicht_selbst",
|
||||||
"Schrittergebnis_begruendung_noetig",
|
"Schrittergebnis_begruendung_noetig",
|
||||||
"TagCodeVergabe_form",
|
"TagCodeVergabe_form",
|
||||||
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
"Verzeichnisquelle_bindpasswort_verschluesselt",
|
||||||
|
|||||||
@@ -333,6 +333,21 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () =
|
|||||||
).rejects.toThrow(/Vorlage_seedSchluessel_einmalig/);
|
).rejects.toThrow(/Vorlage_seedSchluessel_einmalig/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("laesst niemanden sein eigener Vorgesetzter sein", async () => {
|
||||||
|
// Die kuerzeste Schleife, und die einzige, die sich ohne Rekursion
|
||||||
|
// abfangen laesst. Laengere haelt der Dienst auf (#020).
|
||||||
|
const kunde = await kundeAnlegen("VORG");
|
||||||
|
const person = await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
testPrisma.mitarbeiter.update({
|
||||||
|
where: { id: person.id },
|
||||||
|
data: { vorgesetzterId: person.id },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Mitarbeiter_vorgesetzter_nicht_selbst/);
|
||||||
|
});
|
||||||
|
|
||||||
it("laesst beliebig viele Vorlagen ohne Seed-Schluessel zu", async () => {
|
it("laesst beliebig viele Vorlagen ohne Seed-Schluessel zu", async () => {
|
||||||
// Der Index ist partiell, sonst waere die zweite eigene Vorlage eine
|
// Der Index ist partiell, sonst waere die zweite eigene Vorlage eine
|
||||||
// Dublette von null (#021).
|
// Dublette von null (#021).
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
mitarbeiterAktivSetzen,
|
mitarbeiterAktivSetzen,
|
||||||
mitarbeiterAnlegen,
|
mitarbeiterAnlegen,
|
||||||
mitarbeiterBearbeiten,
|
mitarbeiterBearbeiten,
|
||||||
|
vorgesetztenSetzen,
|
||||||
} from "@/server/services/mitarbeiter";
|
} from "@/server/services/mitarbeiter";
|
||||||
import {
|
import {
|
||||||
abteilungsNamen,
|
abteilungsNamen,
|
||||||
geraeteDesMitarbeiters,
|
geraeteDesMitarbeiters,
|
||||||
mitarbeiterHolen,
|
mitarbeiterHolen,
|
||||||
mitarbeiterListe,
|
mitarbeiterListe,
|
||||||
|
moeglicheVorgesetzte,
|
||||||
|
vorgesetzterVon,
|
||||||
} from "@/server/queries/mitarbeiter";
|
} from "@/server/queries/mitarbeiter";
|
||||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
@@ -40,6 +43,118 @@ describe.runIf(datenbankVerfuegbar)("Mitarbeiter", () => {
|
|||||||
await datenbankLeeren();
|
await datenbankLeeren();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Vorgesetzter", () => {
|
||||||
|
it("laesst sich fuer eine von Hand angelegte Person setzen", async () => {
|
||||||
|
// Der Fall, der vorher gar nicht ging: ohne Verzeichnisquelle gab es kein
|
||||||
|
// Feld fuer die Zuordnung (#020).
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
|
||||||
|
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
|
||||||
|
expect(maria.herkunft).toBe(Herkunft.MANUELL);
|
||||||
|
|
||||||
|
await vorgesetztenSetzen(maria.id, petra.id, admin);
|
||||||
|
expect(await vorgesetzterVon(maria.id, admin)).toEqual({
|
||||||
|
id: petra.id,
|
||||||
|
anzeigename: "Petra Klein",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entfernt die Zuordnung bei leerer Angabe", async () => {
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
|
||||||
|
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
|
||||||
|
await vorgesetztenSetzen(maria.id, petra.id, admin);
|
||||||
|
|
||||||
|
await vorgesetztenSetzen(maria.id, "", admin);
|
||||||
|
expect(await vorgesetzterVon(maria.id, admin)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst niemanden sein eigener Vorgesetzter sein", async () => {
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
|
||||||
|
await expect(vorgesetztenSetzen(maria.id, maria.id, admin)).rejects.toThrow(/eigener Vorgesetzter/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine Schleife ueber zwei Personen ab", async () => {
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin);
|
||||||
|
const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin);
|
||||||
|
await vorgesetztenSetzen(b.id, a.id, admin);
|
||||||
|
|
||||||
|
await expect(vorgesetztenSetzen(a.id, b.id, admin)).rejects.toThrow(/Schleife/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine Schleife ueber drei Personen ab", async () => {
|
||||||
|
// Die Datenbank faengt nur die kuerzeste ab; diese braucht den Gang nach
|
||||||
|
// oben im Dienst.
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin);
|
||||||
|
const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin);
|
||||||
|
const c = await mitarbeiterAnlegen({ vorname: "C", nachname: "Drei" }, admin);
|
||||||
|
await vorgesetztenSetzen(b.id, a.id, admin);
|
||||||
|
await vorgesetztenSetzen(c.id, b.id, admin);
|
||||||
|
|
||||||
|
await expect(vorgesetztenSetzen(a.id, c.id, admin)).rejects.toThrow(/Schleife/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gilt nur innerhalb desselben Kunden", async () => {
|
||||||
|
const { eigene, admin } = await grundbestand();
|
||||||
|
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||||
|
const hier = await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: eigene.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||||||
|
});
|
||||||
|
const dort = await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: fremd.id, vorname: "Petra", nachname: "Klein", anzeigename: "Petra Klein" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(vorgesetztenSetzen(hier.id, dort.id, admin)).rejects.toThrow(/selben Kunden/);
|
||||||
|
// Und auch an der Service-Schicht vorbei nicht: der zusammengesetzte
|
||||||
|
// Fremdschluessel haelt dieselbe Grenze.
|
||||||
|
await expect(
|
||||||
|
testPrisma.mitarbeiter.update({
|
||||||
|
where: { id: hier.id },
|
||||||
|
data: { vorgesetzterId: dort.id },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Mitarbeiter_vorgesetzter_gleicher_kunde/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bietet zur Auswahl nur aktive Kollegen desselben Kunden, ohne die Person selbst", async () => {
|
||||||
|
const { eigene, admin } = await grundbestand();
|
||||||
|
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||||
|
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
|
||||||
|
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
|
||||||
|
const weg = await mitarbeiterAnlegen({ vorname: "Alt", nachname: "Kollege" }, admin);
|
||||||
|
await mitarbeiterAktivSetzen(weg.id, false, admin);
|
||||||
|
await testPrisma.mitarbeiter.create({
|
||||||
|
data: { kundeId: fremd.id, vorname: "Fremd", nachname: "Person", anzeigename: "Fremd Person" },
|
||||||
|
});
|
||||||
|
expect(eigene.id).toBe(maria.kundeId);
|
||||||
|
|
||||||
|
const auswahl = await moeglicheVorgesetzte(maria.id, admin);
|
||||||
|
expect(auswahl.map((eintrag) => eintrag.id)).toEqual([petra.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schreibt die Aenderung ins Protokoll, mit Namen", async () => {
|
||||||
|
const { admin } = await grundbestand();
|
||||||
|
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
|
||||||
|
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
|
||||||
|
|
||||||
|
await vorgesetztenSetzen(maria.id, petra.id, admin);
|
||||||
|
await vorgesetztenSetzen(maria.id, null, admin);
|
||||||
|
|
||||||
|
const eintraege = await testPrisma.protokoll.findMany({
|
||||||
|
where: { entitaet: "Mitarbeiter", entitaetId: maria.id },
|
||||||
|
orderBy: { erstelltAm: "asc" },
|
||||||
|
select: { aktion: true },
|
||||||
|
});
|
||||||
|
// Das Anlegen protokolliert selbst - hier zaehlt, was danach kam.
|
||||||
|
expect(eintraege.map((e) => e.aktion).slice(1)).toEqual([
|
||||||
|
"Vorgesetzter auf Petra Klein gesetzt",
|
||||||
|
"Vorgesetzter entfernt",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Anlegen", () => {
|
describe("Anlegen", () => {
|
||||||
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
|
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
|
||||||
const { admin } = await grundbestand();
|
const { admin } = await grundbestand();
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
|
|||||||
abteilung: "Buchhaltung",
|
abteilung: "Buchhaltung",
|
||||||
titel: "Sachbearbeiterin",
|
titel: "Sachbearbeiterin",
|
||||||
dn: DN,
|
dn: DN,
|
||||||
|
// Beides, so wie der Abgleich es schreibt: der rohe Wert des
|
||||||
|
// Verzeichnisses und die gepflegte Zuordnung, aus der gelesen wird (#020).
|
||||||
vorgesetzterExterneId: "guid-chefin",
|
vorgesetzterExterneId: "guid-chefin",
|
||||||
|
vorgesetzterId: chefin.id,
|
||||||
herkunft: Herkunft.ABGLEICH,
|
herkunft: Herkunft.ABGLEICH,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -170,6 +173,52 @@ describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("löst den Vorgesetzten auch für eine von Hand angelegte Person auf", async () => {
|
||||||
|
// Der Fall aus #020: ohne Verzeichnisquelle gab es vorher kein Feld fuer die
|
||||||
|
// Zuordnung, und {{vorgesetzter_sam}} blieb im Onboarding-Befehl leer -
|
||||||
|
// genau dort, wo er gebraucht wird.
|
||||||
|
const { eigene, versionId } = await aufbau();
|
||||||
|
const chefin = await testPrisma.mitarbeiter.create({
|
||||||
|
data: {
|
||||||
|
kundeId: eigene.id,
|
||||||
|
vorname: "Petra",
|
||||||
|
nachname: "Klein",
|
||||||
|
anzeigename: "Petra Klein",
|
||||||
|
sam: "pklein",
|
||||||
|
herkunft: Herkunft.MANUELL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const vonHand = await testPrisma.mitarbeiter.create({
|
||||||
|
data: {
|
||||||
|
kundeId: eigene.id,
|
||||||
|
vorname: "Neu",
|
||||||
|
nachname: "Kollege",
|
||||||
|
anzeigename: "Neu Kollege",
|
||||||
|
sam: "nkollege",
|
||||||
|
herkunft: Herkunft.MANUELL,
|
||||||
|
vorgesetzterId: chefin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Keine Quelle, keine externe Kennung - der alte Leseweg haette hier
|
||||||
|
// ausgestiegen.
|
||||||
|
expect(vonHand.quelleId).toBeNull();
|
||||||
|
expect(vonHand.vorgesetzterExterneId).toBeNull();
|
||||||
|
|
||||||
|
const laufId = await laufAnlegen({
|
||||||
|
vorlagenVersionId: versionId,
|
||||||
|
titel: "Onboarding Neu Kollege",
|
||||||
|
kundeId: eigene.id,
|
||||||
|
mitarbeiterId: vonHand.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const werte = await platzhalterWerteDesLaufs({
|
||||||
|
laufId,
|
||||||
|
technikerName: "Ali Demir",
|
||||||
|
jetzt: new Date("2026-10-04T10:00:00Z"),
|
||||||
|
});
|
||||||
|
expect(werte).toMatchObject({ vorgesetzter: "Petra Klein", vorgesetzter_sam: "pklein" });
|
||||||
|
});
|
||||||
|
|
||||||
it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
|
it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
|
||||||
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
|
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
|
||||||
// ist die Absicht fuer ein Konto, das es noch nicht gibt.
|
// ist die Absicht fuer ein Konto, das es noch nicht gibt.
|
||||||
|
|||||||
@@ -450,6 +450,35 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
|
|||||||
expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2);
|
expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("macht Petra Klein zur Vorgesetzten von Maria Huber", async () => {
|
||||||
|
// Steht so im Auftrag. Ging vor #020 nicht: eine von Hand angelegte Person
|
||||||
|
// konnte keinen Vorgesetzten haben.
|
||||||
|
const eigene = await eigeneOrganisation();
|
||||||
|
await stammdatenSaeen(testPrisma, eigene.id);
|
||||||
|
|
||||||
|
const maria = await testPrisma.mitarbeiter.findFirstOrThrow({
|
||||||
|
where: { sam: "mhuber" },
|
||||||
|
include: { vorgesetzter: { select: { anzeigename: true, sam: true } } },
|
||||||
|
});
|
||||||
|
expect(maria.vorgesetzter).toEqual({ anzeigename: "Klein, Petra", sam: "pklein" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt eine im Betrieb entfernte Vorgesetzten-Zuordnung nicht zurueck", async () => {
|
||||||
|
// Dieselbe Regel wie fuer alles andere im Seed: er legt an, er korrigiert
|
||||||
|
// nicht.
|
||||||
|
const eigene = await eigeneOrganisation();
|
||||||
|
await stammdatenSaeen(testPrisma, eigene.id);
|
||||||
|
const maria = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
|
||||||
|
await testPrisma.mitarbeiter.update({
|
||||||
|
where: { id: maria.id },
|
||||||
|
data: { vorgesetzterId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await stammdatenSaeen(testPrisma, eigene.id);
|
||||||
|
const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { id: maria.id } });
|
||||||
|
expect(nachher.vorgesetzterId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("legt den Bestand beim zweiten Lauf nicht noch einmal an", async () => {
|
it("legt den Bestand beim zweiten Lauf nicht noch einmal an", async () => {
|
||||||
const eigene = await eigeneOrganisation();
|
const eigene = await eigeneOrganisation();
|
||||||
await stammdatenSaeen(testPrisma, eigene.id);
|
await stammdatenSaeen(testPrisma, eigene.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user