feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+117
View File
@@ -0,0 +1,117 @@
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Kundenverwaltung (#008).
*
* Sichtbar sind diese Listen nur im Modus `DIENSTLEISTUNG`; im Modus `INTERN`
* gibt es genau die eigene Organisation, und die erscheint nirgends. Welcher
* Modus gilt, entscheidet der Aufrufer.
*
* Die Mandantensicht wenden diese Funktionen aber **selbst** an, statt sie dem
* Aufrufer zu ueberlassen: ein vergessener Filter waere hier kein Schoenheits-,
* sondern ein Datenschutzfehler. Deshalb verlangt jede von ihnen den Benutzer.
*/
/**
* `undefined` heisst "keine Einschraenkung", eine leere Liste heisst "sieht
* nichts". Wer das verwechselt, zeigt entweder alles oder gar nichts — deshalb
* steht die Umsetzung an einer Stelle.
*/
async function sichtbarkeitsFilter(benutzer: HandelnderBenutzer) {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? {} : { id: { in: [...sichtbar] } };
}
export type KundenZeile = {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
readonly istEigeneOrganisation: boolean;
readonly archiviert: boolean;
readonly notizen: string | null;
/** Fuer die Kundenliste: woran haengt der Kunde? */
readonly anzahlGeraete: number;
readonly anzahlMitarbeiter: number;
readonly anzahlLaeufe: number;
};
/**
* Die Zaehlungen kommen als `_count` aus derselben Abfrage.
*
* In einer Schleife nachzuzaehlen waere eine Abfrage je Kunde – bei einem
* Dienstleister mit dreissig Kunden also einundneunzig statt einer.
*/
const AUSWAHL = {
id: true,
name: true,
kuerzel: true,
istEigeneOrganisation: true,
archiviert: true,
notizen: true,
_count: { select: { geraete: true, mitarbeiter: true, laeufe: true } },
} as const;
type RohZeile = {
id: string;
name: string;
kuerzel: string;
istEigeneOrganisation: boolean;
archiviert: boolean;
notizen: string | null;
_count: { geraete: number; mitarbeiter: number; laeufe: number };
};
function zeileBauen(roh: RohZeile): KundenZeile {
return {
id: roh.id,
name: roh.name,
kuerzel: roh.kuerzel,
istEigeneOrganisation: roh.istEigeneOrganisation,
archiviert: roh.archiviert,
notizen: roh.notizen,
anzahlGeraete: roh._count.geraete,
anzahlMitarbeiter: roh._count.mitarbeiter,
anzahlLaeufe: roh._count.laeufe,
};
}
/**
* Alle Kunden, nicht archivierte zuerst, dann nach Namen.
*
* Archivierte bleiben standardmaessig aussen vor: die Liste ist der
* Alltagsbestand, das Archiv ist ein ausdruecklicher Griff.
*/
export async function kundenListe(
benutzer: HandelnderBenutzer,
auchArchivierte = false,
): Promise<readonly KundenZeile[]> {
const zeilen = await prisma.kunde.findMany({
where: {
...(await sichtbarkeitsFilter(benutzer)),
...(auchArchivierte ? {} : { archiviert: false }),
},
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
return zeilen.map(zeileBauen);
}
export async function kundeHolen(id: string, benutzer: HandelnderBenutzer): Promise<KundenZeile | undefined> {
const zeile = await prisma.kunde.findFirst({
where: { id, ...(await sichtbarkeitsFilter(benutzer)) },
select: AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
/** Fuer Auswahlfelder: id, name, kuerzel nicht archivierter Kunden, nach Namen. */
export async function kundenZurAuswahl(
benutzer: HandelnderBenutzer,
): Promise<readonly { id: string; name: string; kuerzel: string }[]> {
return prisma.kunde.findMany({
where: { archiviert: false, ...(await sichtbarkeitsFilter(benutzer)) },
orderBy: { name: "asc" },
select: { id: true, name: true, kuerzel: true },
});
}