diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 15c20dd0..d95fb7dc 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -199,10 +199,6 @@ body {
font: inherit;
}
-[data-testid="planungsfenster-liste"] {
- padding-inline-start: 1.25rem;
-}
-
/*
* Status nie NUR über Farbe (EYT-41): Text + Symbol tragen die
* Information, Farbe verstärkt sie lediglich.
@@ -697,3 +693,304 @@ body {
display: grid;
align-content: start;
}
+
+/* ============================================================
+ * EYT-147 — Dispositionswerkbank Slice 1: die Woche ist das Produkt
+ *
+ * Nur Anordnung und Dichte; jede Farbe kommt aus den Rollen von
+ * `@easytree/ui`. Kein neues Farbliteral (basisdesign-tokens.test.ts
+ * verbietet es), keine Animation.
+ * ============================================================ */
+
+/*
+ * Die Planungsflaeche bekommt die Breite, die eine Wochenachse braucht —
+ * NUR sie: Start- und Kostenflaeche behalten die zentrierte 60rem-Spalte,
+ * und die vermessene Geometrie der Startseite (shell-smoke) bleibt
+ * unberuehrt, weil `.werkbank` dort nicht vorkommt.
+ */
+.eyt-app-shell__main:has(.werkbank) {
+ max-width: 110rem;
+}
+
+/*
+ * Kopf und Wochennavigation bilden ab Werkbankbreite EINE Toolbar-Zeile:
+ * links die Antwort „was plane ich" (h1 + Zeitraum), rechts die Bedienung.
+ * Die DOM-Reihenfolge Kopf → Navigation → Flaeche bleibt unveraendert —
+ * hier wird nur angeordnet.
+ */
+@media (min-width: 64rem) {
+ .werkbank {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ column-gap: var(--space);
+ }
+
+ .werkbank__kopf {
+ grid-column: 1;
+ }
+
+ .werkbank > .eyt-date-range {
+ grid-column: 2;
+ }
+
+ .werkbank__flaeche {
+ grid-column: 1 / -1;
+ }
+
+ /* In der Toolbar stehen Woche, Zeitraum und Wege in einer Zeile. */
+ .werkbank .eyt-date-range__range,
+ .werkbank .eyt-date-range__detail {
+ flex-basis: auto;
+ }
+}
+
+/* Statuszeile der Wochenflaeche: links der Gegenstand, rechts der Stand. */
+.werkbank-fenster__status {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.25rem var(--space);
+}
+
+.werkbank-fenster__titel h2 {
+ margin: 0;
+ font-size: 1.0625rem;
+}
+
+.werkbank-fenster__zone,
+.werkbank-fenster__version {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--eyt-text-secondary);
+}
+
+.werkbank-fenster__stand {
+ display: grid;
+ gap: 0.125rem;
+ justify-items: end;
+ text-align: right;
+}
+
+.werkbank-fenster__stand > p {
+ margin: 0;
+}
+
+/* Aktionszeile: Publish-Baustein (mit seinen Meldungen) und der Ausloeser
+ des Inspectors. Genau EINE der beiden Schaltflaechen ist primaer. */
+.werkbank-fenster__aktionen {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: var(--space);
+ margin-block: 0.75rem;
+}
+
+.werkbank-fenster__aktionen .eyt-planung-publish {
+ flex: 1 1 24rem;
+ min-width: 0;
+}
+
+.eyt-planung-publish__grenze {
+ margin: 0.25rem 0;
+ font-size: 0.8125rem;
+ color: var(--eyt-text-secondary);
+ max-width: 60rem;
+}
+
+/* Flaeche: Wochenraster, daneben — wenn offen — der Inspector. */
+.werkbank-fenster__flaeche {
+ display: grid;
+ gap: var(--space);
+ align-items: start;
+}
+
+@media (min-width: 64rem) {
+ .werkbank-fenster[data-inspector="offen"] .werkbank-fenster__flaeche {
+ grid-template-columns: minmax(0, 1fr) 22rem;
+ }
+}
+
+/*
+ * Das Wochenraster: sieben Tage als raeumliche Achse. Unterhalb der
+ * Werkbankbreite (u. a. 200-%-Zoom-Aequivalent 720 px) stapeln die Tage
+ * untereinander — kein horizontales Seitenscrollen.
+ */
+.wochenraster {
+ display: grid;
+ gap: 1px;
+ border: 1px solid var(--eyt-border-default);
+ border-radius: var(--radius);
+ background: var(--eyt-border-default);
+ overflow: hidden;
+}
+
+@media (min-width: 64rem) {
+ .wochenraster {
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ }
+}
+
+.wochenraster__tag {
+ background: var(--eyt-bg-canvas);
+ padding: 0.5rem;
+ min-height: 8rem;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ gap: 0.375rem;
+ min-width: 0;
+}
+
+.wochenraster__tagkopf {
+ margin: 0;
+ font-size: 0.8125rem;
+ font-weight: 650;
+}
+
+.wochenraster__tagdatum {
+ font-weight: 400;
+ color: var(--eyt-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.wochenraster__frei {
+ margin: 0;
+ color: var(--eyt-text-secondary);
+ text-align: center;
+ align-self: center;
+}
+
+.wochenraster__einsaetze {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ gap: 0.375rem;
+ align-content: start;
+}
+
+.wochenraster__ausserhalb {
+ grid-column: 1 / -1;
+ background: var(--eyt-state-danger-bg);
+ padding: 0.5rem;
+}
+
+.wochenraster__ausserhalb > h3 {
+ margin: 0 0 0.375rem;
+ font-size: 0.8125rem;
+ color: var(--eyt-state-danger-text);
+}
+
+/* Einsatzkarte: Zeit, Baustelle, Person — kompakt, Zustand ueber Form. */
+.einsatzkarte {
+ background: var(--eyt-bg-surface);
+ border: 1px solid var(--eyt-border-default);
+ border-radius: calc(var(--radius) - 4px);
+ padding: 0.5rem 0.625rem;
+ display: grid;
+ gap: 0.125rem;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.einsatzkarte__zeit {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.einsatzkarte__baustelle {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.einsatzkarte__person {
+ font-size: 0.8125rem;
+ color: var(--eyt-text-secondary);
+}
+
+/* Planstand als Form, nicht nur als Farbe: Entwurfskarten gestrichelt,
+ veroeffentlichte mit durchgehender linker Kante (Basisdesign §3.1). */
+.werkbank-fenster[data-stand="entwurf"] .einsatzkarte,
+.werkbank-fenster[data-stand="entwurf-ueber-veroeffentlicht"] .einsatzkarte {
+ border-style: dashed;
+}
+
+.werkbank-fenster[data-stand="veroeffentlicht"] .einsatzkarte {
+ border-inline-start: 3px solid var(--eyt-state-published-text);
+}
+
+/* Der nichtmodale Erstellungs-Inspector. */
+.werkbank-inspector {
+ border: 1px solid var(--eyt-border-default);
+ border-radius: var(--radius);
+ background: var(--eyt-bg-surface);
+ padding: var(--space);
+}
+
+.werkbank-inspector__kopf {
+ display: flex;
+ justify-content: flex-end;
+ margin-block-end: 0.25rem;
+}
+
+/*
+ * Das Einsatzformular im Inspector traegt das Basisdesign v2 (PO-Review-
+ * Reparatur EYT-147): beschriftete Felder statt nackter Browser-Controls.
+ * Reine Darstellung — native Semantik, Testanker und die gesamte
+ * Validierungs-/Zeitzonenlogik liegen unveraendert in
+ * `planning-assignment-form.tsx`; jede Farbe ist eine Tokenrolle.
+ */
+.werkbank-inspector h3 {
+ margin: 0 0 0.75rem;
+ font-size: 0.9375rem;
+}
+
+.einsatzformular {
+ gap: 0.75rem;
+}
+
+.einsatzformular__feld {
+ display: grid;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.einsatzformular__feld > label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--eyt-text-secondary);
+}
+
+.einsatzformular :is(select, input) {
+ padding: 0.5rem 0.625rem;
+ border: 1px solid var(--eyt-border-default);
+ border-radius: calc(var(--radius) - 4px);
+ background: var(--eyt-bg-canvas);
+ color: var(--eyt-text-primary);
+}
+
+/* Beginn und Ende sind EIN Zeitraum — sie stehen nebeneinander. */
+.einsatzformular__zeiten {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.einsatzformular__meldung {
+ margin: 0.75rem 0 0;
+ padding: 0.5rem 0.625rem;
+ border-radius: calc(var(--radius) - 4px);
+ font-size: 0.875rem;
+}
+
+.einsatzformular__meldung[data-state="erfolg"] {
+ background: var(--eyt-state-published-bg);
+ color: var(--eyt-state-published-text);
+}
+
+.einsatzformular__meldung[data-state="fehler"] {
+ background: var(--eyt-state-danger-bg);
+ color: var(--eyt-state-danger-text);
+}
diff --git a/apps/web/components/planning-assignment-form.tsx b/apps/web/components/planning-assignment-form.tsx
index 47929748..df9db5fb 100644
--- a/apps/web/components/planning-assignment-form.tsx
+++ b/apps/web/components/planning-assignment-form.tsx
@@ -250,69 +250,86 @@ export function AssignmentForm({ window: fenster, onSubmit }: AssignmentFormProp
const feld = (name: string): string => `${idPrefix}-${name}`;
return (
-
+ // Die Klassen sind reine Darstellung (PO-Review-Reparatur EYT-147):
+ // native Semantik, `label`-Zuordnung, Testanker und die gesamte
+ // Validierungs- und Zeitzonenlogik bleiben unveraendert — gestylt wird
+ // ueber `globals.css` mit den Basisdesign-v2-Tokens.
+
Einsatz planen
) : null}
+ {/*
+ Die Bestaetigung nennt weder Versions-Id noch rohen Zeitstempel im
+ Text — beides ist Serverwahrheit fuer Maschinen und liegt in den
+ `data-*`-Attributen (PO-Review-Reparatur EYT-147; `auth-journey` und
+ die Staging-Reise lesen `data-published-version-id`).
+ */}
{ablauf.kind === "erfolg" ? (
- Planversion {ablauf.version.versionId} ist seit {ablauf.version.publishedAtUtc}{" "}
- verbindlich. Sie kann nicht mehr verändert werden.
+ Der Plan dieser Woche ist ab sofort verbindlich. Er kann nicht mehr verändert werden.
) : null}
diff --git a/apps/web/components/planning-window-view.tsx b/apps/web/components/planning-window-view.tsx
index 72dec0d3..53c3bf9f 100644
--- a/apps/web/components/planning-window-view.tsx
+++ b/apps/web/components/planning-window-view.tsx
@@ -1,17 +1,25 @@
"use client";
/**
- * Read-only Planungsfenster (EYT-50).
+ * Wochenarbeitsflaeche der Dispositionswerkbank (EYT-50, umgebaut in EYT-147).
*
- * Bewusst schmal: die kleinste Ansicht, die den SERVERSTAND zeigt. Kein
- * Planner, keine Bearbeitung, keine Wochennavigation — das ist EYT-72.
+ * ## Von der Liste zur Woche (EYT-147 Slice 1)
+ *
+ * Bis EYT-147 zeigte diese Ansicht die Einsaetze als flache Liste unter einer
+ * Ueberschrift — fachlich korrekt, aber als FORMULARSEITE lesbar, nicht als
+ * Planung. Jetzt ist die Woche die Flaeche: sieben Kalendertage als raeumliche
+ * Achse, jeder Einsatz als Karte am Tag seines Beginns. Die Zuordnung rechnet
+ * `lib/wochenraster.ts` ausschliesslich mit Domain-Helfern; diese Datei
+ * entscheidet weiterhin nichts Fachliches.
*
* ## Vier Zustaende, alle sichtbar
*
* laden, leer, Fehler, Erfolg. Der Fehlerzustand ist der wichtigste: er zeigt
* den Grund aus dem Port (`GatewayFailure`) an, statt eine leere Woche zu
* rendern. Eine leere Woche und eine fehlgeschlagene Abfrage sehen sonst
- * identisch aus, und der Ausfall wuerde als "nichts geplant" gelesen.
+ * identisch aus, und der Ausfall wuerde als "nichts geplant" gelesen. Neu seit
+ * EYT-147: auch die TAGESZUORDNUNG faellt sichtbar aus — ist Zone oder Woche
+ * unbestimmbar, steht die flache Liste mit Hinweis da, nie ein leeres Raster.
*
* ## Kein Fallback
*
@@ -25,7 +33,28 @@
* `data-assignment-id` und `data-published-version-id` stehen im Markup, weil
* AK9 verlangt, dass Planer- und Mitarbeiteransicht DIESELBEN serverseitig
* vergebenen Ids zeigen. Ohne sie liesse sich das nur ueber Text vergleichen,
- * und Text ist Darstellung.
+ * und Text ist Darstellung. Jede Zuweisung traegt ihre Id auf GENAU EINEM
+ * Element (`werkbank-serverwahrheit.test.tsx` zaehlt exakt), und jede Karte
+ * ist ein `li` — daran haengen `getAllByRole("listitem")`-Zusicherungen.
+ *
+ * ## Der Inspector ist ein Ort, kein zweiter Zustand
+ *
+ * Das Einsatzformular (`AssignmentForm`, unveraendert) steht seit EYT-147 in
+ * einem seitlichen, NICHTMODALEN Inspector, geoeffnet ueber „Einsatz anlegen".
+ * Der Inspector haelt keinen Fachzustand: Validierung, Idempotenz und
+ * Serverwahrheit bleiben, wo sie waren. Nach erfolgreichem Speichern bleibt er
+ * OFFEN — die Erfolgsmeldung des Formulars muss waehrend des Read-through
+ * sichtbar bleiben (`planning-window-view.test.tsx`), und der naechste Einsatz
+ * ist der haeufigste Folgeschritt. Kein Fokus-Trap: Escape schliesst, der
+ * Fokus kehrt zur ausloesenden Schaltflaeche zurueck.
+ *
+ * ## Genau eine primaere Aktion (Basisdesign §2.3, EYT-147 §8.8)
+ *
+ * Traegt die Woche einen veroeffentlichbaren Entwurf und die Rolle das Recht,
+ * ist „Plan veroeffentlichen" DIE primaere Aktion (sie kommt aus
+ * `PlanningPublishAction`). Nur dann ist „Einsatz anlegen" eine gewoehnliche
+ * Schaltflaeche; sonst ist es selbst die primaere. `auth-journey` zaehlt
+ * `.eyt-primary-action` auf /planung und erwartet hoechstens eine.
*/
import {
newIdempotencyKey,
@@ -35,17 +64,22 @@ import {
type PlanningWindow,
} from "@easytree/contracts";
import {
+ Button,
Card,
EmptyState,
ErrorState,
LoadingState,
+ PrimaryAction,
StateBanner,
StatusBadge,
+ VisuallyHidden,
type StatusTone,
} from "@easytree/ui";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useId, useRef, useState } from "react";
import { usePlanningGateway } from "../lib/planning-gateway-provider";
+import { wochenraster, zeitText } from "../lib/wochenraster";
+import type { AssignmentDto } from "@easytree/contracts";
import { AssignmentForm } from "./planning-assignment-form";
import { PlanningPublishAction } from "./planning-publish-action";
@@ -59,7 +93,7 @@ const FAILURE_TEXT: Record = {
UNAVAILABLE: "Der Server ist nicht erreichbar.",
CONTRACT_VIOLATION: "Die Antwort des Servers entspricht nicht dem Vertrag.",
UNAUTHENTICATED: "Nicht angemeldet.",
- FORBIDDEN: "Keine Berechtigung fuer diese Organisation.",
+ FORBIDDEN: "Keine Berechtigung für diese Organisation.",
STALE_VERSION: "Der Stand ist veraltet.",
REJECTED: "Die Anfrage wurde abgelehnt.",
};
@@ -101,19 +135,26 @@ const ZUSTANDSKLASSE: Record = {
*/
type Stand = "ohne-version" | "entwurf" | "veroeffentlicht" | "entwurf-ueber-veroeffentlicht";
-const STAND_TEXT: Record = {
+/**
+ * Erlaeuternder Zusatz NEBEN dem Abzeichen — nur dort, wo er etwas sagt, was
+ * das Abzeichen nicht schon sagt (PO-Review-Reparatur EYT-147). „Entwurf" mit
+ * dem Satz „Unveroeffentlichter Entwurf." daneben war dieselbe Aussage
+ * zweimal; `null` heisst: das Abzeichen ist die ganze Aussage.
+ */
+const STAND_TEXT: Record = {
"ohne-version": "Für diese Woche existiert noch keine Planversion.",
- entwurf: "Unveroeffentlichter Entwurf.",
- veroeffentlicht: "Veroeffentlichter Stand.",
+ entwurf: null,
+ veroeffentlicht: null,
"entwurf-ueber-veroeffentlicht":
- "Entwurf auf Basis einer bereits veroeffentlichten Version — die Anzeige zeigt den ENTWURF.",
+ "Entwurf auf Basis einer bereits veröffentlichten Version — angezeigt wird der Entwurf.",
};
/**
* Kurzform des Standes als Abzeichen (EYT-140 M7, `AC-008`/`AC-018`).
*
- * Der Satz in `STAND_TEXT` bleibt die Aussage; das Abzeichen traegt sie ein
- * zweites Mal ueber Form und Zeichen. `StatusBadge` setzt je Ton eine eigene
+ * Das Abzeichen ist seit der PO-Review-Reparatur die EINE primaere
+ * Statusanzeige der Werkbank (`werkbank-oberflaechenguards.test.tsx` zaehlt
+ * die `StatusBadge`-Elemente). `StatusBadge` setzt je Ton eine eigene
* Glyphe (`●`, `◐`, `○`), sodass Entwurf und veroeffentlichter Stand auch ohne
* Farbwahrnehmung unterscheidbar bleiben — Basisdesign v2.0 §7 verlangt genau
* das, und eine reine CSS-Klasse erfuellt es nicht.
@@ -132,7 +173,7 @@ const STAND_TON: Record = {
const STAND_ABZEICHEN: Record = {
"ohne-version": "Keine Version",
entwurf: "Entwurf",
- veroeffentlicht: "Veroeffentlicht",
+ veroeffentlicht: "Veröffentlicht",
"entwurf-ueber-veroeffentlicht": "Entwurf",
};
@@ -156,6 +197,34 @@ function anzeigename(eintraege: readonly PlanningResource[], id: string): string
return treffer.active ? treffer.label : `${treffer.label} (inaktiv)`;
}
+/**
+ * Eine Einsatzkarte. Baustelle zuerst — sie ist die raeumliche Antwort der
+ * Disposition —, dann die Person, dann die Wanduhrzeit der Organisation.
+ * Die Id liegt auf GENAU diesem `li` und auf keinem zweiten Element.
+ */
+function Einsatzkarte({ einsatz, fenster }: { einsatz: AssignmentDto; fenster: PlanningWindow }) {
+ return (
+
+ );
+}
+
export function PlanningWindowView({
weekKey,
darfVeroeffentlichen = false,
@@ -188,6 +257,11 @@ export function PlanningWindowView({
*/
const vorgangsSchluessel = useRef<{ vorgang: string; key: IdempotencyKey } | null>(null);
+ /** Sichtbarkeit des Erstellungs-Inspectors. Reiner Darstellungszustand. */
+ const [inspectorOffen, setInspectorOffen] = useState(false);
+ const inspectorRef = useRef(null);
+ const inspectorId = useId();
+
useEffect(() => {
let abgebrochen = false;
// Beim ersten Laden und beim Wochenwechsel ersetzt der Ladezustand den
@@ -213,6 +287,29 @@ export function PlanningWindowView({
};
}, [gateway, weekKey, nachladen]);
+ // Beim Oeffnen wandert der Fokus auf das erste Bedienelement des
+ // Formulars — die Planerin hat die Oeffnung selbst ausgeloest, der Sprung
+ // ist ihre Absicht. Beim Schliessen setzt `schliessen` ihn zurueck.
+ useEffect(() => {
+ if (!inspectorOffen) return;
+ // Das erste FACHLICHE Bedienelement, nicht das erste fokussierbare — die
+ // Schliessen-Schaltflaeche steht davor. Ohne Formular (keine aktiven
+ // Stammdaten) faellt der Fokus auf den Inspector selbst, damit der
+ // Erklaertext im Lesefluss liegt.
+ const formularFeld = inspectorRef.current?.querySelector(
+ '[data-testid="einsatzformular"] select, [data-testid="einsatzformular"] input',
+ );
+ (formularFeld ?? inspectorRef.current)?.focus();
+ }, [inspectorOffen]);
+
+ const schliessen = useCallback(() => {
+ setInspectorOffen(false);
+ // Fokus zurueck auf den Ausloeser — ueber den Anker statt ueber eine Ref,
+ // weil die Primitives (`Button`, `PrimaryAction`) keine Refs durchreichen
+ // und der Ausloeser je nach Stand das eine oder das andere ist.
+ document.querySelector('[data-testid="werkbank-einsatz-anlegen"]')?.focus();
+ }, []);
+
/**
* Speichern und den SERVERSTAND neu lesen.
*
@@ -320,73 +417,188 @@ export function PlanningWindowView({
const { window: fenster } = state;
const stand = standKennung(fenster);
+ const raster = wochenraster(fenster);
+
+ // Genau eine primaere Aktion: veroeffentlichbarer Entwurf mit Recht macht
+ // „Plan veroeffentlichen" primaer (in `PlanningPublishAction`), sonst ist
+ // es „Einsatz anlegen". Beide Bedingungen speisen sich aus DEMSELBEN
+ // Serverstand — sie koennen nicht gleichzeitig wahr sein.
+ const publishIstPrimaer = fenster.sourceVersion?.state === "draft" && darfVeroeffentlichen;
+
+ const ausloeserProps = {
+ "data-testid": "werkbank-einsatz-anlegen",
+ "aria-expanded": inspectorOffen,
+ "aria-controls": inspectorId,
+ onClick: () => {
+ if (inspectorOffen) {
+ schliessen();
+ } else {
+ setInspectorOffen(true);
+ }
+ },
+ } as const;
return (
-
-
+ {/*
+ Die serverseitigen Versions-Ids sind Serverwahrheit, kein
+ Planertext (PO-Review-Reparatur EYT-147): sie stehen NUR in den
+ `data-*`-Attributen, an denen `read-through.spec.ts`,
+ `auth-journey` und die Werkbank-Suiten haengen. Sichtbar bleibt
+ allein die eine Aussage, die das Abzeichen nicht traegt: dass
+ fuer diese Woche noch nichts veroeffentlicht ist.
+ */}
+
- {/*
- Die Publish-Aktion (EYT-107). Sie bekommt den SERVERstand herein und
- meldet die Serverantwort zurueck; die Woche wird danach neu gelesen.
- Sie setzt selbst nichts — siehe Dateikopf von
- `planning-publish-action.tsx`.
- */}
- gateway.publishPlan(befehl, optionen)}
- onVeroeffentlicht={() => setNachladen((n) => n + 1)}
- />
+
+ {/*
+ Die Publish-Aktion (EYT-107). Sie bekommt den SERVERstand herein und
+ meldet die Serverantwort zurueck; die Woche wird danach neu gelesen.
+ Sie setzt selbst nichts — siehe Dateikopf von
+ `planning-publish-action.tsx`.
+ */}
+ gateway.publishPlan(befehl, optionen)}
+ onVeroeffentlicht={() => setNachladen((n) => n + 1)}
+ />
+ {publishIstPrimaer ? (
+
+ ) : (
+ Einsatz anlegen
+ )}
+
{fenster.assignments.length === 0 ? (
- ) : (
-
- {fenster.assignments.map((assignment) => (
-
- {/* Namen statt Uuids: eine Planerin erkennt "Anna Berg auf
- Baustelle Nord", nicht 22222222-…. Die Ids bleiben als
- data-Attribute im Markup, weil AK9 den Id-Vergleich zwischen
- Planer- und Mitarbeitersicht verlangt — der braucht die Id
- selbst, nicht ihre Darstellung. */}
- {anzeigename(fenster.resources.employees, assignment.employeeId)}
- {" auf "}
- {anzeigename(fenster.resources.worksites, assignment.worksiteId)}
- {": "}
- {assignment.interval.startUtc} – {assignment.interval.endUtc}
-
- ))}
-
- )}
+ ) : null}
+
+
+ {raster.art === "raster" ? (
+
+ {raster.tage.map((tag) => (
+
+
+ {tag.wochentagsText}{" "}
+ {tag.datumsText}
+
+ {tag.einsaetze.length === 0 ? (
+ // Nur Zierde fuer Sehende: die Abwesenheit einer Liste sagt
+ // dem Screenreader dasselbe, ein zweiter Text waere Rauschen.
+
+ –
+
+ ) : (
+
+ {tag.einsaetze.map((einsatz) => (
+
+ ))}
+
+ )}
+
+ ))}
+ {raster.ausserhalb.length > 0 ? (
+ // Antworten, die der Server so eigentlich nicht liefern kann —
+ // aber „kann nicht sein" ist kein Renderpfad: sichtbar statt
+ // verschluckt (`lib/wochenraster.ts`).
+
+
Außerhalb dieser Woche
+
+ {raster.ausserhalb.map((einsatz) => (
+
+ ))}
+
+
+ ) : null}
+
+ ) : (
+ // Zone oder Woche unbestimmbar: die flache Liste ist die ehrliche
+ // Rueckfallebene — alle Daten sichtbar, nichts geraten.
+ <>
+
+ {raster.grund === "zone-unbekannt"
+ ? `Die Zeitzone „${fenster.timeZone}“ ist dieser Laufzeit unbekannt; die Einsätze stehen ungeordnet untereinander.`
+ : `Der Wochenschlüssel „${fenster.weekKey}“ ist nicht lesbar; die Einsätze stehen ungeordnet untereinander.`}
+
+ {fenster.assignments.length > 0 ? (
+
{
+ // Nichtmodal, aber mit Rueckweg: Escape schliesst und stellt
+ // den Fokus auf den Ausloeser zurueck. Kein Fokus-Trap — Tab
+ // verlaesst den Inspector wie jeden anderen Seitenbereich.
+ if (ereignis.key === "Escape") {
+ ereignis.stopPropagation();
+ schliessen();
+ }
+ }}
+ >
+
+
+
+
+
+ ) : null}
+
);
}
diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts
index 36101f5c..1d6b27a0 100644
--- a/apps/web/e2e/auth-journey/journey.pwtest.ts
+++ b/apps/web/e2e/auth-journey/journey.pwtest.ts
@@ -1259,6 +1259,87 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({
schritte["9c1_werkbank_breiten"] = { breiten: werkbankBreiten.map((b) => b.width) };
});
+ // ---------------------------------------------------------------------
+ // 9c1b — EYT-147: ein Einsatz entsteht ueber den Inspector der Werkbank
+ // ---------------------------------------------------------------------
+ // Der EINZIGE UI-Schreibnachweis dieser Reise mit echter Identitaet: der
+ // read-through-Harness ersetzt `REQUEST_IDENTITY` und kann ueber die
+ // Identitaet nichts sagen. Bewusst in der EIGENEN Woche 2026-W34: W32
+ // tragen die abgenommenen EYT-144-Kostenzahlen, W33 der Baustellenfilter,
+ // W35–W37 die Angriffe — ein zusaetzlicher Einsatz dort veraenderte
+ // abgenommene Summen. Der Server legt fuer die versionslose W34 selbst
+ // einen Entwurf an (planning-write.repository.ts, „holen oder anlegen").
+ // 18.08. 07:00–09:00 Europe/Berlin = 05:00–07:00Z, Dienstag der W34.
+ await test.step("9c1b — EYT-147: Einsatz ueber den Inspector anlegen", async () => {
+ // Werkbankbreite fuer die Bild-Evidenz — die Dateinamen sagen 1440, also
+ // wird 1440 gemessen, nicht die Standardbreite des Laufs.
+ const breiteVorher = page.viewportSize();
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto("/planung?weekKey=2026-W34");
+ const ausloeser = page.getByTestId("werkbank-einsatz-anlegen");
+ await expect(ausloeser).toBeVisible();
+ // Vor dem Oeffnen ist das Formular nicht im Baum — der Inspector ist der
+ // Erstellungskontext, kein Dauerformular.
+ await expect(page.getByTestId("einsatzformular")).toHaveCount(0);
+ await ausloeser.click();
+ await expect(page.getByTestId("einsatzformular")).toBeVisible();
+ await page.screenshot({
+ path: join(ARTEFAKTE, "12-werkbank-inspector-1440.png"),
+ fullPage: true,
+ });
+
+ await page.getByTestId("feld-employee").selectOption("00000000-0000-4000-8000-00000000e211");
+ await page.getByTestId("feld-worksite").selectOption("00000000-0000-4000-8000-00000000e241");
+ await page.getByTestId("feld-datum").fill("2026-08-18");
+ await page.getByTestId("feld-beginn").fill("07:00");
+ await page.getByTestId("feld-ende").fill("09:00");
+ const [schreibAntwort] = await Promise.all([
+ page.waitForResponse(
+ (antwort) =>
+ antwort.url().includes("/planung/einsaetze") && antwort.request().method() === "POST",
+ ),
+ page.getByTestId("einsatz-speichern").click(),
+ ]);
+ expect(schreibAntwort.status()).toBe(201);
+ const angelegt = (await schreibAntwort.json()) as { id: string };
+ expect(angelegt.id).toMatch(/^[0-9a-f-]{36}$/);
+
+ // Sichtbar wird der BESTAETIGTE Serverstand (Read-through), und die Karte
+ // steht am Kalendertag ihres Beginns — Dienstag, 18.08.
+ const karte = page.locator(`[data-assignment-id="${angelegt.id}"]`);
+ await expect(karte).toBeVisible();
+ await expect(page.locator('[data-tag="2026-08-18"] [data-assignment-id]')).toHaveCount(1);
+ await page.screenshot({
+ path: join(ARTEFAKTE, "13-werkbank-einsatz-serverbestaetigt.png"),
+ fullPage: true,
+ });
+
+ // Reload: dieselbe serverseitige Id, und die vorher versionslose Woche
+ // ist jetzt eindeutig als ENTWURF erkennbar.
+ await page.reload();
+ await expect(page.locator(`[data-assignment-id="${angelegt.id}"]`)).toBeVisible();
+ await expect(page.getByTestId("planungsfenster-stand")).toHaveAttribute(
+ "data-stand",
+ "entwurf",
+ );
+
+ // Zurueck in die Publish-Woche: 9d klickt das NAECHSTE
+ // `planung-veroeffentlichen` — bliebe die Seite auf W34 stehen, würde
+ // dieser Klick den frisch entstandenen W34-Entwurf veroeffentlichen und
+ // der Vergleich mit `entwurfsVersionId` (W32) ginge zu Recht rot
+ // (gemessen im ersten Lauf dieses Schritts).
+ await page.goto(`/planung?weekKey=${PLANWOCHE}`);
+ await expect(page.getByTestId("planungsfenster-stand")).toHaveAttribute(
+ "data-stand",
+ "entwurf",
+ );
+ if (breiteVorher !== null) {
+ await page.setViewportSize(breiteVorher);
+ }
+
+ schritte["9c1b_eyt147_inspector"] = { assignmentId: angelegt.id, woche: "2026-W34" };
+ });
+
// ---------------------------------------------------------------------
// 9c2 — Der P1-Nachweis: die Data-API veroeffentlicht NICHT (EYT-107)
// ---------------------------------------------------------------------
diff --git a/apps/web/e2e/read-through.spec.ts b/apps/web/e2e/read-through.spec.ts
index 69c29ff4..94850c37 100644
--- a/apps/web/e2e/read-through.spec.ts
+++ b/apps/web/e2e/read-through.spec.ts
@@ -490,17 +490,34 @@ const NEU_ENDE = "15:00";
*/
async function wocheOeffnen(seite: import("@playwright/test").Page): Promise {
await seite.goto(SEITE);
- await expect(seite.getByTestId("einsatzformular")).toBeVisible();
+ // Seit EYT-147 steht das Formular im geschlossenen Inspector; das fruehere
+ // Wartesignal `einsatzformular` existiert im Ruhezustand nicht mehr. Der
+ // Ausloeser erscheint wie das Formular erst mit verarbeiteten `resources`,
+ // die Liste erst mit den Zuweisungen — beide zusammen heissen weiterhin,
+ // dass die Antwort vollstaendig verarbeitet ist.
+ await expect(seite.getByTestId("werkbank-einsatz-anlegen")).toBeVisible();
await expect(seite.getByTestId("planungsfenster-liste")).toBeVisible();
return sichtbareZuweisungen(seite);
}
+/**
+ * Den Erstellungs-Inspector oeffnen, falls er zu ist (EYT-147). Idempotent,
+ * damit Folgeschritte innerhalb eines Tests nicht doppelt klicken.
+ */
+async function inspectorOeffnen(seite: import("@playwright/test").Page): Promise {
+ if ((await seite.getByTestId("einsatzformular").count()) === 0) {
+ await seite.getByTestId("werkbank-einsatz-anlegen").click();
+ }
+ await expect(seite.getByTestId("einsatzformular")).toBeVisible();
+}
+
async function formularAusfuellen(
seite: import("@playwright/test").Page,
beginn: string,
ende: string,
datum = NEU_DATUM,
): Promise {
+ await inspectorOeffnen(seite);
await seite.getByTestId("feld-employee").selectOption(A_PERSON);
await seite.getByTestId("feld-worksite").selectOption(A_BAUSTELLE);
await seite.getByTestId("feld-datum").fill(datum);
@@ -513,7 +530,8 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => {
page,
}) => {
await page.goto(SEITE);
- await expect(page.getByTestId("einsatzformular")).toBeVisible();
+ await expect(page.getByTestId("werkbank-einsatz-anlegen")).toBeVisible();
+ await inspectorOeffnen(page);
// Die Namen stammen aus e2e/harness/seed.sql und existieren nirgends im
// Clientcode — waeren sie eine Fixture, stuende hier ein anderer Text.
@@ -543,6 +561,7 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => {
});
await page.goto(SEITE);
+ await inspectorOeffnen(page);
await page.getByTestId("feld-employee").selectOption(A_PERSON);
await page.getByTestId("feld-worksite").selectOption(A_BAUSTELLE);
// Datum, Beginn und Ende fehlen absichtlich.
@@ -717,6 +736,7 @@ test.describe.serial("Planungsroute: Responsive- und Accessibility-Abnahme", ()
}) => {
await page.setViewportSize(fall.viewport);
await wocheOeffnen(page);
+ await inspectorOeffnen(page);
const employee = page.getByTestId("feld-employee");
const worksite = page.getByTestId("feld-worksite");
diff --git a/apps/web/e2e/staging/journey.pwtest.ts b/apps/web/e2e/staging/journey.pwtest.ts
index 9270db5d..a2644e9f 100644
--- a/apps/web/e2e/staging/journey.pwtest.ts
+++ b/apps/web/e2e/staging/journey.pwtest.ts
@@ -132,6 +132,11 @@ async function einsatzAnlegen(
page: Page,
eingabe: { mitarbeiter: string; baustelle: string; datum: string },
): Promise {
+ // EYT-147: das Formular steht im Inspector und ist erst nach „Einsatz
+ // anlegen" im Baum. Idempotent gegen einen bereits offenen Inspector.
+ if ((await page.getByTestId("einsatzformular").count()) === 0) {
+ await page.getByTestId("werkbank-einsatz-anlegen").click();
+ }
await page.getByTestId("feld-employee").selectOption({ label: eingabe.mitarbeiter });
await page.getByTestId("feld-worksite").selectOption({ label: eingabe.baustelle });
await page.getByTestId("feld-datum").fill(eingabe.datum);
@@ -301,6 +306,17 @@ test.describe.serial("EYT-142 Staging-Kernreise", () => {
await schnappschuss(seite, "06-fokus-publish-1440");
}
if (testid === "feld-employee") fokusEmployee = true;
+ // EYT-147: das Formular liegt im Inspector. Erreicht die Tastatur den
+ // Ausloeser, oeffnet Enter ihn, und der Fokus landet programmatisch im
+ // ersten Formularfeld — genau das wird hier mitgemessen.
+ if (testid === "werkbank-einsatz-anlegen" && !fokusEmployee) {
+ await seite.keyboard.press("Enter");
+ const nachOeffnen = await seite.evaluate(
+ () => document.activeElement?.getAttribute("data-testid") ?? "",
+ );
+ if (nachOeffnen !== "") erreicht.push(nachOeffnen);
+ if (nachOeffnen === "feld-employee") fokusEmployee = true;
+ }
}
expect(fokusEmployee, `Formularfeld per Tastatur in <=${budget} Tabs`).toBe(true);
expect(fokusPublish, `Publish-Knopf per Tastatur in <=${budget} Tabs`).toBe(true);
diff --git a/apps/web/lib/wochenraster.ts b/apps/web/lib/wochenraster.ts
new file mode 100644
index 00000000..ed42b9ee
--- /dev/null
+++ b/apps/web/lib/wochenraster.ts
@@ -0,0 +1,181 @@
+/**
+ * Wochenraster der Dispositionswerkbank (EYT-147 Slice 1).
+ *
+ * ## Was diese Datei ist — und was sie bewusst nicht ist
+ *
+ * Eine reine Zuordnungsfunktion: sie legt die Einsätze einer Serverantwort auf
+ * die sieben Kalendertage ihrer Woche. Sie erfindet keinen fachlichen Zustand,
+ * rechnet keine Woche und trifft keine Planungsentscheidung — Wochengrenze,
+ * Kalendertage und die Zuordnung eines Zeitpunkts zu einem Tag kommen
+ * vollständig aus `@easytree/domain` (`planningWeekDateRange`, `dayAfter`,
+ * `localBusinessDate`) und `@easytree/contracts` (`parseIsoWeekKey`). Eine
+ * eigene Tagesrechnung hier wäre die zweite Kalenderregel neben der Domain —
+ * und beide liefen genau an DST- und Jahresgrenzen auseinander.
+ *
+ * ## Ein Einsatz gehört zum Tag seines BEGINNS
+ *
+ * Dieselbe Regel, mit der `planningWeekOf` eine Nachtschicht in der Woche
+ * ihres Beginns hält: der Kalendertag eines Einsatzes ist der Tag, an dem er
+ * in der Zone der Organisation beginnt. Ein Einsatz von Montag 22:00 bis
+ * Dienstag 06:00 steht auf der Montags-Spalte — genau dort fängt die Kolonne
+ * an zu arbeiten.
+ *
+ * ## Nichts verschwindet
+ *
+ * Ein Einsatz, dessen Beginn NICHT in einem der sieben Tage liegt, wandert
+ * sichtbar nach `ausserhalb` statt still wegzufallen — eine Antwort, die der
+ * Server so eigentlich nicht liefern kann (der Create-Pfad vergleicht die
+ * Woche), aber „kann eigentlich nicht sein" ist kein Renderpfad. Dasselbe
+ * fail-sichtbare Prinzip gilt für die ganze Ableitung: eine unbekannte Zone
+ * oder ein unlesbarer Wochenschlüssel ergeben `art: "unbestimmbar"` mit
+ * Grund, und die Ansicht fällt auf die flache Liste zurück, statt ein leeres
+ * Raster als „nichts geplant" auszugeben.
+ *
+ * ## Die Zeitbeschriftung ist Darstellung, keine Wahrheit
+ *
+ * `zeitText` formatiert einen UTC-Zeitpunkt als Wanduhrzeit der Organisation
+ * über `Intl.DateTimeFormat` mit AUSDRÜCKLICHER Zone und festem `de-DE` —
+ * nie über die Laufzeitumgebung. Die maßgeblichen Werte bleiben die
+ * UTC-Intervalle des Vertrags; sie stehen unverändert als `data`-Attribute im
+ * Markup.
+ */
+import { parseIsoWeekKey } from "@easytree/contracts";
+import type { AssignmentDto } from "@easytree/contracts";
+import {
+ compareLocalBusinessDate,
+ createTimeZone,
+ dayAfter,
+ localBusinessDate,
+ planningWeekDateRange,
+} from "@easytree/domain";
+import type { LocalBusinessDate } from "@easytree/domain";
+
+/** Montag steht per Konstruktion an Index 0 — `planningWeekDateRange.monday`. */
+const WOCHENTAGSNAMEN = [
+ "Montag",
+ "Dienstag",
+ "Mittwoch",
+ "Donnerstag",
+ "Freitag",
+ "Samstag",
+ "Sonntag",
+] as const;
+
+export interface Wochentag {
+ /** Kanonischer Kalendertag als `YYYY-MM-DD` — für `data`-Anker und Schlüssel. */
+ readonly tagKey: string;
+ /** Ausgeschriebener Wochentag, etwa `Montag`. */
+ readonly wochentagsText: string;
+ /** Kalendertag als `TT.MM.`, ohne Jahr — das Jahr trägt die Wochenüberschrift. */
+ readonly datumsText: string;
+ /** Einsätze, deren Beginn in der Organisationszone auf diesen Tag fällt — nach Beginn sortiert. */
+ readonly einsaetze: readonly AssignmentDto[];
+}
+
+export type Wochenraster =
+ | {
+ readonly art: "raster";
+ readonly tage: readonly Wochentag[];
+ /** Einsätze, deren Beginn ausserhalb der sieben Tage liegt — sichtbar, nie verschluckt. */
+ readonly ausserhalb: readonly AssignmentDto[];
+ }
+ | {
+ readonly art: "unbestimmbar";
+ readonly grund: "zone-unbekannt" | "woche-unlesbar";
+ };
+
+function zweistellig(wert: number): string {
+ return String(wert).padStart(2, "0");
+}
+
+function tagKey(tag: LocalBusinessDate): string {
+ return `${String(tag.year).padStart(4, "0")}-${zweistellig(tag.month)}-${zweistellig(tag.day)}`;
+}
+
+/**
+ * Sortierung innerhalb eines Tages: Beginn, dann Ende, dann Id.
+ *
+ * Der Vergleich läuft über `Date.parse`, nicht über die Zeichenkette: der
+ * Vertrag normalisiert die Schreibweise des Instants nicht, und `+02:00`
+ * gegen `Z` sortiert lexikalisch falsch. Die Id als letzter Schlüssel ist ein
+ * schlichter Ordinalvergleich — `localeCompare` hinge an der Umgebung.
+ */
+function nachBeginn(a: AssignmentDto, b: AssignmentDto): number {
+ const beginn = Date.parse(a.interval.startUtc) - Date.parse(b.interval.startUtc);
+ if (beginn !== 0) return beginn;
+ const ende = Date.parse(a.interval.endUtc) - Date.parse(b.interval.endUtc);
+ if (ende !== 0) return ende;
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
+}
+
+/**
+ * Legt die Einsätze einer Woche auf ihre sieben Kalendertage.
+ *
+ * @param fenster Ausschnitt der Serverantwort: Woche, Zone, Einsätze.
+ * @returns Das vollständige Raster, oder `unbestimmbar` mit Grund — nie ein
+ * halbes Raster, in dem Einsätze fehlen.
+ */
+export function wochenraster(fenster: {
+ readonly weekKey: string;
+ readonly timeZone: string;
+ readonly assignments: readonly AssignmentDto[];
+}): Wochenraster {
+ const woche = parseIsoWeekKey(fenster.weekKey);
+ if (!woche.ok) return { art: "unbestimmbar", grund: "woche-unlesbar" };
+
+ const zone = createTimeZone(fenster.timeZone);
+ if (!zone.ok) return { art: "unbestimmbar", grund: "zone-unbekannt" };
+
+ const bereich = planningWeekDateRange(woche.week);
+ const tage: LocalBusinessDate[] = [bereich.monday];
+ for (let i = 1; i < 7; i += 1) {
+ const vortag = tage[i - 1];
+ if (vortag === undefined) break; // unerreichbar; hält noUncheckedIndexedAccess
+ tage.push(dayAfter(vortag));
+ }
+
+ const jeTag: AssignmentDto[][] = tage.map(() => []);
+ const ausserhalb: AssignmentDto[] = [];
+ for (const einsatz of fenster.assignments) {
+ const beginnTag = localBusinessDate(new Date(einsatz.interval.startUtc), zone.timeZone);
+ const index = tage.findIndex((tag) => compareLocalBusinessDate(tag, beginnTag) === 0);
+ if (index === -1) {
+ ausserhalb.push(einsatz);
+ } else {
+ jeTag[index]?.push(einsatz);
+ }
+ }
+
+ return {
+ art: "raster",
+ tage: tage.map((tag, index) => ({
+ tagKey: tagKey(tag),
+ wochentagsText: WOCHENTAGSNAMEN[index] ?? "",
+ datumsText: `${zweistellig(tag.day)}.${zweistellig(tag.month)}.`,
+ einsaetze: [...(jeTag[index] ?? [])].sort(nachBeginn),
+ })),
+ ausserhalb: [...ausserhalb].sort(nachBeginn),
+ };
+}
+
+/**
+ * Wanduhrzeit eines UTC-Zeitpunkts in einer benannten Zone, als `HH:MM`.
+ *
+ * Ausdrücklich `de-DE`, `hour12: false` und die ÜBERGEBENE Zone — nichts an
+ * dieser Formatierung liest die Laufzeitumgebung. Eine unbrauchbare Eingabe
+ * ergibt sichtbar den Rohwert statt einer geratenen Zeit.
+ */
+export function zeitText(instantUtc: string, timeZone: string): string {
+ const zeitpunkt = new Date(instantUtc);
+ if (Number.isNaN(zeitpunkt.getTime())) return instantUtc;
+ try {
+ return new Intl.DateTimeFormat("de-DE", {
+ timeZone,
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(zeitpunkt);
+ } catch {
+ return instantUtc;
+ }
+}
diff --git a/apps/web/test/dispositionswerkbank.test.tsx b/apps/web/test/dispositionswerkbank.test.tsx
new file mode 100644
index 00000000..a2ead894
--- /dev/null
+++ b/apps/web/test/dispositionswerkbank.test.tsx
@@ -0,0 +1,214 @@
+/**
+ * Dispositionswerkbank Slice 1 (EYT-147) — Woche als Achse, Inspector, eine
+ * primaere Aktion.
+ *
+ * ## Gegenmutationen (ausgefuehrt, siehe PR-Beschreibung)
+ *
+ * 1. In `planning-window-view.tsx` `useState(false)` des Inspectors auf
+ * `useState(true)` stellen → „der Inspector ist anfangs geschlossen" rot.
+ * 2. Den Ausloeser unabhaengig vom Stand als `PrimaryAction` rendern →
+ * „im Entwurfszustand ist Veroeffentlichen die einzige primaere Aktion"
+ * rot (und `planungs-werkbank.test.tsx` zaehlt 2).
+ * 3. Die Tageszuordnung in `lib/wochenraster.ts` auf das UTC-Datum stellen →
+ * dort rot (`wochenraster.test.ts`), nicht hier: die Rechnung hat genau
+ * einen Ort.
+ */
+import type { GatewayResult, PlanningGateway, PlanningWindow } from "@easytree/contracts";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { PlanningWindowView } from "../components/planning-window-view";
+import { PlanningGatewayProvider } from "../lib/planning-gateway-provider";
+
+const PERSON = "22222222-2222-4222-8222-222222222222";
+const BAUSTELLE = "33333333-3333-4333-8333-333333333333";
+
+function fenster(teil: Partial): PlanningWindow {
+ return {
+ weekKey: "2026-W36",
+ timeZone: "Europe/Berlin",
+ assignments: [],
+ sourceVersion: null,
+ publishedVersionId: null,
+ resources: {
+ employees: [{ id: PERSON, label: "Anna Berg", active: true }],
+ worksites: [{ id: BAUSTELLE, label: "Baustelle Nord", active: true }],
+ },
+ ...teil,
+ };
+}
+
+function einsatz(id: string, startUtc: string, endUtc: string) {
+ return { id, employeeId: PERSON, worksiteId: BAUSTELLE, interval: { startUtc, endUtc } };
+}
+
+function gatewayMit(result: GatewayResult): PlanningGateway {
+ return {
+ getPlanningWindow: () => Promise.resolve(result),
+ validateDraft: () => {
+ throw new Error("in dieser Ansicht nicht benutzt");
+ },
+ createAssignment: () => {
+ throw new Error("in diesem Test nicht benutzt");
+ },
+ publishPlan: () => {
+ throw new Error("in diesem Test nicht benutzt");
+ },
+ };
+}
+
+function rendern(wert: PlanningWindow, darfVeroeffentlichen = false): void {
+ render(
+
+
+ ,
+ );
+}
+
+afterEach(cleanup);
+
+describe("EYT-147 — die Woche ist die Flaeche", () => {
+ it("zeigt alle sieben Wochentage der Woche aus der Serverantwort", async () => {
+ rendern(fenster({}));
+ const liste = await screen.findByTestId("planungsfenster-liste");
+ const koepfe = [...liste.querySelectorAll(".wochenraster__tagkopf")].map(
+ (kopf) => kopf.textContent ?? "",
+ );
+ expect(koepfe).toHaveLength(7);
+ expect(koepfe[0]).toContain("Montag");
+ expect(koepfe[0]).toContain("31.08.");
+ expect(koepfe[6]).toContain("Sonntag");
+ expect(koepfe[6]).toContain("06.09.");
+ });
+
+ it("legt einen Einsatz auf den Kalendertag seines Beginns in der Organisationszone", async () => {
+ // 22:30 UTC am Montag ist in Berlin bereits Dienstag, 00:30 — die Karte
+ // steht am Dienstag. Eine UTC-Zuordnung legte sie auf den Montag.
+ rendern(
+ fenster({
+ sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" },
+ assignments: [
+ einsatz(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ "2026-08-31T22:30:00.000Z",
+ "2026-09-01T04:00:00.000Z",
+ ),
+ ],
+ }),
+ );
+ const liste = await screen.findByTestId("planungsfenster-liste");
+ const dienstag = liste.querySelector('[data-tag="2026-09-01"]');
+ expect(dienstag).not.toBeNull();
+ const karte = within(dienstag as HTMLElement).getByRole("listitem");
+ expect(karte.getAttribute("data-assignment-id")).toBe("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
+ // Die Karte nennt Baustelle, Person und Wanduhrzeit — nicht die Uuid.
+ expect(karte.textContent).toContain("Baustelle Nord");
+ expect(karte.textContent).toContain("Anna Berg");
+ expect(karte.textContent).toContain("00:30");
+ const montag = liste.querySelector('[data-tag="2026-08-31"]');
+ expect(within(montag as HTMLElement).queryAllByRole("listitem")).toHaveLength(0);
+ });
+
+ it("verschluckt einen Einsatz ausserhalb der Woche nicht, sondern stellt ihn aus", async () => {
+ rendern(
+ fenster({
+ sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" },
+ assignments: [
+ einsatz(
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ "2026-09-14T06:00:00.000Z",
+ "2026-09-14T14:00:00.000Z",
+ ),
+ ],
+ }),
+ );
+ const liste = await screen.findByTestId("planungsfenster-liste");
+ const ausserhalb = liste.querySelector(".wochenraster__ausserhalb");
+ expect(ausserhalb).not.toBeNull();
+ expect(
+ within(ausserhalb as HTMLElement)
+ .getByRole("listitem")
+ .getAttribute("data-assignment-id"),
+ ).toBe("cccccccc-cccc-4ccc-8ccc-cccccccccccc");
+ });
+});
+
+describe("EYT-147 — der Erstellungs-Inspector", () => {
+ it("ist anfangs geschlossen: das Formular steht erst nach „Einsatz anlegen“ im Baum", async () => {
+ rendern(fenster({}));
+ const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen");
+ expect(screen.queryByTestId("einsatzformular")).toBeNull();
+ expect(ausloeser.getAttribute("aria-expanded")).toBe("false");
+
+ await userEvent.click(ausloeser);
+ expect(await screen.findByTestId("einsatzformular")).toBeTruthy();
+ expect(ausloeser.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("setzt beim Oeffnen den Fokus in das erste Bedienelement des Formulars", async () => {
+ rendern(fenster({}));
+ await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen"));
+ await screen.findByTestId("einsatzformular");
+ expect(document.activeElement).toBe(screen.getByTestId("feld-employee"));
+ });
+
+ it("schliesst mit Escape und stellt den Fokus auf den Ausloeser zurueck", async () => {
+ rendern(fenster({}));
+ const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen");
+ await userEvent.click(ausloeser);
+ await screen.findByTestId("einsatzformular");
+
+ await userEvent.keyboard("{Escape}");
+ expect(screen.queryByTestId("einsatzformular")).toBeNull();
+ expect(document.activeElement).toBe(ausloeser);
+ });
+
+ it("schliesst ueber die Schaltflaeche und stellt den Fokus zurueck", async () => {
+ rendern(fenster({}));
+ const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen");
+ await userEvent.click(ausloeser);
+ await userEvent.click(await screen.findByTestId("werkbank-inspector-schliessen"));
+ expect(screen.queryByTestId("einsatzformular")).toBeNull();
+ expect(document.activeElement).toBe(ausloeser);
+ });
+});
+
+describe("EYT-147 — genau eine primaere Aktion je Zustand", () => {
+ it("macht im veroeffentlichbaren Entwurf das Veroeffentlichen zur einzigen primaeren Aktion", async () => {
+ rendern(
+ fenster({ sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" } }),
+ true,
+ );
+ const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen");
+ const primaere = document.querySelectorAll(".eyt-primary-action");
+ expect(primaere).toHaveLength(1);
+ expect(primaere[0]?.getAttribute("data-testid")).toBe("planung-veroeffentlichen");
+ expect(ausloeser.classList.contains("eyt-primary-action")).toBe(false);
+ });
+
+ it("macht ohne Veroeffentlichungsweg das Anlegen zur einzigen primaeren Aktion", async () => {
+ rendern(
+ fenster({
+ sourceVersion: { id: "44444444-4444-4444-8444-444444444444", state: "published" },
+ publishedVersionId: "44444444-4444-4444-8444-444444444444",
+ }),
+ true,
+ );
+ const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen");
+ expect(screen.queryByTestId("planung-veroeffentlichen")).toBeNull();
+ const primaere = document.querySelectorAll(".eyt-primary-action");
+ expect(primaere).toHaveLength(1);
+ expect(primaere[0]).toBe(ausloeser);
+ });
+
+ it("laesst dem Entwurf OHNE planning.publish die Anlage als primaere Aktion", async () => {
+ rendern(
+ fenster({ sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" } }),
+ false,
+ );
+ const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen");
+ expect(screen.queryByTestId("planung-veroeffentlichen")).toBeNull();
+ expect(ausloeser.classList.contains("eyt-primary-action")).toBe(true);
+ });
+});
diff --git a/apps/web/test/planning-publish-action.test.tsx b/apps/web/test/planning-publish-action.test.tsx
index 7e0040b2..29e86760 100644
--- a/apps/web/test/planning-publish-action.test.tsx
+++ b/apps/web/test/planning-publish-action.test.tsx
@@ -112,6 +112,13 @@ describe("Zustandsdarstellung", () => {
expect(marke.textContent).toContain("Entwurf");
});
+ it("zeigt ohne Version KEINE Standmarke — es gibt keinen Stand zu benennen (EYT-147)", () => {
+ // Eine „Entwurf"-Marke ueber einer versionslosen Woche waere erfundene
+ // Information; die Wochenansicht sagt dort bereits „Keine Version".
+ zeichne({ sourceVersionId: null });
+ expect(screen.queryByTestId("planung-stand-marke")).toBeNull();
+ });
+
it("zeigt nach Erfolg den veroeffentlichten Stand mit Textmarke", async () => {
zeichne();
fireEvent.click(aktion() as HTMLElement);
diff --git a/apps/web/test/planning-window-view.test.tsx b/apps/web/test/planning-window-view.test.tsx
index 66058407..dd228333 100644
--- a/apps/web/test/planning-window-view.test.tsx
+++ b/apps/web/test/planning-window-view.test.tsx
@@ -71,6 +71,16 @@ const PLANBARE_WOCHE: PlanningWindow = {
// ein echter Befund aussieht.
afterEach(cleanup);
+/**
+ * Seit EYT-147 steht das Einsatzformular im seitlichen Inspector und ist erst
+ * nach „Einsatz anlegen" im Baum. Der erste Zugriff WARTET auf den Ausloeser
+ * (der erscheint erst mit dem geladenen Fenster), der zweite auf das Formular.
+ */
+async function inspectorOeffnen(): Promise {
+ await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen"));
+ await screen.findByTestId("einsatzformular");
+}
+
describe("PlanningWindowView", () => {
it("zeigt zuerst den Ladezustand", () => {
renderWith({ ok: true, value: LEERE_WOCHE });
@@ -239,7 +249,7 @@ describe("PlanningWindowView", () => {
,
);
- await screen.findByTestId("einsatzformular");
+ await inspectorOeffnen();
await userEvent.selectOptions(
screen.getByTestId("feld-employee"),
@@ -309,7 +319,7 @@ describe("PlanningWindowView", () => {
,
);
- await screen.findByTestId("einsatzformular");
+ await inspectorOeffnen();
await userEvent.selectOptions(
screen.getByTestId("feld-employee"),
@@ -404,7 +414,7 @@ describe("PlanningWindowView", () => {
,
);
- await screen.findByTestId("einsatzformular");
+ await inspectorOeffnen();
// Vor dem Speichern: genau EIN Lesevorgang, und noch keine Zuweisung.
expect(leseversuch).toBe(1);
diff --git a/apps/web/test/werkbank-oberflaechenguards.test.tsx b/apps/web/test/werkbank-oberflaechenguards.test.tsx
new file mode 100644
index 00000000..545a9581
--- /dev/null
+++ b/apps/web/test/werkbank-oberflaechenguards.test.tsx
@@ -0,0 +1,194 @@
+/**
+ * EYT-147 PO-Review-Reparatur — Oberflaechen-Guards der Dispositionswerkbank.
+ *
+ * ## R1 — technische Identifikatoren sind kein Planertext
+ *
+ * Die Werkbank zeigte eine serverseitige Planversions-Id als sichtbaren Satz
+ * („Zuletzt veroeffentlicht: ") und die Publish-Bestaetigung nannte Id
+ * UND rohen ISO-Zeitstempel im Fliesstext. Beides ist Serverwahrheit, aber
+ * keine Planerinformation. Die Guards hier verlangen beides zugleich:
+ *
+ * - KEINE rohe UUID und KEIN roher ISO-Instant im Text der Werkbank;
+ * - DIESELBEN echten Server-Ids weiterhin im vorgesehenen `data-*`-Seam,
+ * an dem `read-through.spec.ts`, `auth-journey` und die Unit-Suiten haengen.
+ *
+ * Der Textvergleich laeuft ueber `textContent` des gesamten Baums — bewusst
+ * einschliesslich visuell versteckter Texte: auch ein `VisuallyHidden` mit
+ * einer UUID waere fuer Screenreader-Nutzer eine zugemutete Id.
+ *
+ * ## R2 — genau EINE Statusmarke fuer den Planstand
+ *
+ * Vor der Reparatur trugen `planungsfenster-stand-abzeichen` (Wochenansicht)
+ * und `planung-stand-marke` (Publish-Baustein) beide ein sichtbares
+ * `StatusBadge` mit derselben Aussage („Entwurf" neben „Entwurf"). Der Guard
+ * zaehlt die `StatusBadge`-Elemente der gerenderten Werkbank und verlangt
+ * genau eines — das der Wochenansicht. Die Marke des Publish-Bausteins bleibt
+ * als unsichtbarer Seam (Testanker, Screenreader, `data-stand`) erhalten.
+ *
+ * ## Gegenmutationen (ausgefuehrt, gemessen, zurueckgenommen — siehe PR)
+ *
+ * 1. `planning-window-view.tsx`: die sichtbare Zeile
+ * `Zuletzt veroeffentlicht: ${fenster.publishedVersionId}` wiederherstellen
+ * → „nennt im Text keine rohe UUID" rot.
+ * 2. `planning-publish-action.tsx`: in `planung-stand-marke` wieder ein
+ * `StatusBadge` rendern → „traegt genau eine Statusmarke" rot.
+ */
+import type { GatewayResult, PlanningGateway, PlanningWindow } from "@easytree/contracts";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { PlanningWindowView } from "../components/planning-window-view";
+import { PlanningGatewayProvider } from "../lib/planning-gateway-provider";
+import {
+ EINSATZ_VOM_SERVER,
+ ENTWURF_VERSION,
+ VEROEFFENTLICHTE_VERSION,
+ fensterMitEntwurf,
+ fensterVeroeffentlicht,
+} from "./helpers/werkbank-daten";
+
+const WOCHE = "2026-W34";
+
+/** RFC-4122-Form, unabhaengig von Gross-/Kleinschreibung. */
+const UUID_MUSTER = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
+/** Roher ISO-Instant (`2026-08-19T12:00…`) — als Text niemals Planer-UI. */
+const ISO_INSTANT_MUSTER = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
+
+/** Entwurf UEBER einer veroeffentlichten Version — beide Server-Ids im Spiel. */
+function fensterEntwurfUeberVeroeffentlicht(): PlanningWindow {
+ return { ...fensterMitEntwurf(WOCHE), publishedVersionId: VEROEFFENTLICHTE_VERSION };
+}
+
+function gatewayMit(
+ antwort: () => GatewayResult,
+ publishPlan?: PlanningGateway["publishPlan"],
+): PlanningGateway {
+ return {
+ getPlanningWindow: () => Promise.resolve(antwort()),
+ validateDraft: () => {
+ throw new Error("in dieser Suite nicht benutzt");
+ },
+ createAssignment: () => {
+ throw new Error("in dieser Suite nicht benutzt");
+ },
+ publishPlan:
+ publishPlan ??
+ (() => {
+ throw new Error("in dieser Suite nicht benutzt");
+ }),
+ };
+}
+
+function rendern(gateway: PlanningGateway, darfVeroeffentlichen: boolean): void {
+ render(
+
+
+ ,
+ );
+}
+
+afterEach(cleanup);
+
+describe("R1 — kein technischer Identifikator im Text der Werkbank", () => {
+ it("nennt im Text keine rohe UUID — die Server-Ids stehen im data-Seam", async () => {
+ rendern(
+ gatewayMit(() => ({ ok: true, value: fensterEntwurfUeberVeroeffentlicht() })),
+ true,
+ );
+ await screen.findByTestId("planungsfenster-liste");
+
+ // Kein Textknoten des Baums traegt eine UUID — sichtbar oder versteckt.
+ expect(document.body.textContent ?? "").not.toMatch(UUID_MUSTER);
+
+ // Und zwar NICHT, weil die Ids fehlen: dieselben echten Server-Ids
+ // stehen weiterhin exakt an ihren technischen Seams.
+ const version = screen.getByTestId("planungsfenster-version");
+ expect(version.getAttribute("data-published-version-id")).toBe(VEROEFFENTLICHTE_VERSION);
+ expect(version.getAttribute("data-source-version-id")).toBe(ENTWURF_VERSION);
+ expect(document.querySelector(`[data-assignment-id="${EINSATZ_VOM_SERVER}"]`)).not.toBeNull();
+ });
+
+ it("nennt im Text keinen rohen ISO-Zeitstempel", async () => {
+ rendern(
+ gatewayMit(() => ({ ok: true, value: fensterEntwurfUeberVeroeffentlicht() })),
+ true,
+ );
+ await screen.findByTestId("planungsfenster-liste");
+ expect(document.body.textContent ?? "").toMatch(/\d{2}:\d{2}/); // Wanduhrzeit ja …
+ expect(document.body.textContent ?? "").not.toMatch(ISO_INSTANT_MUSTER); // … Instant nein.
+ });
+
+ it("haelt auch die Publish-Bestaetigung frei von UUID und Instant — beide bleiben als data-Attribute", async () => {
+ const VERSION_ID = ENTWURF_VERSION;
+ const PUBLISHED_AT = "2026-08-19T12:34:56.000Z";
+ let veroeffentlicht = false;
+ const publishPlan = vi.fn(() => {
+ veroeffentlicht = true;
+ return Promise.resolve({
+ ok: true as const,
+ value: {
+ versionId: VERSION_ID,
+ weekKey: WOCHE,
+ publishedAtUtc: PUBLISHED_AT,
+ assignmentIds: [EINSATZ_VOM_SERVER],
+ },
+ });
+ });
+ rendern(
+ gatewayMit(
+ () => ({
+ ok: true,
+ value: veroeffentlicht ? fensterVeroeffentlicht(WOCHE) : fensterMitEntwurf(WOCHE),
+ }),
+ publishPlan,
+ ),
+ true,
+ );
+
+ await userEvent.click(await screen.findByTestId("planung-veroeffentlichen"));
+ const erfolg = await screen.findByTestId("planung-publish-erfolg");
+
+ expect(erfolg.textContent ?? "").not.toMatch(UUID_MUSTER);
+ expect(erfolg.textContent ?? "").not.toMatch(ISO_INSTANT_MUSTER);
+ // Serverwahrheit unveraendert am Seam: exakt die Id und der Instant der
+ // Serverantwort, nichts Erfundenes.
+ expect(erfolg.getAttribute("data-published-version-id")).toBe(VERSION_ID);
+ expect(erfolg.getAttribute("data-published-at-utc")).toBe(PUBLISHED_AT);
+ // Der ganze Baum bleibt sauber, auch mit sichtbarer Erfolgsleiste.
+ expect(document.body.textContent ?? "").not.toMatch(UUID_MUSTER);
+ });
+});
+
+describe("R2 — genau eine Statusmarke fuer den Planstand", () => {
+ it("traegt im veroeffentlichbaren Entwurf genau EIN StatusBadge — das der Wochenansicht", async () => {
+ rendern(
+ gatewayMit(() => ({ ok: true, value: fensterMitEntwurf(WOCHE) })),
+ true,
+ );
+ await screen.findByTestId("planungsfenster-liste");
+
+ const marken = document.querySelectorAll(".eyt-status-badge");
+ expect(marken).toHaveLength(1);
+ expect(marken[0]?.getAttribute("data-testid")).toBe("planungsfenster-stand-abzeichen");
+
+ // Der Seam des Publish-Bausteins bleibt: Testanker, `data-stand` und
+ // Screenreader-Text existieren weiter — nur die zweite SICHTBARE Marke
+ // ist weg.
+ const seam = screen.getByTestId("planung-stand-marke");
+ expect(seam.getAttribute("data-stand")).toBe("draft");
+ expect(seam.textContent ?? "").toContain("Entwurf");
+ });
+
+ it("traegt im veroeffentlichten Stand genau EIN StatusBadge", async () => {
+ rendern(
+ gatewayMit(() => ({ ok: true, value: fensterVeroeffentlicht(WOCHE) })),
+ false,
+ );
+ await screen.findByTestId("planungsfenster-liste");
+
+ const marken = document.querySelectorAll(".eyt-status-badge");
+ expect(marken).toHaveLength(1);
+ expect(marken[0]?.getAttribute("data-testid")).toBe("planungsfenster-stand-abzeichen");
+ });
+});
diff --git a/apps/web/test/werkbank-serverwahrheit.test.tsx b/apps/web/test/werkbank-serverwahrheit.test.tsx
index fc705df4..c6a21049 100644
--- a/apps/web/test/werkbank-serverwahrheit.test.tsx
+++ b/apps/web/test/werkbank-serverwahrheit.test.tsx
@@ -75,6 +75,12 @@ vi.mock("next/navigation", async () => {
const MITTWOCH_KW34 = new Date("2026-08-19T12:00:00.000Z");
const RECHTE = ["planning.read", "planning.write"];
+// Sieben userEvent-Schritte je Reise (seit EYT-147 einer mehr: der Inspector
+// wird zuerst geoeffnet) liegen unter Volllast ueber dem 5-s-Standard —
+// gemessen 31.08.2026 (5133 ms, nur unter parallelem turbo-Lauf). Das Budget
+// ist Robustheit, keine Abschwaechung: es aendert keine Zusicherung.
+vi.setConfig({ testTimeout: 15_000 });
+
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(MITTWOCH_KW34);
@@ -185,6 +191,10 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach
* sie es auch.
*/
async function einsatzAnlegen(datum: string): Promise {
+ // Seit EYT-147 steht das Formular im Inspector: erst den Ausloeser
+ // druecken (er erscheint mit dem geladenen Fenster), dann warten die
+ // Feldzugriffe wie zuvor auf einen ZUSTAND, nicht auf eine Frist.
+ await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen"));
await userEvent.selectOptions(await screen.findByTestId("feld-employee"), PERSON_ID);
await userEvent.selectOptions(screen.getByTestId("feld-worksite"), BAUSTELLE_ID);
await userEvent.type(screen.getByTestId("feld-datum"), datum);
diff --git a/apps/web/test/wochenraster.test.ts b/apps/web/test/wochenraster.test.ts
new file mode 100644
index 00000000..a5050374
--- /dev/null
+++ b/apps/web/test/wochenraster.test.ts
@@ -0,0 +1,195 @@
+/**
+ * Wochenraster der Dispositionswerkbank (EYT-147 Slice 1).
+ *
+ * Feste Wochen, feste Zeitpunkte — kein `new Date()` ohne Argument. Die
+ * tragenden Zusicherungen:
+ *
+ * - Der Kalendertag eines Einsatzes folgt der ZONE der Organisation, nicht dem
+ * UTC-Datum. Gegenmutation: in `wochenraster` den Beginn per
+ * `startUtc.slice(0, 10)` einem Tag zuordnen → „22:30 UTC ist schon der
+ * Folgetag in Berlin" geht rot.
+ * - Kein Einsatz verschwindet: was nicht in die Woche fällt, steht in
+ * `ausserhalb`. Gegenmutation: den `ausserhalb`-Zweig zu `continue` machen →
+ * die Zählzusicherung geht rot.
+ * - Die Tagessortierung hängt am BEGINN, nicht an der Id: die Fixtures
+ * widersprechen der Id-Reihenfolge absichtlich (Sortierschlüssel dürfen
+ * nicht vom einverstandenen Tiebreak maskiert werden).
+ */
+import { describe, expect, it } from "vitest";
+
+import type { AssignmentDto } from "@easytree/contracts";
+
+import { wochenraster, zeitText } from "../lib/wochenraster";
+
+const ZONE = "Europe/Berlin";
+
+function einsatz(id: string, startUtc: string, endUtc: string): AssignmentDto {
+ return {
+ id,
+ employeeId: "33333333-3333-4333-8333-333333333333",
+ worksiteId: "44444444-4444-4444-8444-444444444444",
+ interval: { startUtc, endUtc },
+ } as AssignmentDto;
+}
+
+describe("wochenraster", () => {
+ it("legt die Woche 2026-W36 als Montag 31.08. bis Sonntag 06.09. aus", () => {
+ const raster = wochenraster({ weekKey: "2026-W36", timeZone: ZONE, assignments: [] });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+
+ expect(raster.tage).toHaveLength(7);
+ expect(raster.tage.map((t) => t.wochentagsText)).toEqual([
+ "Montag",
+ "Dienstag",
+ "Mittwoch",
+ "Donnerstag",
+ "Freitag",
+ "Samstag",
+ "Sonntag",
+ ]);
+ expect(raster.tage[0]?.datumsText).toBe("31.08.");
+ expect(raster.tage[0]?.tagKey).toBe("2026-08-31");
+ expect(raster.tage[6]?.datumsText).toBe("06.09.");
+ expect(raster.tage[6]?.tagKey).toBe("2026-09-06");
+ expect(raster.ausserhalb).toHaveLength(0);
+ });
+
+ it("überquert die Jahresgrenze: 2026-W53 endet am 03.01.2027", () => {
+ const raster = wochenraster({ weekKey: "2026-W53", timeZone: ZONE, assignments: [] });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+ expect(raster.tage[0]?.tagKey).toBe("2026-12-28");
+ expect(raster.tage[6]?.tagKey).toBe("2027-01-03");
+ });
+
+ it("ordnet einen Einsatz dem Kalendertag seines Beginns in der Organisationszone zu", () => {
+ // 22:30 UTC am 31.08. ist in Berlin bereits der 01.09., 00:30 — der
+ // Einsatz gehört auf den DIENSTAG. Eine UTC-Datumszuordnung legte ihn auf
+ // den Montag.
+ const nachtbeginn = einsatz(
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ "2026-08-31T22:30:00.000Z",
+ "2026-09-01T04:00:00.000Z",
+ );
+ const raster = wochenraster({
+ weekKey: "2026-W36",
+ timeZone: ZONE,
+ assignments: [nachtbeginn],
+ });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+ expect(raster.tage[0]?.einsaetze).toHaveLength(0);
+ expect(raster.tage[1]?.einsaetze.map((e) => e.id)).toEqual([nachtbeginn.id]);
+ });
+
+ it("hält eine Nachtschicht am Tag ihres Beginns — dieselbe Regel wie planningWeekOf", () => {
+ // Freitag 20:00 Berlin bis Samstag 04:00 Berlin: die Karte steht am Freitag.
+ const nachtschicht = einsatz(
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ "2026-09-04T18:00:00.000Z",
+ "2026-09-05T02:00:00.000Z",
+ );
+ const raster = wochenraster({
+ weekKey: "2026-W36",
+ timeZone: ZONE,
+ assignments: [nachtschicht],
+ });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+ expect(raster.tage[4]?.einsaetze.map((e) => e.id)).toEqual([nachtschicht.id]);
+ expect(raster.tage[5]?.einsaetze).toHaveLength(0);
+ });
+
+ it("sortiert einen Tag nach Beginn — gegen die Id-Reihenfolge", () => {
+ // Die Ids sind absichtlich GEGENLÄUFIG zum Beginn: bestünde die Sortierung
+ // aus dem Id-Tiebreak allein, wäre die Reihenfolge genau falsch.
+ const spaet = einsatz(
+ "11111111-0000-4000-8000-000000000001",
+ "2026-09-02T12:00:00.000Z",
+ "2026-09-02T14:00:00.000Z",
+ );
+ const frueh = einsatz(
+ "99999999-0000-4000-8000-000000000009",
+ "2026-09-02T06:00:00.000Z",
+ "2026-09-02T08:00:00.000Z",
+ );
+ const raster = wochenraster({
+ weekKey: "2026-W36",
+ timeZone: ZONE,
+ assignments: [spaet, frueh],
+ });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+ expect(raster.tage[2]?.einsaetze.map((e) => e.id)).toEqual([frueh.id, spaet.id]);
+ });
+
+ it("verschluckt keinen Einsatz ausserhalb der Woche — er steht sichtbar in ausserhalb", () => {
+ const fremd = einsatz(
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ "2026-09-14T06:00:00.000Z",
+ "2026-09-14T14:00:00.000Z",
+ );
+ const eigen = einsatz(
+ "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ "2026-09-01T06:00:00.000Z",
+ "2026-09-01T14:00:00.000Z",
+ );
+ const raster = wochenraster({
+ weekKey: "2026-W36",
+ timeZone: ZONE,
+ assignments: [fremd, eigen],
+ });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+ expect(raster.ausserhalb.map((e) => e.id)).toEqual([fremd.id]);
+ const verteilt = raster.tage.reduce((summe, tag) => summe + tag.einsaetze.length, 0);
+ expect(verteilt + raster.ausserhalb.length).toBe(2);
+ });
+
+ it("meldet eine unbekannte Zone als unbestimmbar statt ein leeres Raster zu liefern", () => {
+ const raster = wochenraster({
+ weekKey: "2026-W36",
+ timeZone: "Nicht/Vorhanden",
+ assignments: [],
+ });
+ expect(raster).toEqual({ art: "unbestimmbar", grund: "zone-unbekannt" });
+ });
+
+ it("meldet einen unlesbaren Wochenschlüssel als unbestimmbar", () => {
+ const raster = wochenraster({ weekKey: "keine-woche", timeZone: ZONE, assignments: [] });
+ expect(raster).toEqual({ art: "unbestimmbar", grund: "woche-unlesbar" });
+ });
+
+ it("ordnet in der DST-Endwoche den Umstellungssonntag korrekt zu", () => {
+ // 2026-W43: 19.10.–25.10.2026; am Sonntag, 25.10., endet die Sommerzeit.
+ // 00:30 UTC ist 02:30 CEST — noch Sonntag; die Karte steht am Sonntag.
+ const umstellung = einsatz(
+ "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
+ "2026-10-25T00:30:00.000Z",
+ "2026-10-25T06:00:00.000Z",
+ );
+ const raster = wochenraster({
+ weekKey: "2026-W43",
+ timeZone: ZONE,
+ assignments: [umstellung],
+ });
+ if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`);
+ expect(raster.tage[6]?.tagKey).toBe("2026-10-25");
+ expect(raster.tage[6]?.einsaetze.map((e) => e.id)).toEqual([umstellung.id]);
+ });
+});
+
+describe("zeitText", () => {
+ it("formatiert einen UTC-Zeitpunkt als Berliner Wanduhrzeit", () => {
+ expect(zeitText("2026-09-01T06:00:00.000Z", ZONE)).toBe("08:00");
+ });
+
+ it("formatiert Winterzeit mit dem Winter-Versatz", () => {
+ expect(zeitText("2026-12-01T06:00:00.000Z", ZONE)).toBe("07:00");
+ });
+
+ it("gibt einen unlesbaren Zeitpunkt roh zurück statt zu raten", () => {
+ expect(zeitText("keine-zeit", ZONE)).toBe("keine-zeit");
+ });
+
+ it("gibt bei unbekannter Zone den Rohwert zurück", () => {
+ expect(zeitText("2026-09-01T06:00:00.000Z", "Nicht/Vorhanden")).toBe(
+ "2026-09-01T06:00:00.000Z",
+ );
+ });
+});
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/01-angemeldete-appshell.png b/docs/evidence/2026-08-31-eyt-147-slice-1/01-angemeldete-appshell.png
new file mode 100644
index 00000000..39062d13
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/01-angemeldete-appshell.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/02-stundensatzverwaltung.png b/docs/evidence/2026-08-31-eyt-147-slice-1/02-stundensatzverwaltung.png
new file mode 100644
index 00000000..8d25d011
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/02-stundensatzverwaltung.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/03-benutzer-b-ohne-zugang.png b/docs/evidence/2026-08-31-eyt-147-slice-1/03-benutzer-b-ohne-zugang.png
new file mode 100644
index 00000000..618fd71a
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/03-benutzer-b-ohne-zugang.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/03-satzabloesung.png b/docs/evidence/2026-08-31-eyt-147-slice-1/03-satzabloesung.png
new file mode 100644
index 00000000..d67573bc
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/03-satzabloesung.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/04-feld-shell-320.png b/docs/evidence/2026-08-31-eyt-147-slice-1/04-feld-shell-320.png
new file mode 100644
index 00000000..a6497b87
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/04-feld-shell-320.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/04-planung-entwurf.png b/docs/evidence/2026-08-31-eyt-147-slice-1/04-planung-entwurf.png
new file mode 100644
index 00000000..d1b95b08
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/04-planung-entwurf.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/05-feld-shell-375.png b/docs/evidence/2026-08-31-eyt-147-slice-1/05-feld-shell-375.png
new file mode 100644
index 00000000..62c19d25
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/05-feld-shell-375.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/05-planung-veroeffentlicht.png b/docs/evidence/2026-08-31-eyt-147-slice-1/05-planung-veroeffentlicht.png
new file mode 100644
index 00000000..43747f8f
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/05-planung-veroeffentlicht.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/06-planung-zweiter-kontext.png b/docs/evidence/2026-08-31-eyt-147-slice-1/06-planung-zweiter-kontext.png
new file mode 100644
index 00000000..46c241e0
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/06-planung-zweiter-kontext.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/07-kosten-snapshot.png b/docs/evidence/2026-08-31-eyt-147-slice-1/07-kosten-snapshot.png
new file mode 100644
index 00000000..77334d4e
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/07-kosten-snapshot.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/08-kosten-zweiter-kontext.png b/docs/evidence/2026-08-31-eyt-147-slice-1/08-kosten-zweiter-kontext.png
new file mode 100644
index 00000000..425b6f1d
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/08-kosten-zweiter-kontext.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/08b-member-ohne-kostenrecht.png b/docs/evidence/2026-08-31-eyt-147-slice-1/08b-member-ohne-kostenrecht.png
new file mode 100644
index 00000000..ae644581
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/08b-member-ohne-kostenrecht.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/09-kosten-gefiltert.png b/docs/evidence/2026-08-31-eyt-147-slice-1/09-kosten-gefiltert.png
new file mode 100644
index 00000000..67f8d09f
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/09-kosten-gefiltert.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/10-kosten-gefiltert-zweiter.png b/docs/evidence/2026-08-31-eyt-147-slice-1/10-kosten-gefiltert-zweiter.png
new file mode 100644
index 00000000..69692f41
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/10-kosten-gefiltert-zweiter.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1440.png b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1440.png
new file mode 100644
index 00000000..3d1742b7
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1440.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1920.png b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1920.png
new file mode 100644
index 00000000..69272131
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1920.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/12-werkbank-inspector-1440.png b/docs/evidence/2026-08-31-eyt-147-slice-1/12-werkbank-inspector-1440.png
new file mode 100644
index 00000000..1a866eff
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/12-werkbank-inspector-1440.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/13-werkbank-einsatz-serverbestaetigt.png b/docs/evidence/2026-08-31-eyt-147-slice-1/13-werkbank-einsatz-serverbestaetigt.png
new file mode 100644
index 00000000..777dad4a
Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/13-werkbank-einsatz-serverbestaetigt.png differ
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/README.md b/docs/evidence/2026-08-31-eyt-147-slice-1/README.md
new file mode 100644
index 00000000..48b8015f
--- /dev/null
+++ b/docs/evidence/2026-08-31-eyt-147-slice-1/README.md
@@ -0,0 +1,121 @@
+# EYT-147 Slice 1 — Dispositionswerkbank: Evidenzpaket
+
+> **Stand:** 31.08.2026, Europe/Berlin
+> **Basis:** `origin/master` = `f8e96e4ceb4f00ae5f5ac777c6eb47aa8f766d6f` (Merge PR #100)
+> **Branch:** `feat/eyt-147-dispositionswerkbank-slice-1`
+> **Evidenzstufe dieses Pakets:** lokale Läufe auf dem Entwicklungsrechner (colima).
+> Der ausgeführte CI-Lauf am PR-Head ist die höhere Stufe und wird im PR nachgetragen.
+
+## Was der Slice liefert
+
+`Planung öffnen → aktuelle Woche sofort verstehen → Einsatz räumlich in der Woche sehen →
+Einsatz über den Inspector anlegen → bestätigten Serverzustand sehen → Entwurf eindeutig
+erkennen → veröffentlichen → veröffentlichten Zustand erkennen → Reload zeigt denselben
+serverseitigen Zustand.`
+
+`Edit` existiert im Serververtrag nicht und wurde **nicht erfunden** — siehe
+[`ui-parity-matrix.md`](ui-parity-matrix.md) (CAPABILITY_GAP).
+
+## Lokale Läufe (Befehle und gemessene Ergebnisse)
+
+| Gate | Befehl | Ergebnis |
+| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| Format | `git ls-files -z \| xargs -0 pnpm exec prettier --ignore-unknown --check` | exit 0 |
+| Lint + Typen | `pnpm exec turbo run lint typecheck --force` | `16 successful, 16 total`, `Cached: 0` |
+| Unit-Tests | `EASYTREE_TEST_DB_URL= … pnpm exec turbo run test --force --env-mode=loose` | `10 successful, 10 total`, `Cached: 0` — Web: 40 Dateien / 474 Tests |
+| Build | `env -u EASYTREE_API_PROXY_TARGET pnpm exec turbo run build --force` | `6 successful`, `Cached: 0` |
+| web-smoke (Playwright) | `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3001 pnpm exec playwright test` | **29 passed** (shell-smoke 25 + planungswerkbank 4) |
+| read-through (lokal emuliert) | Phasen aus `scripts/read-through-harness.sh`; Abweichung: `supabase start -x studio -x vector -x logflare` (vector startet unter colima nicht) | Phase 13: **7 passed** · Schreibpfad: **6 passed** · Nachweis 7: **1 passed** |
+| auth-journey (lokal, echte GoTrue-Identität) | `pnpm exec playwright test -c e2e/auth-journey/config.ts` nach `supabase db reset` | **5 passed**, Teardown `restzeilen=0` |
+
+Anmerkung Unit-Gate: auf diesem Rechner lauscht der colima-Portforward auf 54322 mit
+einem fremden Datenbestand; die Integrationssuiten liefen deshalb mit ausdrücklich
+unerreichbarer `EASYTREE_TEST_DB_URL` im dokumentierten `mode=local`-Skip (CI-Parität:
+der Pflichtjob `unit-tests` hat ebenfalls keine erreichbare DB; `db-gates` fährt die
+Suiten `mode=required` gegen den echten Stack).
+
+## Gegenmutationen (ausgeführt, gemessen, zurückgenommen)
+
+| # | Mutation | Erwartet rot | Gemessen |
+| --- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------- |
+| 1 | `lib/wochenraster.ts`: Tageszuordnung über das UTC-Datum (`startUtc.slice(0,10)`) statt `localBusinessDate` | „ordnet einen Einsatz dem Kalendertag seines Beginns in der Organisationszone zu" | 1 failed / 12 passed |
+| 2 | `lib/wochenraster.ts`: `ausserhalb`-Zweig zu `continue` (Einsatz verschluckt) | „verschluckt keinen Einsatz ausserhalb der Woche" | 1 failed / 12 passed |
+| 3 | `lib/wochenraster.ts`: Tagessortierung nur über die Id | „sortiert einen Tag nach Beginn — gegen die Id-Reihenfolge" | 1 failed / 12 passed |
+| 4 | `planning-window-view.tsx`: Inspector `useState(true)` (anfangs offen) | „ist anfangs geschlossen …" | 4 failed / 6 passed |
+| 5 | `planning-window-view.tsx`: Ausloeser unbedingt als `PrimaryAction` | CTA-Exklusivität in `dispositionswerkbank.test.tsx` UND `planungs-werkbank.test.tsx` („GENAU EIN … CTA") | 2 failed (je Datei 1) |
+
+Jede Mutation wurde per Sicherungskopie eingespielt und byte-identisch zurückgenommen
+(`diff` leer; die Dateien waren zum Zeitpunkt der Mutationen uncommittet, ein
+`git diff`-Beleg existiert deshalb nicht — Sicherungskopie-Verfahren nach
+Projektgedächtnis `git-checkout-reverts-to-commit-not-worktree`).
+
+## Browser-Evidenz (aus der real ausgeführten auth-journey, lokaler Lauf)
+
+Alle PNGs stammen aus `apps/web/test-results/auth-journey/` des grünen Laufs — keine
+Mockups, keine statischen Designbilder.
+
+| Datei | Zeigt |
+| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `11-werkbank-planung-1440.png` / `…-1920.png` | Dispositionswerkbank bei 1440/1920: Toolbar, Wochenachse Mo–So, Einsatzkarte am Montag, Entwurfszustand, genau ein primärer CTA |
+| `04-planung-entwurf.png` | Entwurfszustand (Badge + Text + gestrichelte Karten) |
+| `12-werkbank-inspector-1440.png` | Erstellungs-Inspector offen, Fokus im ersten Formularfeld, versionslose Woche ehrlich als „Keine Version" |
+| `13-werkbank-einsatz-serverbestaetigt.png` | Serverbestätigte Karte am Dienstag 18.08. nach realem `POST /planung/einsaetze` (201), Formular geleert, Erfolgsmeldung |
+| `05-planung-veroeffentlicht.png` | Serverantwort der Veröffentlichung (Banner ohne Id im Text; die Versions-Id liegt in `data-published-version-id`); der Kopf zeigt noch den Moment VOR dem Read-through — die Reload-Zusicherung direkt danach misst `data-stand="veroeffentlicht"` |
+| `06-planung-zweiter-kontext.png` | Zweiter Browserkontext, dieselbe veröffentlichte Versions-Id |
+| `04-feld-shell-320.png` / `05-feld-shell-375.png` | Feld-Shell-Grenze unverändert |
+
+Barrierefreiheit im Lauf: axe (wcag2a/2aa/21a/21aa/best-practice) bei 1440×900,
+1920×1080 und 720×450 (200-%-Äquivalent) = 0 Verstöße; sichtbarer Fokus je tabbarem
+Element bei 1440 und 1920; genau eine `main`-Landmark; ≤ 1 primärer CTA; horizontaler
+Überlauf ≤ 1 px. Der automatisierte 720-px-Reflow ist NICHT identisch mit echtem
+Browserzoom; der menschliche 200-%-Check bleibt Reviewpunkt des PO.
+
+## Serverwahrheit im Lauf
+
+- UI-Create (neu, Schritt 9c1b): `POST /api/v1/planung/einsaetze` → 201, Karte erst nach
+ Read-through, Reload zeigt dieselbe Server-Id, Woche W34 wird `entwurf`.
+- Publish (Schritt 9d): veröffentlichte Versions-Id == vorher angezeigte Entwurfs-Id
+ (`…e251`), Reload → `data-stand="veroeffentlicht"`, Publish-Knopf weg, zweiter
+ Browserkontext mit frischem Login sieht dieselbe Id, API-Wiederholung → 409
+ `already-published`.
+- Negativ: B ohne Mitgliedschaft → `planung-org-erforderlich` + Publish-POST 403;
+ member ohne `planning.read` → `planung-forbidden` + `GET /planung/fenster` 403;
+ ohne `costs.read` keine Kosten-Chunks; PostgREST-Angriffe unverändert abgeriegelt.
+
+## PO-Review-Reparatur (31.08.2026, zweiter Durchgang)
+
+Reparatur-Slice vor der erneuten visuellen PO-Abnahme. Vier UI-Findings, kein
+fachlicher Eingriff; alle Screenshots dieses Pakets wurden aus einem frischen
+gruenen `auth-journey`-Lauf NACH der Reparatur regeneriert.
+
+| Finding | Reparatur |
+| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| R1 — Planversions-UUID sichtbar („Zuletzt veroeffentlicht: ") und roher ISO-Zeitstempel in der Publish-Bestaetigung | Sichtbarer Text traegt keine Id und keinen Instant mehr; die echten Server-Ids stehen unveraendert in `data-source-version-id` / `data-published-version-id`, der Publish-Instant neu in `data-published-at-utc` |
+| R2 — doppelte dominante Statusmarke (Abzeichen der Wochenansicht UND `StatusBadge` in `planung-stand-marke`) | Genau EIN sichtbares `StatusBadge` (Wochenansicht); `planung-stand-marke` bleibt als unsichtbarer Seam (Testanker, Screenreader-Text, `data-stand`) erhalten |
+| R3 — Einsatzformular als ungestylte Browser-Defaults | Formular traegt Basisdesign-v2-Tokens (Feldgruppen, Beginn/Ende nebeneinander, Meldungs-Toene); Semantik, Testanker, Validierung und Zeitzonenlogik unveraendert |
+| R4 — ASCII-Formen im sichtbaren Text (`Veroeffentlicht`, `Ausserhalb`, `fuer`) | Sichtbare Texte auf korrektes Deutsch; technische Werte (`data-stand="veroeffentlicht"`, Testids, Dateinamen) unveraendert |
+
+Neuer Guard: `apps/web/test/werkbank-oberflaechenguards.test.tsx` (UUID-/Instant-
+Leck ueber `textContent` des gesamten Baums, Seam-Erhalt, Badge-Zaehlung).
+Gegenmutationen ausgefuehrt und byte-identisch zurueckgenommen
+(Sicherungskopie-Verfahren, Dateien waren uncommittet):
+
+| # | Mutation | Gemessen |
+| --- | ---------------------------------------------------------------------------------- | ---------------------------------- |
+| 1 | Sichtbare Zeile `Zuletzt veroeffentlicht: ${publishedVersionId}` wiederhergestellt | 2 failed / 3 passed (UUID-Guards) |
+| 2 | Zweites sichtbares Badge in `planung-stand-marke` wiederhergestellt | 2 failed / 3 passed (Badge-Guards) |
+
+Gemessene Laeufe dieses Durchgangs (dieselben Befehle wie oben): Format exit 0;
+`turbo run lint typecheck --force` = 16 successful / Cached: 0;
+`turbo run test --force --env-mode=loose` (Test-DB unerreichbar, `mode=local`-Skip
+wie im Pflichtjob `unit-tests`) = 10 successful / Cached: 0 — Web direkt:
+41 Dateien / 479 Tests; `turbo run build --force` (ohne `EASYTREE_API_PROXY_TARGET`)
+= 6 successful / Cached: 0; web-smoke Playwright = 29 passed; auth-journey lokal
+(frischer `db reset`, echte GoTrue-Identitaet) = 5 passed, Teardown `restzeilen=0`.
+`read-through` und `db-gates` sind lokal nicht CI-aequivalent reproduzierbar
+(UNVERIFIED_LOCAL) — der ausgefuehrte CI-Lauf am PR-Head ist dafuer die Evidenz.
+
+Hinweis Datums-/Zeitfelder: die nativen `date`/`time`-Eingaben folgen der
+Browsersprache; die `mm/dd/yyyy`-Platzhalter in den Screenshots stammen aus dem
+en-US-Chromium des Playwright-Laufs, ein deutscher Browser zeigt TT.MM.JJJJ.
+Massgeblich bleibt die Organisationszeitzone (`zuIntervall`, unveraendert).
diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/ui-parity-matrix.md b/docs/evidence/2026-08-31-eyt-147-slice-1/ui-parity-matrix.md
new file mode 100644
index 00000000..5c05ea92
--- /dev/null
+++ b/docs/evidence/2026-08-31-eyt-147-slice-1/ui-parity-matrix.md
@@ -0,0 +1,54 @@
+# EYT-147 Slice 1 — UI-Parity-Matrix
+
+> **Stand:** 31.08.2026, Europe/Berlin
+> **Baseline-Head:** `f8e96e4ceb4f00ae5f5ac777c6eb47aa8f766d6f` (`origin/master`, Merge von PR #100)
+> **Quelle der Operationen:** `packages/contracts/openapi/v1.json` am Baseline-Head, abgeglichen
+> gegen die Routen-Wahrheit `apps/api/test/openapi-route-conformance.test.ts`
+> (`NOT_YET_IMPLEMENTED`). Nur belegte Funktionen stehen als REAL.
+
+## Methode
+
+Jede Vertragsoperation bekommt genau eine Zeile. `Server` sagt, ob am Baseline-Head eine
+registrierte Route existiert (REAL) oder die Operation in `NOT_YET_IMPLEMENTED` steht
+(NICHT IMPLEMENTIERT). `UI heute` beschreibt die reale Oberfläche am Baseline-Head.
+`Slice 1` ist die Entscheidung dieses Inkrements. Ein „Nicht-UI-Systempfad" ist begründet,
+nicht behauptet.
+
+## Admin-/Werkbank-Operationen
+
+| Operation | Server | UI heute (Baseline) | Slice 1 |
+| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GET /planung/fenster` — Planungsfenster lesen | REAL (`PlanningController`) | REAL — `/planung` lädt die Woche, flache Einsatzliste | **REDESIGN** — Woche als räumliche Tagesachse (Mo–So), Einsätze als Karten am realen Tag |
+| `POST /planung/einsaetze` — Einsatz anlegen | REAL | REAL — Formular unten in der Karte, immer offen | **INTEGRATE** — dasselbe Formular, unverändert validiert, im seitlichen Inspector; Öffnung über `+ Einsatz anlegen` |
+| `POST /planung/versionen` — Plan veröffentlichen | REAL | REAL — `PlanningPublishAction` mit Serverwahrheit | **INTEGRATE** — unverändert; als primäre Aktion des Entwurfszustands positioniert |
+| `POST /planung/entwuerfe/validierung` — Entwurf prüfen | REAL | KEINE — kein UI-Aufrufer | **Nicht-UI-Systempfad, begründet:** Die Prüfung läuft fachlich identisch im Publish-Pfad; die Publish-Ablehnungen (409-URNs) sind die sichtbare Antwort. Ein eigener „Prüfen"-Knopf ohne PO-Entscheid wäre eine zweite gleichgewichtete Aktion neben der einen Primäraktion. Bleibt EYT-147-Restumfang. |
+| Assignment **bearbeiten** | **EXISTIERT NICHT** — kein Update-Command im Port (`packages/contracts/src/planning/gateway.ts`: nur `getPlanningWindow`, `validateDraft`, `createAssignment`, `publishPlan`) | keine | **CAPABILITY_GAP** — wird nicht erfunden. Slice 1 = anzeigen → anlegen → bestätigen → Entwurf → veröffentlichen → Reload. Karten sind nicht klickbar-editierbar. |
+| Assignment **löschen** | EXISTIERT NICHT (kein Command, DB: kein `delete`-Grant, „born a draft") | keine | **CAPABILITY_GAP** — nicht erfunden. |
+| Drag&Drop-Umdisposition | EXISTIERT NICHT (kein Move-/Update-Command) | keine | **CAPABILITY_GAP** — kein Drag&Drop ohne idempotenten Server-Command (Auftrag §11). |
+| Konfliktberechnung/-anzeige je Karte | Vertrag liefert `PlanningConflictDto` nur als **Publish-/Validierungsantwort**, nicht im `PlanningWindow` | Publish-Ablehnung `blocking-conflict` sichtbar | **PRESERVE** — Konflikt bleibt Publish-Antwort auf Planebene; kein erfundener Karten-Konfliktstatus. |
+| Entwurf/Veröffentlicht-Status | REAL — Vertrag trägt Status **nur auf Planversionsebene** (`sourceVersion.state`, `publishedVersionId`), nicht je Assignment | REAL — Textzeile + `StatusBadge` | **REDESIGN** — Status prominent auf Wochen-/Planebene (Text + Glyphe + Farbe); KEIN erfundener Einzelkarten-Status. |
+| `GET /auth/session` / `POST /auth/login` / `POST /auth/logout` | REAL | REAL — `/anmelden`, Shell, `SessionProvider` | **PRESERVE** |
+| `GET /kosten/mitarbeiter` | REAL | REAL — `/kosten/stundensaetze` | **PRESERVE** (nur mit `costs.read`) |
+| `GET /kosten/stundensaetze/{employeeId}` | REAL | REAL — Satzverwaltung | **PRESERVE** |
+| `POST /kosten/stundensaetze` | REAL | REAL — Satzformular | **PRESERVE** |
+| `GET /kosten/planversionen` | REAL | REAL — Kostenfläche | **PRESERVE** |
+| `GET /kosten/planversionen/{id}/baustellen` | REAL | REAL — Kostenfläche | **PRESERVE** |
+| `POST /kosten/snapshots` | REAL | REAL — Kostenfläche | **PRESERVE** |
+| `GET /kosten/snapshots/{snapshotId}` | REAL | REAL — Kostenfläche | **PRESERVE** |
+
+## Mitarbeiter-Operationen (nicht Slice 1, nicht Werkbank)
+
+| Operation | Server | Einordnung |
+| ------------------------------ | ------------------------------------------- | ------------------------------------------------------------ |
+| `GET /einsatz/plan` | NICHT IMPLEMENTIERT (`NOT_YET_IMPLEMENTED`) | EYT-81; Feld-Shell zeigt den ehrlichen Leerzustand (EYT-113) |
+| `POST /einsatz/bestaetigungen` | NICHT IMPLEMENTIERT | EYT-14/EYT-81 |
+| `POST /einsatz/ablehnungen` | NICHT IMPLEMENTIERT | EYT-14/EYT-81 |
+| `POST /einsatz/zeiten/start` | NICHT IMPLEMENTIERT | EYT-14 |
+| `POST /einsatz/zeiten/stopp` | NICHT IMPLEMENTIERT | EYT-14 |
+
+## Bewusst NICHT sichtbar gemacht (Reality Boundaries, Auftrag §10)
+
+Wetter, Equipment, Arbeitszeiterfassung, Mitarbeiter-Bestätigung/-Ablehnung,
+Routenoptimierung, Solver, Ressourcenplanung, neue Kostenberechnungen: für keine dieser
+Fähigkeiten existiert am Baseline-Head ein Server-/Domain-Pfad — sie erscheinen nicht in
+der Werkbank, auch nicht als Platzhalter.
diff --git a/supabase/migrations/20260901101624_0019_worksite_days.sql b/supabase/migrations/20260901101624_0019_worksite_days.sql
new file mode 100644
index 00000000..061fba2c
--- /dev/null
+++ b/supabase/migrations/20260901101624_0019_worksite_days.sql
@@ -0,0 +1,774 @@
+-- Migration 0019_worksite_days — Datenfundament der baustellenzentrierten Planung
+-- (EYT-125 Architektur, EYT-147 Slice, Meilenstein M1)
+--
+-- ANLASS
+-- ---------------------------------------------------------------------------
+-- Die Planung war bisher personenzentriert: eine Zuweisung hing an einer Woche
+-- und einer Person, ein BAUSTELLENTAG existierte nirgends als Gegenstand. Damit
+-- liess sich weder ein Tag als Ganzes umplanen noch eine Tagesbesetzung
+-- revisionssicher fortschreiben. Diese Migration legt das Fundament dafuer an —
+-- und ausschliesslich das Fundament: keine Taetigkeiten, keine Ressourcen, kein
+-- Tagesstatus, kein Zeitraum, kein Generator.
+--
+-- ZWEI OBJEKTE, ZWEI VERSCHIEDENE LEBENSDAUERN
+-- ---------------------------------------------------------------------------
+-- `worksite_days` ist die IDENTITAET: eine Zeile je (Organisation, Baustelle,
+-- lokaler Tag), versionsuebergreifend stabil. Sie ueberlebt jede Planversion und
+-- traegt deshalb weder Zeiten noch einen Publish-Marker.
+--
+-- `worksite_day_configurations` ist die REVISION: der Planungsstand genau dieses
+-- Tages in genau einer Planversion. Wird eine Version kopiert, entsteht eine neue
+-- Konfiguration auf DERSELBEN Identitaet.
+--
+-- KEINE ZWEITE PUBLISH-WAHRHEIT
+-- ---------------------------------------------------------------------------
+-- `plan_versions.published_at` bleibt die einzige Veroeffentlichungsmarke. Keine
+-- Tabelle dieser Migration traegt einen eigenen. Wo die Unveraenderlichkeit einer
+-- veroeffentlichten Revision durchzusetzen ist, wird der Zustand der ELTERNZEILE
+-- gelesen — nicht ein lokal gespiegelter Marker, der auseinanderlaufen koennte.
+--
+-- APPEND-ONLY, KEIN BACKFILL
+-- ---------------------------------------------------------------------------
+-- Diese Migration aendert keine bestehende Zeile. `assignments` bekommt eine
+-- NULLABLE Spalte; Bestandszuweisungen bleiben ohne Tageskonfiguration gueltig
+-- und behalten ihre Identitaet. Es gibt keinen Cutover-Schritt hier.
+--
+-- ROLLBACK (vorwaerts, als NEUE 0020 — 0019 wird nach dem Merge nie editiert)
+-- ---------------------------------------------------------------------------
+-- alter table public.assignments drop constraint assignments_worksite_day_configuration_fk;
+-- drop index public.assignments_worksite_day_configuration_idx;
+-- alter table public.assignments drop column worksite_day_configuration_id;
+-- revoke insert on table public.assignments from authenticated;
+-- grant insert (org_id, plan_version_id, employee_id, worksite_id,
+-- starts_at_utc, ends_at_utc) on table public.assignments to authenticated;
+-- drop function app.remove_assignment_from_worksite_day(uuid, uuid);
+-- drop function app.read_idempotency_result(text, text);
+-- drop function app.lock_week_draft(text);
+-- drop table public.worksite_day_configurations;
+-- drop table public.worksite_days;
+-- drop function app.reject_worksite_day_configuration_change_in_published_plan();
+-- drop function app.assignment_belongs_to_worksite_day_configuration();
+-- -- REIHENFOLGE: erst die Policy, dann die Spalte. Die 0019-Fassung von
+-- -- idempotency_records_insert_in_org nennt result_payload in ihrer
+-- -- with-check-Klausel und haengt damit an der Spalte; umgekehrt bricht der
+-- -- Rollback mit "cannot drop column result_payload … policy … depends on it"
+-- -- ab (gemessen, PostgreSQL 17.6, Transaktion zurueckgerollt).
+-- drop policy idempotency_records_insert_in_org on public.idempotency_records;
+-- create policy idempotency_records_insert_in_org on public.idempotency_records
+-- for insert to authenticated with check (org_id in (select app.user_org_ids()));
+-- alter table public.idempotency_records
+-- drop constraint idempotency_records_worksite_day_ops_need_payload,
+-- drop constraint idempotency_records_result_payload_is_object,
+-- drop column result_payload;
+-- grant select on table public.idempotency_records to authenticated;
+--
+-- Der Rollback verliert die in `result_payload` gespeicherten Erstantworten. Das
+-- ist der einzige Datenverlust dieser Umkehr und betrifft ausschliesslich die
+-- beiden neuen Vorgaenge, die es vor dieser Migration nicht gab.
+
+-- ---------------------------------------------------------------------------
+-- 1. IDENTITAET: eine Zeile je (Organisation, Baustelle, lokaler Tag)
+-- ---------------------------------------------------------------------------
+-- Keine Planungsdaten, keine Zeiten, kein Publish-Marker, versionsuebergreifend
+-- stabil (R-01). Die Ortszone der Organisation entscheidet, welcher reale Tag
+-- gemeint ist — sie wird hier NICHT materialisiert, weil das ein zweiter Ort
+-- fuer eine Wahrheit waere, die `organizations.time_zone` bereits traegt.
+create table public.worksite_days (
+ id uuid not null default gen_random_uuid(),
+ org_id uuid not null references public.organizations (id) on delete cascade,
+ worksite_id uuid not null,
+ local_date date not null,
+ created_at timestamptz not null default now(),
+ primary key (id),
+ unique (id, org_id),
+ -- Die Identitaetsgarantie (I-4): derselbe reale Baustellentag ist immer
+ -- dieselbe Zeile. Ohne sie waere "stabile Identitaet" eine Absichtserklaerung.
+ unique (org_id, worksite_id, local_date),
+ -- Tenantgebunden: eine baustellenfremde Referenz scheitert auf FK-Ebene
+ -- (23503), unabhaengig von RLS. `restrict`, weil eine Baustelle mit geplanten
+ -- Tagen nicht stillschweigend verschwinden darf.
+ foreign key (worksite_id, org_id) references public.worksites (id, org_id) on delete restrict
+);
+-- EYT-120 ergaenzt hier spaeter additiv `continuous_worksite_plan_id uuid null`.
+
+comment on table public.worksite_days is
+ 'Stabile Identitaet eines Baustellentages je Organisation, Baustelle und lokalem Datum (EYT-147). Traegt bewusst KEINE Zeiten und KEINEN Publish-Marker: sie ueberlebt jede Planversion, waehrend der Planungsstand in worksite_day_configurations liegt.';
+
+comment on column public.worksite_days.local_date is
+ 'Lokaler Geschaeftstag in der Zeitzone der Organisation. Kein Zeitstempel — welcher UTC-Bereich das ist, entscheidet organizations.time_zone zur Lesezeit.';
+
+-- ---------------------------------------------------------------------------
+-- 2. REVISION: der Planungsstand dieses Tages in genau einer Planversion
+-- ---------------------------------------------------------------------------
+create table public.worksite_day_configurations (
+ id uuid not null default gen_random_uuid(),
+ org_id uuid not null references public.organizations (id) on delete cascade,
+ worksite_day_id uuid not null,
+ plan_version_id uuid not null,
+ -- Nebenlaeufigkeits-Token der TAGESIDENTITAET, je Revision materialisiert und
+ -- beim Kopieren uebernommen. KEINE Zeiten, KEIN published_at.
+ lock_version integer not null default 0 check (lock_version >= 0),
+ created_at timestamptz not null default now(),
+ primary key (id),
+ unique (id, org_id),
+ -- I-3: ein Baustellentag hat je Planversion hoechstens EINEN Stand.
+ unique (plan_version_id, worksite_day_id),
+ foreign key (worksite_day_id, org_id) references public.worksite_days (id, org_id),
+ -- Kaskade NUR hier: verschwindet ein unveroeffentlichter Entwurf, geht sein
+ -- Tagesstand mit. Die IDENTITAET bleibt (siehe FK oben, ohne Kaskade).
+ foreign key (plan_version_id, org_id)
+ references public.plan_versions (id, org_id) on delete cascade
+);
+
+comment on table public.worksite_day_configurations is
+ 'Revisionsgebundener Planungsstand eines Baustellentages in genau einer Planversion (EYT-147). Traegt KEINEN eigenen Publish-Marker: autoritativ ist plan_versions.published_at, das die Guards dieser Migration an der Elternzeile lesen.';
+
+comment on column public.worksite_day_configurations.lock_version is
+ 'Nebenlaeufigkeits-Token der Tagesidentitaet, je Revision materialisiert und beim Kopieren einer Planversion uebernommen. Genau EINE Stale-Wahrheit je Tag (R-20).';
+
+create index worksite_day_configurations_version_idx
+ on public.worksite_day_configurations (plan_version_id);
+create index worksite_day_configurations_day_idx
+ on public.worksite_day_configurations (worksite_day_id);
+
+-- ---------------------------------------------------------------------------
+-- 3. RLS und Grants nach dem 0017-Muster
+-- ---------------------------------------------------------------------------
+-- Spaltenweise Grants, Kanal und Recht in der Policy. KEIN delete-Grant auf
+-- beiden Tabellen: dieser Slice hat kein Loesch-Command, und ein Recht ohne
+-- Verbraucher ist reine Angriffsflaeche (0017). Entsprechend gibt es auch keine
+-- delete-Policy — sonst genuegte spaeter ein `grant`, um den Weg unbemerkt zu
+-- oeffnen.
+revoke all on table public.worksite_days, public.worksite_day_configurations
+ from anon, authenticated;
+
+grant select on table public.worksite_days, public.worksite_day_configurations to authenticated;
+
+grant insert (org_id, worksite_id, local_date) on table public.worksite_days to authenticated;
+
+grant insert (org_id, worksite_day_id, plan_version_id, lock_version)
+ on table public.worksite_day_configurations to authenticated;
+
+grant update (lock_version) on table public.worksite_day_configurations to authenticated;
+
+alter table public.worksite_days enable row level security;
+alter table public.worksite_days force row level security;
+alter table public.worksite_day_configurations enable row level security;
+alter table public.worksite_day_configurations force row level security;
+
+create policy worksite_days_select_in_org on public.worksite_days
+ for select to authenticated
+ using (org_id in (select app.user_org_ids()));
+
+-- `app.is_runtime_channel()` steht zuerst, wie in 0015 und 0017: PostgreSQL
+-- garantiert keine Auswertungsreihenfolge, aber die Zeile sagt dem Lesenden,
+-- welche Bedingung die tragende ist.
+create policy worksite_days_insert_in_org on public.worksite_days
+ for insert to authenticated
+ with check (
+ app.is_runtime_channel()
+ and org_id in (select app.user_org_ids())
+ and app.has_permission(org_id, 'planning.write')
+ );
+
+create policy worksite_day_configurations_select_in_org on public.worksite_day_configurations
+ for select to authenticated
+ using (org_id in (select app.user_org_ids()));
+
+create policy worksite_day_configurations_insert_in_org on public.worksite_day_configurations
+ for insert to authenticated
+ with check (
+ app.is_runtime_channel()
+ and org_id in (select app.user_org_ids())
+ and app.has_permission(org_id, 'planning.write')
+ );
+
+-- Den ENTWURFSZUSTAND prueft der Trigger in Block 5, nicht diese Policy —
+-- dieselbe Arbeitsteilung wie beim assignments-INSERT (0016/0017): die Policy
+-- beantwortet "darf dieser Kanal mit diesem Recht ueberhaupt schreiben", die
+-- Fachregel steht im Trigger.
+create policy worksite_day_configurations_update_in_org on public.worksite_day_configurations
+ for update to authenticated
+ using (
+ app.is_runtime_channel()
+ and org_id in (select app.user_org_ids())
+ and app.has_permission(org_id, 'planning.write')
+ )
+ with check (
+ app.is_runtime_channel()
+ and org_id in (select app.user_org_ids())
+ and app.has_permission(org_id, 'planning.write')
+ );
+
+-- ---------------------------------------------------------------------------
+-- 4. Aufnahme-Guard: eine veroeffentlichte Version nimmt nichts mehr auf
+-- ---------------------------------------------------------------------------
+-- Wiederverwendung der generischen 0016-Fassung: sie liest ausschliesslich
+-- `new.plan_version_id` und `new.org_id` und ist damit auf jede Kindtabelle von
+-- `plan_versions` anwendbar. Ihr Fehlertext spricht von "Zuweisung"; das ist der
+-- Preis der Wiederverwendung und wird hier benannt statt durch eine zweite,
+-- fast gleiche Funktion vermieden.
+create trigger worksite_day_configurations_reject_published_plan
+ before insert or update of plan_version_id on public.worksite_day_configurations
+ for each row execute function app.reject_assignment_in_published_plan();
+
+-- ---------------------------------------------------------------------------
+-- 5. Unveraenderlichkeit OHNE lokalen Marker
+-- ---------------------------------------------------------------------------
+-- ZWINGEND `security definer` — gemessene 0015-Lektion (CI-Lauf 30862744360):
+-- ein INVOKER-`for share` prueft zusaetzlich die USING-Klausel der UPDATE-Policy
+-- von `plan_versions`, und die verlangt `planning.publish`. Eine Sitzung mit nur
+-- `planning.write` — das Recht dieser Tabellen — faende die veroeffentlichte
+-- Elternzeile NICHT, `v_published_at` bliebe NULL und der Guard liesse die
+-- Mutation still durch.
+create function app.reject_worksite_day_configuration_change_in_published_plan()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_published_at timestamptz;
+begin
+ -- (1) DIE ZUGEHOERIGKEIT IST UNVERAENDERLICH.
+ -- `worksite_day_id` und `plan_version_id` sind die Identitaet dieser Zeile,
+ -- nicht ihr Inhalt. Ein Umhaengen wuerde I-1 (Block 7) NACHTRAEGLICH
+ -- brechen: der Zugehoerigkeitstrigger auf `assignments` feuert nur bei
+ -- INSERT/UPDATE DORT und laeuft bei einer Aenderung HIER nie erneut.
+ -- Review-MESSUNG (PostgreSQL 17.6, lokaler Stack, zurueckgerollt): ohne
+ -- diesen Riegel liess `update … set plan_version_id = `
+ -- eine Konfiguration auf Planversion B zeigen, waehrend ihre Zuweisungen
+ -- weiter auf A zeigten (`gleich=f`), und `update … set worksite_day_id =
+ -- ` erzeugte `assignment.worksite_id <>
+ -- worksite_days.worksite_id`. Beide Netze liessen es durch: der
+ -- Aufnahme-Guard sah einen Entwurf, dieser Guard nur den Publish-Stand.
+ --
+ -- Das Spaltenrecht allein genuegt hier NICHT als Riegel. `authenticated`
+ -- besitzt zwar nur `update (lock_version)`, aber 0017 hat fuer genau diese
+ -- Konstruktion die Regel aufgestellt: ein Weg, der nur durch ein fehlendes
+ -- Grant versperrt ist, geht mit dem naechsten `grant` unbemerkt wieder auf.
+ if tg_op = 'UPDATE'
+ and (new.worksite_day_id <> old.worksite_day_id
+ or new.plan_version_id <> old.plan_version_id) then
+ raise exception 'Tageskonfiguration kann nicht auf einen anderen Tag oder eine andere Planversion umgehaengt werden'
+ using errcode = '23514';
+ end if;
+
+ -- (2) VEROEFFENTLICHUNGSSTAND DER ELTERNZEILE.
+ -- BEWUSST OHNE Sichtbarkeitsfilter — anders als der Aufnahme-Guard aus
+ -- 0016. Dort ist `new.plan_version_id` ein vom Aufrufer GEWAEHLTER Wert,
+ -- und der Filter verhindert, dass die Fehlermeldung die Existenz einer
+ -- fremden Planversion verraet; die Entscheidung faellt dann beim
+ -- Fremdschluessel (23503). Hier stammt `old.plan_version_id` aus einer
+ -- Zeile, die der Aufrufer bereits in der Hand hat — es gibt keine fremde
+ -- Existenz mehr zu verraten, und der Filter waere reiner Schaden.
+ --
+ -- Review-MESSUNG (PostgreSQL 17.6, lokaler Stack, zurueckgerollt): MIT
+ -- Filter erzeugte eine Sitzung mit ORG-FREMDER Identitaet in
+ -- `request.jwt.claims` denselben `not found` wie die Loeschkaskade — und
+ -- der DELETE-Zweig liess sie durch: `DELETE 1`, die Tageskonfiguration
+ -- einer VEROEFFENTLICHTEN Planversion war weg. Dieselbe Anweisung ohne
+ -- Claims wurde korrekt mit 23514 abgewiesen. Der Bestandsguard
+ -- `app.reject_published_row_change` (0010) hat diese Luecke nicht, weil er
+ -- `old.published_at` an der Zeile SELBST liest und damit
+ -- identitaetsunabhaengig ist. Ohne Filter ist `not found` hier wieder
+ -- eindeutig: die Elternzeile existiert wirklich nicht mehr.
+ select pv.published_at
+ into v_published_at
+ from public.plan_versions pv
+ where pv.id = old.plan_version_id
+ and pv.org_id = old.org_id
+ for share;
+
+ if not found then
+ -- Review-MESSUNG: bei einer Loeschkaskade ist die Elternzeile im
+ -- BEFORE-DELETE des Kindes BEREITS entfernt — „nicht gefunden" ist dort der
+ -- NORMALFALL. Eine fail-closed-Variante machte gemessen schon das Loeschen
+ -- eines UNVEROEFFENTLICHTEN Entwurfs mit Tageskonfiguration unmoeglich und
+ -- haette die Aufraeumpfade der Bestandssuiten gebrochen. Die Entscheidung
+ -- gehoert dort dem Fremdschluessel und der Kaskade, nicht dieser Regel. Die
+ -- Unveraenderlichkeit VEROEFFENTLICHTER Zeilen bleibt gewahrt, weil eine
+ -- veroeffentlichte `plan_versions`-Zeile nach 0010 selbst unloeschbar ist
+ -- und ihre Kaskade damit nie anlaeuft.
+ --
+ -- Bei UPDATE kann es per Konstruktion keine Kaskade geben (kein
+ -- `on update cascade`), und der Fremdschluessel garantiert die Elternzeile —
+ -- ein `not found` ist dort ein unerklaerter Zustand und wird benannt.
+ if tg_op = 'DELETE' then
+ return old;
+ end if;
+ raise exception 'Planversion der Tageskonfiguration nicht aufloesbar'
+ using errcode = '23514';
+ end if;
+
+ if v_published_at is not null then
+ raise exception 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich'
+ using errcode = '23514';
+ end if;
+
+ if tg_op = 'DELETE' then
+ return old;
+ end if;
+ return new;
+end
+$$;
+
+comment on function app.reject_worksite_day_configuration_change_in_published_plan() is
+ 'Zwei Regeln fuer worksite_day_configurations (EYT-147). (1) Die Zugehoerigkeit ist unveraenderlich: worksite_day_id und plan_version_id lassen sich nicht umhaengen, sonst braeche I-1 nachtraeglich, weil der Zugehoerigkeitstrigger auf assignments dabei nie erneut feuert. (2) Die Konfiguration einer veroeffentlichten Planversion ist unveraenderlich — ohne zweiten Publish-Marker, der Zustand wird an der Elternzeile gelesen. security definer aus demselben Grund wie 0015: ein Invoker-for-share pruefte die UPDATE-Policy von plan_versions mit und saehe die Zeile ohne planning.publish nicht. Die Elternabfrage traegt BEWUSST KEINEN Sichtbarkeitsfilter: old.plan_version_id stammt aus einer Zeile, die der Aufrufer schon haelt, es gibt also keine fremde Existenz zu verraten — mit Filter erzeugte eine org-fremde Identitaet denselben not-found wie die Loeschkaskade und konnte gemessen eine veroeffentlichte Tageskonfiguration loeschen. Fail-open ausschliesslich fuer DELETE, weil dort die Loeschkaskade den Normalfall "nicht gefunden" erzeugt.';
+
+revoke all on function app.reject_worksite_day_configuration_change_in_published_plan()
+ from public;
+
+create trigger worksite_day_configurations_immutable_when_published
+ before update or delete on public.worksite_day_configurations
+ for each row
+ execute function app.reject_worksite_day_configuration_change_in_published_plan();
+
+-- ---------------------------------------------------------------------------
+-- 6. Assignments: Unterordnung unter die REVISION (R-26)
+-- ---------------------------------------------------------------------------
+-- Nullable fuer Bestandszeilen. Kein Backfill, keine Aenderung der bisherigen
+-- Assignment-Identitaet.
+alter table public.assignments add column worksite_day_configuration_id uuid;
+
+comment on column public.assignments.worksite_day_configuration_id is
+ 'Tageskonfiguration, unter der diese Zuweisung geplant wurde (EYT-147). NULL fuer Bestandszeilen aus der personenzentrierten Planung; diese bleiben unveraendert gueltig.';
+
+-- NO ACTION (Default). Review-MESSUNG (PostgreSQL 17.6, Replika dieser
+-- Topologie): RESTRICT verhielte sich hier identisch; der einzige dokumentierte
+-- Unterschied ist Deferrierbarkeit.
+alter table public.assignments add constraint assignments_worksite_day_configuration_fk
+ foreign key (worksite_day_configuration_id, org_id)
+ references public.worksite_day_configurations (id, org_id);
+
+create index assignments_worksite_day_configuration_idx
+ on public.assignments (worksite_day_configuration_id);
+
+-- Der 0017-Grantumfang waechst um GENAU eine Spalte. `id` bleibt draussen (0012
+-- A4), `update` und `delete` bleiben entzogen, und es entsteht KEINE zweite
+-- Schreibpolicy auf `assignments` — die 0017-Fassung gilt unveraendert weiter.
+revoke insert on table public.assignments from authenticated;
+grant insert (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id,
+ starts_at_utc, ends_at_utc)
+ on table public.assignments to authenticated;
+
+-- ---------------------------------------------------------------------------
+-- 7. Strukturelle Zugehoerigkeit (I-1)
+-- ---------------------------------------------------------------------------
+-- Baustelle und Planversion der Zuweisung stimmen mit denen ihrer
+-- Tageskonfiguration ueberein. Der Fremdschluessel allein saehe das nicht: er
+-- prueft die Existenz der Konfiguration im selben Mandanten, nicht die
+-- Uebereinstimmung dreier weiterer Werte.
+--
+-- Bewusst INVOKER (kein `security definer`): die Funktion liest nur Zeilen, die
+-- der Aufrufer ohnehin sehen darf, und ihr `if not found then return new` gibt
+-- die Existenzfrage ausdruecklich an den Fremdschluessel ab (23503). Ein definer
+-- waere hier eine unnoetige Rechteerhoehung.
+create function app.assignment_belongs_to_worksite_day_configuration()
+returns trigger
+language plpgsql
+set search_path = ''
+as $$
+declare
+ v_worksite_id uuid;
+ v_plan_version_id uuid;
+begin
+ -- Legacy-/Bestandspfad: eine Zuweisung ohne Tageskonfiguration ist gueltig.
+ if new.worksite_day_configuration_id is null then
+ return new;
+ end if;
+
+ select d.worksite_id, c.plan_version_id
+ into v_worksite_id, v_plan_version_id
+ from public.worksite_day_configurations c
+ join public.worksite_days d
+ on d.id = c.worksite_day_id and d.org_id = c.org_id
+ where c.id = new.worksite_day_configuration_id
+ and c.org_id = new.org_id;
+
+ -- Existenz und Sichtbarkeit gehoeren dem Fremdschluessel (23503), nicht
+ -- dieser Regel.
+ if not found then
+ return new;
+ end if;
+
+ if v_worksite_id <> new.worksite_id or v_plan_version_id <> new.plan_version_id then
+ raise exception 'Zuweisung widerspricht Baustelle oder Planversion ihrer Tageskonfiguration'
+ using errcode = '23514';
+ end if;
+
+ return new;
+end
+$$;
+
+comment on function app.assignment_belongs_to_worksite_day_configuration() is
+ 'Strukturelle Zugehoerigkeit einer Zuweisung zu ihrer Tageskonfiguration (EYT-147, I-1): Organisation, Baustelle und Planversion muessen uebereinstimmen. Bewusst invoker — die Existenzfrage gehoert dem Fremdschluessel.';
+
+revoke all on function app.assignment_belongs_to_worksite_day_configuration() from public;
+
+-- Der Publish-Spiegel aus 0010 (`update published_at`, security definer)
+-- passiert diesen Trigger folgenlos: er aendert keine der drei verglichenen
+-- Spalten, und der Vergleich bleibt damit wahr.
+create trigger assignments_belong_to_worksite_day_configuration
+ before insert or update on public.assignments
+ for each row execute function app.assignment_belongs_to_worksite_day_configuration();
+
+-- ---------------------------------------------------------------------------
+-- 8. FACHLICH GEBUNDENE Entfernen-Grenze (R-13/R-23)
+-- ---------------------------------------------------------------------------
+-- KEIN allgemeines DELETE-Primitive. 0017 hat `delete` auf `assignments`
+-- ersatzlos entzogen; diese Funktion gibt es nicht zurueck, sondern stellt genau
+-- einen fachlich gebundenen Vorgang bereit: entferne GENAU DIESE Zuweisung aus
+-- GENAU DIESEM Baustellentag.
+create function app.remove_assignment_from_worksite_day(
+ p_assignment_id uuid,
+ p_worksite_day_configuration_id uuid
+) returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_a record;
+ v_c record;
+ v_published_at timestamptz;
+begin
+ if not app.is_runtime_channel() then
+ raise exception 'Nur der Laufzeitkanal darf Zuweisungen aus einem Baustellentag entfernen'
+ using errcode = '42501';
+ end if;
+
+ -- ACHTUNG: unter `security definer` ist dieser Select NICHT RLS-gefiltert; die
+ -- einzige Mandantengrenze ist der explizite org-Check unten. Praezedenz fuer
+ -- definer + `app.user_org_ids()` im Rumpf: app.reject_assignment_in_published_plan
+ -- (0015/0016). Von app.lock_employee_planning (0012, invoker) stammt der
+ -- andere Teil des Musters: die Organisation kommt aus der ZEILE, nie aus einem
+ -- Parameter.
+ select a.org_id, a.plan_version_id, a.published_at, a.worksite_day_configuration_id
+ into v_a
+ from public.assignments a
+ where a.id = p_assignment_id;
+
+ select c.org_id, c.plan_version_id
+ into v_c
+ from public.worksite_day_configurations c
+ where c.id = p_worksite_day_configuration_id;
+
+ -- Fremd und inexistent sind nach aussen ununterscheidbar: EIN Fehler, EIN
+ -- Wortlaut. Alles andere waere ein Existenzleck ueber fremde Mandanten.
+ if v_a is null or v_c is null
+ or v_a.org_id not in (select app.user_org_ids())
+ or v_c.org_id <> v_a.org_id
+ or v_a.worksite_day_configuration_id is distinct from p_worksite_day_configuration_id
+ or v_a.plan_version_id <> v_c.plan_version_id then
+ raise exception 'Zuweisung gehoert nicht zu dieser Tageskonfiguration'
+ using errcode = 'P0002';
+ end if;
+
+ if not app.has_permission(v_a.org_id, 'planning.write') then
+ raise exception 'Kein Planungsrecht' using errcode = '42501';
+ end if;
+
+ select pv.published_at
+ into v_published_at
+ from public.plan_versions pv
+ where pv.id = v_a.plan_version_id
+ and pv.org_id = v_a.org_id
+ for share; -- Race gegen einen laufenden Publish
+
+ if not found then
+ -- Konsistent mit Block 5, aber mit umgekehrtem Ausgang: hier ist KEINE
+ -- Kaskade im Spiel (die Funktion wird direkt aufgerufen, nicht aus einem
+ -- Trigger), die Elternzeile existiert per Fremdschluessel, und ein stummer
+ -- Durchlass waere ein Loch in genau der Grenze, die diese Funktion ist.
+ raise exception 'Planversion der Zuweisung nicht aufloesbar' using errcode = '23514';
+ end if;
+
+ if v_published_at is not null or v_a.published_at is not null then
+ raise exception 'Veroeffentlichte Zuweisung kann nicht entfernt werden'
+ using errcode = '23514';
+ end if;
+
+ delete from public.assignments where id = p_assignment_id;
+ -- Zweiter Riegel bleibt scharf: `assignments_published_immutable` (0010) feuert
+ -- auch fuer den definer/Eigentuemer und lehnte eine veroeffentlichte Zeile
+ -- zusaetzlich mit 23514 ab.
+end
+$$;
+
+comment on function app.remove_assignment_from_worksite_day(uuid, uuid) is
+ 'Entfernt GENAU EINE Zuweisung aus GENAU EINEM Baustellentag (EYT-147, R-13/R-23). Kein allgemeines DELETE-Primitive: Laufzeitkanal, Mandantenbindung aus der Zeile, planning.write, fachliche Zugehoerigkeit und Veroeffentlichungsstand werden in dieser Reihenfolge geprueft. Fremd und inexistent teilen sich einen Fehler, damit keine fremde Existenz durchscheint.';
+
+revoke all on function app.remove_assignment_from_worksite_day(uuid, uuid) from public;
+grant execute on function app.remove_assignment_from_worksite_day(uuid, uuid) to authenticated;
+
+-- ---------------------------------------------------------------------------
+-- 9. Idempotenz: unveraenderliches Ergebnis des ERSTEN Aufrufs (R-21)
+-- ---------------------------------------------------------------------------
+alter table public.idempotency_records add column result_payload jsonb;
+
+alter table public.idempotency_records
+ add constraint idempotency_records_result_payload_is_object
+ check (result_payload is null or jsonb_typeof(result_payload) = 'object');
+
+-- Pflicht fuer die neuen Vorgaenge in der DATENBANK, nicht nur im Test: ein
+-- payloadloser Insert ist damit 23514 statt eines stillen Zustands, in dem der
+-- Replay weder die erste Antwort reproduzieren noch ehrlich scheitern koennte.
+-- Der Name ist 49 Zeichen lang und damit 14 unter NAMEDATALEN-1 (63) — er wird
+-- nicht abgeschnitten. Gemessen, nicht geschaetzt: select length(conname).
+alter table public.idempotency_records
+ add constraint idempotency_records_worksite_day_ops_need_payload
+ check (
+ operation not in ('planning.plan_worksite_day', 'planning.update_worksite_day_team')
+ or result_payload is not null
+ );
+
+-- Kanalpruefung nachziehen: bis heute prueft die INSERT-Policy nur die
+-- Organisation — 0017 liess das stehen, weil kein Verbraucher davon profitierte.
+-- Mit `result_payload` speist diese Spalte den Antwortkoerper eines Commands;
+-- ohne Kanalriegel koennte jede authenticated-Sitzung mit Org-Mitgliedschaft
+-- ueber die Data-API ein Replay-Ergebnis vorschreiben.
+--
+-- Additiv und OPERATIONSGEBUNDEN, nicht pauschal (Review-MESSUNG): es gibt VIER
+-- Schreiber von `idempotency_records` — planning.create_assignment,
+-- planning.publish_plan, costs.create_rate_version und costs.create_cost_snapshot.
+-- `costs.create_rate_version` schreibt `employee_rate_versions`, die selbst
+-- KEINEN Laufzeitkanal verlangt, und seine Integrationssuite laeuft ueber eine
+-- `postgres`-Sitzung. Eine pauschale Kanalpflicht liesse diesen Bestandspfad an
+-- RLS scheitern (gemessen: derselbe Insert gelingt mit der Bestands-Policy und
+-- scheitert mit der pauschalen Fassung). Den Kanal braucht nur, wer ein
+-- `result_payload` schreibt — also genau die beiden neuen Vorgaenge.
+drop policy idempotency_records_insert_in_org on public.idempotency_records;
+create policy idempotency_records_insert_in_org on public.idempotency_records
+ for insert to authenticated
+ with check (
+ org_id in (select app.user_org_ids())
+ and (result_payload is null or app.is_runtime_channel())
+ );
+
+-- LESEGRENZE (R5-1). RLS filtert ZEILEN, nicht Spalten: eine Policy-Bedingung
+-- koennte den Payload nur verbergen, indem sie die ganze Zeile verschwinden
+-- laesst — und selbst dann bliebe er fuer jede Sitzung lesbar, die die
+-- Zeilenbedingung erfuellt. Der Payload traegt historische Command-Evidenz
+-- (Team, Personen, Intervalle eines Draft-Standes, der spaeter geaendert oder
+-- entfernt worden sein kann) und 0012 kennt keine Aufbewahrungsregel.
+--
+-- Deshalb ein SPALTENRECHT statt einer Zeilenbedingung — dasselbe Muster, mit
+-- dem 0010/0017 die assignments-Schreibrechte begrenzt haben, nur fuer SELECT.
+-- Die SELECT-POLICY bleibt woertlich unveraendert (org-weit): der einzige
+-- Bestandsleser liest `subject_id, request_fingerprint`
+-- (pg-idempotency-store.ts:26-28, gemessen: kein `select *` im Repository), und
+-- beide Spalten stehen weiter im Grant. Alle vier Bestandsvorgaenge bleiben
+-- damit unberuehrt.
+revoke select on table public.idempotency_records from authenticated;
+grant select (id, org_id, operation, idempotency_key, subject_id, request_fingerprint,
+ created_at)
+ on table public.idempotency_records to authenticated;
+
+comment on column public.idempotency_records.result_payload is
+ 'Unveraenderliche Antwort des ERSTEN Aufrufs (EYT-147). Nullable fuer Bestandsvorgaenge, die ihr Ergebnis ueber subject_id zurueckliesen. Inhalt: ausschliesslich Ids, Zeitstempel und Zahlen — niemals Labels oder Namen. ABER ausdruecklich NICHT derselbe Massstab wie audit_events.context: dies ist historische Command-Evidenz und haelt Personen- und Intervallzuordnungen fest, die aus dem aktuellen Draft spaeter entfernt worden sein koennen. Deshalb steht diese Spalte NICHT im SELECT-Grant und ist ausschliesslich ueber app.read_idempotency_result lesbar: nur die beiden Payload-Vorgaenge, nur im Laufzeitkanal, nur mit planning.write UND nur gebunden an die genau eine aktive Organisation der aufrufenden Identitaet, die die Funktion vor der Zeilenauswahl aufloest — und deshalb insert-only.';
+
+-- Die EINZIGE Lesestelle des Payloads: command- und kanalgebundene
+-- definer-Funktion.
+create function app.read_idempotency_result(p_operation text, p_key text)
+returns jsonb
+language plpgsql
+stable
+security definer
+set search_path = ''
+as $$
+declare
+ v_org uuid;
+ v_payload jsonb;
+ v_orgs uuid[];
+begin
+ -- 1. Nur die beiden Payload-Vorgaenge. Ein anderer Operationsname kommt hier
+ -- nie an; kaeme er doch, ist das ein Aufruferfehler und kein Leseweg.
+ if p_operation not in ('planning.plan_worksite_day', 'planning.update_worksite_day_team') then
+ raise exception 'Kein Payload-Vorgang' using errcode = '42501';
+ end if;
+
+ -- 2. Laufzeitkanal.
+ if not app.is_runtime_channel() then
+ raise exception 'Nur der Laufzeitkanal darf ein Replay-Ergebnis lesen'
+ using errcode = '42501';
+ end if;
+
+ -- 3. ORGANISATION ZUERST, deterministisch und aus dem authentisierten Kontext.
+ -- Unter `security definer` gibt es KEINE RLS-Filterung — die Mandantengrenze
+ -- muss die Funktion selbst ziehen, und zwar BEVOR sie eine Zeile auswaehlt.
+ -- Der Unique-Schluessel von 0012 ist (org_id, operation, idempotency_key),
+ -- NICHT (operation, key): zwei Organisationen duerfen denselben
+ -- Vorgangsnamen und denselben Schluessel fuehren. Gemessen am lokalen Stack
+ -- (zwei Organisationen, gleicher Vorgang, gleicher Schluessel): ein
+ -- ungebundener Select liefert found=t mit der FREMDEN Zeile, und ein
+ -- Nachfilter verwarf sie dann — der Aufrufer bekaeme einen Replay-MISS statt
+ -- seiner Erstantwort.
+ --
+ -- Die Regel ist dieselbe wie im Planning-Command: GENAU EINE aktive
+ -- Organisation, sonst fail-closed. KEIN order-by-limit-1 — eine Auswahl
+ -- unter mehreren Mitgliedschaften waere geraten. `v_orgs[1]` ist hier keine
+ -- Auswahl, sondern die Identitaet: der Zugriff erfolgt erst, nachdem genau
+ -- ein Element festgestellt wurde. (`min(o)` waere die naheliegende Kurzform
+ -- und ist NICHT verwendbar: PostgreSQL kennt kein min(uuid).)
+ --
+ -- `into strict` waere die kuerzere Schreibweise, ist hier aber bewusst NICHT
+ -- gewaehlt: sie wirft NO_DATA_FOUND = P0002, und diesen Code fuehrt
+ -- `app.remove_assignment_from_worksite_day` in dieser Migration bereits mit
+ -- einer anderen Bedeutung. Zwei Bedeutungen fuer einen SQLSTATE waeren an
+ -- einer Sicherheitsgrenze eine vermeidbare Mehrdeutigkeit.
+ --
+ -- ROLLENVERTEILUNG: die Anwendung hat die Eindeutigkeit bereits vor der
+ -- Idempotenz geprueft und dort NO_ORGANISATION/AMBIGUOUS_ORGANISATION
+ -- beantwortet. Dieser Riegel ist Tiefenverteidigung: loest er aus, ist eine
+ -- Invariante verletzt, kein regulaerer Fachfall — deshalb genuegt hier ein
+ -- einheitliches 42501.
+ select array_agg(o) into v_orgs from app.user_org_ids() as o;
+ if v_orgs is null then
+ raise exception 'Keine aktive Organisation' using errcode = '42501';
+ end if;
+ if array_length(v_orgs, 1) > 1 then
+ raise exception 'Mehrdeutige Organisation — genau eine aktive Mitgliedschaft erforderlich'
+ using errcode = '42501';
+ end if;
+ v_org := v_orgs[1];
+
+ -- 4. Recht auf der aufgeloesten Organisation. VOR der Payload-Abfrage, damit
+ -- ein fehlendes Recht nichts ueber die Existenz eines Schluessels verraet.
+ if not app.has_permission(v_org, 'planning.write') then
+ raise exception 'Kein Planungsrecht' using errcode = '42501';
+ end if;
+
+ -- 5. Erst jetzt die Zeile — mandantengebunden ueber den VOLLSTAENDIGEN
+ -- Unique-Schluessel.
+ select result_payload
+ into v_payload
+ from public.idempotency_records
+ where org_id = v_org
+ and operation = p_operation
+ and idempotency_key = p_key;
+
+ -- 6. Fremder Schluessel und nicht vorhandener Schluessel sind nach aussen
+ -- identisch: beides null, also "kein Replay" — kein Fehler, der eine
+ -- fremde Existenz verraet.
+ if not found then
+ return null;
+ end if;
+ return v_payload;
+end
+$$;
+
+comment on function app.read_idempotency_result(text, text) is
+ 'Die EINZIGE Lesestelle von idempotency_records.result_payload (EYT-147, F1). Nur die beiden Payload-Vorgaenge, nur im Laufzeitkanal, nur mit planning.write, und gebunden an die GENAU EINE aktive Organisation der aufrufenden Identitaet — aufgeloest VOR der Zeilenauswahl, ohne Organisationsparameter und ohne order-by-limit-1. Fremder und unbekannter Schluessel liefern beide null.';
+
+revoke all on function app.read_idempotency_result(text, text) from public;
+grant execute on function app.read_idempotency_result(text, text) to authenticated;
+
+-- ---------------------------------------------------------------------------
+-- 10. Klasse-(2)-Erwerb als Domain-Funktion (F2/F3)
+-- ---------------------------------------------------------------------------
+-- WARUM NICHT im Anwendungscode mit einem einfachen `select … for share`:
+-- gemessen am lokalen Stack (PostgreSQL 17.6, Laufzeitkanal, Recht
+-- `planning.publish` entzogen):
+-- A) INVOKER `… where week_key=… and published_at is null for share` -> KEINE ZEILE
+-- B) DEFINER dieselbe Abfrage -> Zeile gefunden
+-- Ursache ist dieselbe, die 0015 fuer den Assignment-Guard dokumentiert: ein
+-- `for share` prueft zusaetzlich die USING-Klausel der UPDATE-Policy von
+-- `plan_versions`, und die verlangt `planning.publish`. Eine Sitzung mit nur
+-- `planning.write` — der Normalfall dieser Commands — faende die Draft-Zeile
+-- nicht und koennte die Postcondition nicht erfuellen.
+--
+-- WARUM DIE SCHLEIFE: veroeffentlicht ein Publisher waehrend des Wartens, wertet
+-- PostgreSQL die Qualifikation nach dem Commit erneut aus (EvalPlanQual);
+-- `published_at is null` trifft nicht mehr zu und das Statement liefert 0 Zeilen.
+-- Ohne Wiederholung stuende der Aufrufer ohne Draft da.
+--
+-- WARUM DIE MANDANTENAUFLOESUNG VORNE STEHT (F3): unter `security definer` laeuft
+-- die Funktion als Eigentuemer, und der traegt BYPASSRLS. Die Mengenform
+-- `where org_id in (select app.user_org_ids())` waere hier KEINE Grenze, sondern
+-- eine Auswahl. Gemessen an genau dieser Mengenform (isolierte Probe,
+-- zurueckgerollt): zwei Orgs ohne Entwurf -> `query returned more than one row`;
+-- Org A hat einen Entwurf, Org B nicht -> legt STILL einen Entwurf in B an und
+-- gibt dessen Id zurueck. Der mittlere Fall ist der gefaehrliche: ein nach aussen
+-- ERFOLGREICHER Command im falschen Mandanten.
+create function app.lock_week_draft(p_week_key text)
+returns uuid
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_id uuid;
+ v_org uuid;
+ v_orgs uuid[];
+ v_runde integer := 0;
+begin
+ if not app.is_runtime_channel() then
+ raise exception 'Nur der Laufzeitkanal darf einen Wochenentwurf sperren'
+ using errcode = '42501';
+ end if;
+
+ -- F3: Mandantengrenze zuerst, danach ist jeder Zugriff an `v_org` gebunden.
+ select array_agg(o) into v_orgs from app.user_org_ids() as o;
+ if v_orgs is null then
+ raise exception 'Keine aktive Organisation' using errcode = '42501';
+ end if;
+ if array_length(v_orgs, 1) <> 1 then
+ raise exception 'Mehrdeutige Organisation - genau eine aktive Mitgliedschaft erforderlich'
+ using errcode = '42501';
+ end if;
+ v_org := v_orgs[1];
+
+ if not app.has_permission(v_org, 'planning.write') then
+ raise exception 'Kein Planungsrecht' using errcode = '42501';
+ end if;
+
+ loop
+ v_runde := v_runde + 1;
+
+ -- (a) Anlegen. Gewinnt diese Transaktion, haelt sie die Zeile ueber den
+ -- eigenen, noch nicht committeten Insert; das reentrante `for share` in
+ -- (b) waere dann ein No-op. Die Existenz von `v_org` in
+ -- `public.organizations` prueft weiterhin der Fremdschluessel von
+ -- `plan_versions` — ein eigener Lesezugriff darauf entfaellt mit F3
+ -- ersatzlos.
+ insert into public.plan_versions (org_id, week_key)
+ values (v_org, p_week_key)
+ on conflict do nothing
+ returning id into v_id;
+ if v_id is not null then
+ return v_id;
+ end if;
+
+ -- (b) Bestehenden Entwurf sperren. `for share` konfligiert mit dem
+ -- `for update` des Publishers, nicht aber mit einem zweiten `for share`
+ -- — zwei Planerinnen derselben Woche behindern einander nicht.
+ -- `planning.write` ist oben auf `v_org` bereits geprueft; eine zweite
+ -- Pruefung im Praedikat waere dieselbe Aussage.
+ select pv.id
+ into v_id
+ from public.plan_versions pv
+ where pv.org_id = v_org
+ and pv.week_key = p_week_key
+ and pv.published_at is null
+ for share;
+ if v_id is not null then
+ return v_id;
+ end if;
+
+ -- (c) Weder angelegt noch gefunden: zwischen (a) und (b) wurde
+ -- veroeffentlicht. Die naechste Runde legt den Folgedraft an. Begrenzt,
+ -- damit ein unerwarteter Zustand BENANNT scheitert statt zu kreisen.
+ if v_runde >= 3 then
+ raise exception 'Wochenentwurf nicht ermittelbar (% Runden)', v_runde
+ using errcode = '55P03';
+ end if;
+ end loop;
+end
+$$;
+
+comment on function app.lock_week_draft(text) is
+ 'Erwirbt den Wochenentwurf einer Organisation und haelt ihn bis Transaktionsende als Klasse-(2)-Lock (EYT-147, F2/F3). security definer, weil ein Invoker-for-share die UPDATE-Policy von plan_versions mitpruefte und eine Sitzung mit nur planning.write den Entwurf nicht faende. Ohne Organisationsparameter und ohne order-by-limit-1: die genau eine aktive Organisation wird VOR jedem Zugriff aufgeloest, sonst 42501. Die Wiederholungsrunde faengt einen konkurrierenden Publish ab und legt den Folgedraft an.';
+
+revoke all on function app.lock_week_draft(text) from public;
+grant execute on function app.lock_week_draft(text) to authenticated;
diff --git a/supabase/tests/0005_schema_meta_gate.sql b/supabase/tests/0005_schema_meta_gate.sql
index 2a566a2a..dadc0ee1 100644
--- a/supabase/tests/0005_schema_meta_gate.sql
+++ b/supabase/tests/0005_schema_meta_gate.sql
@@ -66,7 +66,13 @@ insert into expected_tables (table_name, tenant_owned, note) values
-- EYT-109). Die Rechte-, Policy- und Kanalzusicherungen dazu stehen in
-- supabase/tests/0013_cost_snapshots.sql.
('cost_snapshots', true, 'Unveraenderlicher Kopf eines Plan-Personalkosten-Snapshots (0018)'),
- ('cost_snapshot_positions', true, 'Positionen eines Kosten-Snapshots, Reihenfolge per ordinal eingefroren (0018)');
+ ('cost_snapshot_positions', true, 'Positionen eines Kosten-Snapshots, Reihenfolge per ordinal eingefroren (0018)'),
+ -- Stabile Identitaet eines Baustellentages, versionsuebergreifend. Traegt
+ -- bewusst keine Zeiten und keinen Publish-Marker (0019, EYT-147); die
+ -- Struktur-, Rechte- und Invariantenzusicherungen stehen in
+ -- supabase/tests/0014_worksite_days.sql.
+ ('worksite_days', true, 'Stabile Identitaet eines Baustellentages je Organisation, Baustelle und lokalem Datum (0019)'),
+ ('worksite_day_configurations', true, 'Revisionsgebundener Planungsstand eines Baustellentages in genau einer Planversion (0019)');
-- ---------------------------------------------------------------------------
-- 1. Vollstaendigkeit in beide Richtungen
@@ -203,14 +209,39 @@ select is(
-- Gegenprobe zu 4 und 5: ohne diese Zeile waeren die Verbote auch dann gruen,
-- wenn ueberhaupt keine Rechte vergeben sind — und die Anwendung stuende vor
-- lauter permission denied.
+--
+-- EINE benannte Ausnahme seit 0019 (EYT-147), sonst unveraendert.
+--
+-- 0019 entzieht auf `idempotency_records` das TABELLEN-select, um
+-- `result_payload` unlesbar zu machen (R5-1), und vergibt die uebrigen sieben
+-- Spalten einzeln neu. GEMESSEN (PostgreSQL 17.6, lokaler Stack nach db reset):
+-- has_table_privilege('authenticated','public.idempotency_records','select') = false
+-- has_any_column_privilege('authenticated','public.idempotency_records','SELECT') = true
+-- Die urspruengliche Fassung ging deshalb rot, obwohl die AUSSAGE dieser Zeile —
+-- "die Anwendung laeuft nicht in permission denied" — weiterhin wahr ist: der
+-- einzige Bestandsleser liest `subject_id, request_fingerprint`, und beide
+-- stehen im Grant.
+--
+-- Die Ausnahme ist BEWUSST auf diese eine Tabelle eingegrenzt und nicht
+-- repo-weit gezogen. `has_any_column_privilege` ist wahr, sobald IRGENDEINE
+-- Spalte das Recht traegt, und schliesst das Tabellenrecht mit ein — als
+-- Ersatz fuer ALLE 18 Tabellen waere die Zusicherung damit fuer 17 von ihnen
+-- schwaecher als vorher, ohne dass irgendetwas das verlangt. Welche Spalten es
+-- bei `idempotency_records` genau sind, misst
+-- supabase/tests/0014_worksite_days.sql (B9/B10/E1/E2) exakt; diese Zeile
+-- beantwortet hier nur noch "die Anwendung kommt an ihre Daten".
select is(
(
select coalesce(array_agg(e.table_name order by e.table_name), array[]::text[])
from expected_tables e
- where not has_table_privilege('authenticated', 'public.' || e.table_name, 'select')
+ where case
+ when e.table_name = 'idempotency_records'
+ then not has_any_column_privilege('authenticated', 'public.' || e.table_name, 'SELECT')
+ else not has_table_privilege('authenticated', 'public.' || e.table_name, 'select')
+ end
),
array[]::text[],
- 'authenticated hat auf jeder erwarteten Tabelle select — sonst laeuft die Anwendung in permission denied'
+ 'authenticated hat auf jeder erwarteten Tabelle select — bei idempotency_records seit 0019 spaltenweise, sonst auf Tabellenebene; sonst laeuft die Anwendung in permission denied'
);
-- ---------------------------------------------------------------------------
diff --git a/supabase/tests/0014_worksite_days.sql b/supabase/tests/0014_worksite_days.sql
new file mode 100644
index 00000000..956c7af8
--- /dev/null
+++ b/supabase/tests/0014_worksite_days.sql
@@ -0,0 +1,1128 @@
+-- 0014_worksite_days — Datenfundament der baustellenzentrierten Planung (EYT-147, M1)
+--
+-- ## Was hier gemessen wird
+--
+-- Migration 0019 legt die stabile Tagesidentitaet `public.worksite_days` und den
+-- revisionsgebundenen Tagesstand `public.worksite_day_configurations` an, ordnet
+-- `assignments` additiv unter eine Tageskonfiguration, ergaenzt
+-- `idempotency_records.result_payload` samt Lesegrenze und stellt drei neue
+-- `app`-Funktionen bereit.
+--
+-- ## Warum die Struktur VOR dem Verhalten steht
+--
+-- Die Abschnitte A bis D fragen ausschliesslich den Systemkatalog und werfen
+-- deshalb auch dann keinen Fehler, wenn die Objekte fehlen — sie melden `not ok`.
+-- Genau das ist der rote Ausgangszustand dieses Meilensteins: einzeln benannte
+-- Fehlschlaege statt eines einzigen Abbruchs, aus dem sich nichts lesen liesse.
+-- Erst Abschnitt E benutzt `has_column_privilege` (das wirft bei fehlender
+-- Spalte und beendet den Lauf), Abschnitt F legt die Fixtures an.
+--
+-- ## Welche Zusicherungen schon VOR 0019 wahr waren
+--
+-- GEMESSEN, indem die Migration beiseitegelegt und `db reset` gefahren wurde:
+-- ein durchgehender Lauf meldet 44 rote und FUENF gruene Zusicherungen — B5, B7,
+-- B8, C8, C9 —, danach bricht er bei E1 ab (`column "result_payload" … does not
+-- exist`). Drei weitere sind hinter der Abbruchstelle ebenfalls vorher gruen und
+-- deshalb nur EINZELN messbar: E2, J0 und K2.
+--
+-- Diese acht sagen nichts ueber 0019; sie sind PRESERVATION-Waechter und sollen
+-- vor UND nach der Migration halten. Jede andere Zusicherung dieser Datei ist im
+-- Vor-Zustand rot. Wer eine neunte vorher-gruene findet, hat eine vakuoese
+-- Zusicherung gefunden.
+--
+-- ## Warum der Kanal hier nur NEGATIV pruefbar ist
+--
+-- `app.is_runtime_channel()` vergleicht `session_user` mit `easytree_app`. In
+-- pgTAP ist `session_user` immer `postgres`; `SET SESSION AUTHORIZATION` verlangt
+-- Superuser und steht hier nicht zur Verfuegung (dieselbe Lage wie in
+-- `0012_planning_data_api_boundary.sql`). Diese Datei belegt deshalb, dass ein
+-- NICHT-Laufzeitkanal abgewiesen wird. Sie belegt NICHT, dass der echte Kanal
+-- durchkommt, und sie erreicht die Zweige `P0002` und `23514` von
+-- `app.remove_assignment_from_worksite_day` nicht — der Kanalriegel steht davor.
+-- Diese Nachweise liegen in M3/M4 ueber eine echte `easytree_app`-Verbindung.
+--
+-- ## Warum die Rechte ueber `aclexplode` und nicht ueber `has_*_privilege`
+--
+-- `has_column_privilege` ist wahr, sobald IRGENDEIN Weg das Recht traegt, und
+-- beantwortet je Spalte nur eine Ja/Nein-Frage. Die Zusicherung dieses Slices ist
+-- aber eine MENGENgleichheit: genau diese Spalten und keine weitere. `aclexplode`
+-- ueber `pg_attribute.attacl` liefert die Menge selbst — eine spaeter zusaetzlich
+-- gegrantete Spalte faellt damit auf, waehrend eine Reihe von Einzelfragen sie
+-- nicht sehen wuerde. Abschnitt E fuehrt die vom Plan woertlich verlangten
+-- `has_column_privilege`-Zusicherungen zusaetzlich, nicht ersatzweise.
+
+begin;
+select plan(80);
+
+-- ===========================================================================
+-- A. Struktur beider neuer Tabellen (katalogbasiert, wirft nie)
+-- ===========================================================================
+
+-- A1/A2: die Spaltenmenge EXAKT — das ist zugleich der Nachweis der beiden
+-- Negativvorgaben aus dem Plan: keine Zeitspalten, kein eigener Publish-Marker,
+-- keine vorgezogenen EYT-120-Felder. Eine Einzelfrage je erwarteter Spalte
+-- koennte das nicht sagen.
+select is(
+ (
+ select coalesce(array_agg(
+ a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod)
+ || case when a.attnotnull then ' NOT NULL' else ' NULL' end
+ order by a.attnum), array[]::text[])
+ from pg_attribute a
+ where a.attrelid = to_regclass('public.worksite_days')
+ and a.attnum > 0 and not a.attisdropped
+ ),
+ array[
+ 'id uuid NOT NULL',
+ 'org_id uuid NOT NULL',
+ 'worksite_id uuid NOT NULL',
+ 'local_date date NOT NULL',
+ 'created_at timestamp with time zone NOT NULL'
+ ],
+ 'A1 worksite_days traegt exakt die fuenf Identitaetsspalten — keine Zeiten, kein Publish-Marker, kein EYT-120-Feld'
+);
+
+select is(
+ (
+ select coalesce(array_agg(
+ a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod)
+ || case when a.attnotnull then ' NOT NULL' else ' NULL' end
+ order by a.attnum), array[]::text[])
+ from pg_attribute a
+ where a.attrelid = to_regclass('public.worksite_day_configurations')
+ and a.attnum > 0 and not a.attisdropped
+ ),
+ array[
+ 'id uuid NOT NULL',
+ 'org_id uuid NOT NULL',
+ 'worksite_day_id uuid NOT NULL',
+ 'plan_version_id uuid NOT NULL',
+ 'lock_version integer NOT NULL',
+ 'created_at timestamp with time zone NOT NULL'
+ ],
+ 'A2 worksite_day_configurations traegt exakt die sechs Revisionsspalten — keine Taetigkeiten, Ressourcen, Status-, Default- oder Zeitraumfelder'
+);
+
+-- A3/A4: Constraints EXAKT, inklusive der beiden Identitaetsgarantien I-3 und
+-- I-4 und der tenantgebundenen Fremdschluessel. Gemessen (PostgreSQL 17.6,
+-- lokaler Stack, Probetabellen, Transaktion zurueckgerollt): der laengste
+-- generierte Name ist `worksite_day_configurations_plan_version_id_worksite_day_id_key`
+-- mit 63 Zeichen und wird damit NICHT abgeschnitten (NAMEDATALEN-1 = 63).
+select is(
+ (
+ select coalesce(array_agg(c.conname::text || ' :: ' || pg_get_constraintdef(c.oid)
+ order by c.conname::text), array[]::text[])
+ from pg_constraint c
+ where c.conrelid = to_regclass('public.worksite_days')
+ ),
+ array[
+ 'worksite_days_id_org_id_key :: UNIQUE (id, org_id)',
+ 'worksite_days_org_id_fkey :: FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE',
+ 'worksite_days_org_id_worksite_id_local_date_key :: UNIQUE (org_id, worksite_id, local_date)',
+ 'worksite_days_pkey :: PRIMARY KEY (id)',
+ 'worksite_days_worksite_id_org_id_fkey :: FOREIGN KEY (worksite_id, org_id) REFERENCES worksites(id, org_id) ON DELETE RESTRICT'
+ ],
+ 'A3 worksite_days: I-4 (org_id, worksite_id, local_date) unique, (id, org_id) unique als FK-Ziel, tenantgebundene Worksite-FK mit RESTRICT'
+);
+
+select is(
+ (
+ select coalesce(array_agg(c.conname::text || ' :: ' || pg_get_constraintdef(c.oid)
+ order by c.conname::text), array[]::text[])
+ from pg_constraint c
+ where c.conrelid = to_regclass('public.worksite_day_configurations')
+ ),
+ array[
+ 'worksite_day_configurations_id_org_id_key :: UNIQUE (id, org_id)',
+ 'worksite_day_configurations_lock_version_check :: CHECK ((lock_version >= 0))',
+ 'worksite_day_configurations_org_id_fkey :: FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE',
+ 'worksite_day_configurations_pkey :: PRIMARY KEY (id)',
+ 'worksite_day_configurations_plan_version_id_org_id_fkey :: FOREIGN KEY (plan_version_id, org_id) REFERENCES plan_versions(id, org_id) ON DELETE CASCADE',
+ 'worksite_day_configurations_plan_version_id_worksite_day_id_key :: UNIQUE (plan_version_id, worksite_day_id)',
+ 'worksite_day_configurations_worksite_day_id_org_id_fkey :: FOREIGN KEY (worksite_day_id, org_id) REFERENCES worksite_days(id, org_id)'
+ ],
+ 'A4 worksite_day_configurations: I-3 (plan_version_id, worksite_day_id) unique, tenantgebundene FKs, Kaskade NUR an plan_versions'
+);
+
+select is(
+ (
+ select coalesce(array_agg(c.relname::text order by c.relname::text), array[]::text[])
+ from pg_index i join pg_class c on c.oid = i.indexrelid
+ where i.indrelid = to_regclass('public.worksite_day_configurations')
+ and not i.indisunique and not i.indisprimary
+ ),
+ array['worksite_day_configurations_day_idx', 'worksite_day_configurations_version_idx'],
+ 'A5 worksite_day_configurations traegt die beiden Leseindizes auf Planversion und Tag'
+);
+
+-- A5b: der Leseindex auf der Assignment-Seite. A5 zaehlt nur die beiden Indizes
+-- AUF worksite_day_configurations und saehe sein Verschwinden nicht.
+select is(
+ (
+ select count(*)::int
+ from pg_index i join pg_class c on c.oid = i.indexrelid
+ where i.indrelid = to_regclass('public.assignments')
+ and c.relname = 'assignments_worksite_day_configuration_idx'
+ ),
+ 1,
+ 'A5b assignments traegt den Leseindex auf worksite_day_configuration_id'
+);
+
+-- A6: die additive Assignment-Spalte. NULLABLE ist die Aussage — Bestandszeilen
+-- werden nicht angefasst, und ein Backfill findet nicht statt.
+select is(
+ (
+ select a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod)
+ || case when a.attnotnull then ' NOT NULL' else ' NULL' end
+ from pg_attribute a
+ where a.attrelid = to_regclass('public.assignments')
+ and a.attname = 'worksite_day_configuration_id' and not a.attisdropped
+ ),
+ 'worksite_day_configuration_id uuid NULL',
+ 'A6 assignments.worksite_day_configuration_id ist additiv und NULLABLE — Bestandszeilen bleiben gueltig'
+);
+
+select is(
+ (
+ select pg_get_constraintdef(c.oid)
+ from pg_constraint c
+ where c.conrelid = to_regclass('public.assignments')
+ and c.conname = 'assignments_worksite_day_configuration_fk'
+ ),
+ 'FOREIGN KEY (worksite_day_configuration_id, org_id) REFERENCES worksite_day_configurations(id, org_id)',
+ 'A7 assignments: tenantgebundene FK auf die Tageskonfiguration, ohne Kaskade'
+);
+
+select is(
+ (
+ select a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod)
+ || case when a.attnotnull then ' NOT NULL' else ' NULL' end
+ from pg_attribute a
+ where a.attrelid = to_regclass('public.idempotency_records')
+ and a.attname = 'result_payload' and not a.attisdropped
+ ),
+ 'result_payload jsonb NULL',
+ 'A8 idempotency_records.result_payload ist additiv und NULLABLE — Bestandsvorgaenge bleiben schreibbar'
+);
+
+-- A9: die beiden Pflicht-Checks auf result_payload. Der zweite ist der
+-- eigentliche Riegel: ein payloadloser Insert der neuen Vorgaenge ist 23514,
+-- nicht ein stiller Zustand, in dem ein Replay weder reproduzieren noch ehrlich
+-- scheitern koennte.
+select is(
+ (
+ select coalesce(array_agg(c.conname::text order by c.conname::text), array[]::text[])
+ from pg_constraint c
+ where c.conrelid = to_regclass('public.idempotency_records')
+ and c.contype = 'c'
+ and c.conname like 'idempotency_records_%payload%'
+ ),
+ array['idempotency_records_result_payload_is_object',
+ 'idempotency_records_worksite_day_ops_need_payload'],
+ 'A9 idempotency_records: Objektform UND Payloadpflicht der beiden WorksiteDay-Vorgaenge sind Datenbankregeln'
+);
+
+-- ===========================================================================
+-- B. Rechtelage als MENGE (katalogbasiert ueber aclexplode, wirft nie)
+-- ===========================================================================
+
+select is(
+ (
+ select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[])
+ from pg_attribute a
+ cross join lateral aclexplode(a.attacl) ac
+ where a.attrelid = to_regclass('public.worksite_days')
+ and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'INSERT'
+ ),
+ array['local_date', 'org_id', 'worksite_id'],
+ 'B1 worksite_days: INSERT erreicht exakt (org_id, worksite_id, local_date) — id und created_at bleiben der Datenbank'
+);
+
+select is(
+ (
+ select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[])
+ from pg_attribute a
+ cross join lateral aclexplode(a.attacl) ac
+ where a.attrelid = to_regclass('public.worksite_day_configurations')
+ and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'INSERT'
+ ),
+ array['lock_version', 'org_id', 'plan_version_id', 'worksite_day_id'],
+ 'B2 worksite_day_configurations: INSERT erreicht exakt die vier fachlichen Spalten'
+);
+
+select is(
+ (
+ select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[])
+ from pg_attribute a
+ cross join lateral aclexplode(a.attacl) ac
+ where a.attrelid = to_regclass('public.worksite_day_configurations')
+ and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'UPDATE'
+ ),
+ array['lock_version'],
+ 'B3 worksite_day_configurations: UPDATE erreicht ausschliesslich lock_version'
+);
+
+-- B4: kein DELETE auf beiden neuen Tabellen. `delete` kennt kein Spaltenrecht,
+-- die Frage geht deshalb an relacl.
+select is(
+ (
+ select coalesce(array_agg(c.relname::text || ':' || ac.privilege_type order by c.relname::text, ac.privilege_type), array[]::text[])
+ from pg_class c
+ cross join lateral aclexplode(c.relacl) ac
+ where c.oid in (to_regclass('public.worksite_days'), to_regclass('public.worksite_day_configurations'))
+ and ac.grantee = 'authenticated'::regrole
+ ),
+ array['worksite_day_configurations:SELECT', 'worksite_days:SELECT'],
+ 'B4 beide neuen Tabellen: auf Tabellenebene traegt authenticated NUR select — kein delete, kein pauschales insert/update'
+);
+
+select is(
+ (
+ select coalesce(array_agg(c.relname::text || ':' || ac.privilege_type order by c.relname::text, ac.privilege_type), array[]::text[])
+ from pg_class c
+ cross join lateral aclexplode(c.relacl) ac
+ where c.oid in (to_regclass('public.worksite_days'), to_regclass('public.worksite_day_configurations'))
+ and ac.grantee = 'anon'::regrole
+ ),
+ array[]::text[],
+ 'B5 beide neuen Tabellen: anon traegt kein einziges Recht'
+);
+
+-- B6/B7: der 0017-Grantumfang auf assignments waechst um GENAU eine Spalte.
+select is(
+ (
+ select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[])
+ from pg_attribute a
+ cross join lateral aclexplode(a.attacl) ac
+ where a.attrelid = to_regclass('public.assignments')
+ and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'INSERT'
+ ),
+ array['employee_id', 'ends_at_utc', 'org_id', 'plan_version_id', 'starts_at_utc',
+ 'worksite_day_configuration_id', 'worksite_id'],
+ 'B6 assignments: der INSERT-Grant waechst um genau worksite_day_configuration_id — id bleibt draussen (0012 A4)'
+);
+
+-- Gefiltert auf die vier DML-Rechte, wie in 0005 Regel 4/5/7. Gemessen
+-- (PostgreSQL 17.6, lokaler Stack nach 0019): `authenticated` traegt auf
+-- `public.assignments` ausserdem MAINTAIN, REFERENCES, TRIGGER und TRUNCATE —
+-- und zwar auf `public.plan_versions` genauso, also aus den Supabase-
+-- Default-Privilegien und NICHT aus dieser Migration. Sie hier mitzuzaehlen
+-- machte die Zusicherung zu einer Aussage ueber die Plattform statt ueber den
+-- 0017-Grantumfang, den sie bewachen soll.
+select is(
+ (
+ select coalesce(array_agg(distinct ac.privilege_type order by ac.privilege_type), array[]::text[])
+ from pg_class c
+ cross join lateral aclexplode(c.relacl) ac
+ where c.oid = to_regclass('public.assignments') and ac.grantee = 'authenticated'::regrole
+ and ac.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
+ ),
+ array['SELECT'],
+ 'B7 assignments: von den vier DML-Rechten bleibt auf Tabellenebene NUR select — kein update-, kein delete-Grant (0012 A1/A2 bleiben wahr)'
+);
+
+select is(
+ (
+ select coalesce(array_agg(distinct ac.privilege_type order by ac.privilege_type), array[]::text[])
+ from pg_attribute a
+ cross join lateral aclexplode(a.attacl) ac
+ where a.attrelid = to_regclass('public.assignments')
+ and ac.grantee = 'authenticated'::regrole and ac.privilege_type in ('UPDATE', 'DELETE')
+ ),
+ array[]::text[],
+ 'B8 assignments: auch spaltenweise traegt authenticated kein update- und kein delete-Recht'
+);
+
+-- B9/B10: die Lesegrenze des Payloads sitzt auf der SPALTE. Das Tabellenrecht
+-- select ist entzogen, die uebrigen sieben Spalten sind einzeln gegrantet, und
+-- result_payload ist in dieser Menge NICHT enthalten.
+select is(
+ (
+ select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[])
+ from pg_attribute a
+ cross join lateral aclexplode(a.attacl) ac
+ where a.attrelid = to_regclass('public.idempotency_records')
+ and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'SELECT'
+ ),
+ array['created_at', 'id', 'idempotency_key', 'operation', 'org_id',
+ 'request_fingerprint', 'subject_id'],
+ 'B9 idempotency_records: SELECT ist spaltenweise vergeben und result_payload steht NICHT darin'
+);
+
+select is(
+ (
+ select coalesce(array_agg(distinct ac.privilege_type order by ac.privilege_type), array[]::text[])
+ from pg_class c
+ cross join lateral aclexplode(c.relacl) ac
+ where c.oid = to_regclass('public.idempotency_records') and ac.grantee = 'authenticated'::regrole
+ and ac.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
+ ),
+ array['INSERT'],
+ 'B10 idempotency_records: von den vier DML-Rechten bleibt auf Tabellenebene NUR insert — select ist entzogen und spaltenweise neu vergeben, update und delete gab es nie'
+);
+
+-- ===========================================================================
+-- C. Policy- und RLS-Lage (katalogbasiert, wirft nie)
+-- ===========================================================================
+
+select is(
+ (
+ select coalesce(array_agg(c.relname::text || ' rls=' || c.relrowsecurity::text
+ || ' forced=' || c.relforcerowsecurity::text
+ order by c.relname::text), array[]::text[])
+ from pg_class c
+ where c.oid in (to_regclass('public.worksite_days'), to_regclass('public.worksite_day_configurations'))
+ ),
+ array['worksite_day_configurations rls=true forced=true', 'worksite_days rls=true forced=true'],
+ 'C1 beide neuen Tabellen haben RLS aktiviert UND erzwungen'
+);
+
+select is(
+ (
+ select coalesce(array_agg(p.cmd::text order by p.cmd::text), array[]::text[])
+ from pg_policies p
+ where p.schemaname = 'public' and p.tablename = 'worksite_days'
+ ),
+ array['INSERT', 'SELECT'],
+ 'C2 worksite_days: genau zwei Policies (select, insert) — keine update-, keine delete-Policy, die ein spaeteres grant reaktivieren koennte'
+);
+
+select is(
+ (
+ select coalesce(array_agg(p.cmd::text order by p.cmd::text), array[]::text[])
+ from pg_policies p
+ where p.schemaname = 'public' and p.tablename = 'worksite_day_configurations'
+ ),
+ array['INSERT', 'SELECT', 'UPDATE'],
+ 'C3 worksite_day_configurations: genau drei Policies — keine delete-Policy'
+);
+
+select ok(
+ (
+ select coalesce(qual, '') || coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'worksite_days' and cmd = 'INSERT'
+ ) like '%is_runtime_channel%',
+ 'C4 worksite_days: die INSERT-Policy prueft den Laufzeitkanal'
+);
+
+select ok(
+ (
+ select coalesce(qual, '') || coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'worksite_days' and cmd = 'INSERT'
+ ) like '%planning.write%',
+ 'C5 worksite_days: die INSERT-Policy prueft das atomare Recht planning.write'
+);
+
+select ok(
+ (
+ select coalesce(qual, '') || coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'INSERT'
+ ) like '%is_runtime_channel%'
+ and (
+ select coalesce(qual, '') || coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'INSERT'
+ ) like '%planning.write%',
+ 'C6 worksite_day_configurations: die INSERT-Policy prueft Kanal UND planning.write'
+);
+
+-- C7 misst die beiden Klauseln EINZELN. Eine frueher Fassung verkettete
+-- `qual || with_check` zu einem String und suchte darin — ein Treffer in nur
+-- EINER Haelfte genuegte dann, obwohl der Zusicherungstext "in beiden Klauseln"
+-- behauptet (Review-MESSUNG: eine UPDATE-Policy mit vollem `using`, aber
+-- `with check (org_id in (…))` ohne Kanal und ohne Recht, blieb gruen).
+select is(
+ (
+ select coalesce(array_agg(teil || '=' || (klausel like '%is_runtime_channel%'
+ and klausel like '%planning.write%')::text
+ order by teil), array[]::text[])
+ from (
+ select 'qual' as teil, coalesce(qual, '') as klausel from pg_policies
+ where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'UPDATE'
+ union all
+ select 'with_check', coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'UPDATE'
+ ) k
+ ),
+ array['qual=true', 'with_check=true'],
+ 'C7 worksite_day_configurations: die UPDATE-Policy prueft Kanal UND planning.write in JEDER der beiden Klauseln einzeln'
+);
+
+-- C4b/C5b: die MANDANTENGRENZE der beiden Lese-Policies. Sie ist dort das
+-- EINZIGE Glied — ohne diese Zusicherung liesse sich `using (org_id in (select
+-- app.user_org_ids()))` durch `using (true)` ersetzen, und ALLE 14 pgTAP-Dateien
+-- des Repos blieben gruen (Review-MESSUNG, Gegenmutation in einer Transaktion).
+select is(
+ (
+ select coalesce(array_agg(p.tablename::text || ' :: ' || coalesce(p.qual, '')
+ order by p.tablename::text), array[]::text[])
+ from pg_policies p
+ where p.schemaname = 'public' and p.cmd = 'SELECT'
+ and p.tablename in ('worksite_days', 'worksite_day_configurations')
+ ),
+ array[
+ 'worksite_day_configurations :: (org_id IN ( SELECT app.user_org_ids() AS user_org_ids))',
+ 'worksite_days :: (org_id IN ( SELECT app.user_org_ids() AS user_org_ids))'
+ ],
+ 'C4b beide Lese-Policies binden woertlich an die aktiven Mitgliedschaften der Identitaet — hier ist die Mandantengrenze das einzige Glied'
+);
+
+-- C5b: und die INSERT-Policies tragen dasselbe Glied. Heute deckt
+-- `app.has_permission(org_id, …)` denselben Mandanten mit ab; die Zusicherung
+-- schuetzt die Tiefenverteidigung, nicht die heutige Grenze.
+select is(
+ (
+ select count(*)::int from pg_policies
+ where schemaname = 'public' and cmd = 'INSERT'
+ and tablename in ('worksite_days', 'worksite_day_configurations')
+ and coalesce(with_check, '') like '%org_id IN ( SELECT app.user_org_ids()%'
+ ),
+ 2,
+ 'C5b beide INSERT-Policies binden zusaetzlich zu Kanal und Recht ausdruecklich an die Organisation'
+);
+
+-- C8: 0012 A/B-Erhalt an der Grenze. Genau EINE Schreibpolicy auf assignments —
+-- keine zweite, die neben der 0017-Fassung stuende.
+select is(
+ (
+ select count(*)::int from pg_policies
+ where schemaname = 'public' and tablename = 'assignments'
+ and cmd in ('INSERT', 'UPDATE', 'DELETE')
+ ),
+ 1,
+ 'C8 assignments: es bleibt bei GENAU EINER Schreibpolicy (INSERT) — 0019 fuegt keine zweite hinzu'
+);
+
+-- C9: die SELECT-Policy von idempotency_records ist gegenueber 0012 woertlich
+-- unveraendert. Die Grenze soll auf der Spalte sitzen (B9), nicht in der
+-- Zeilenbedingung — sonst verschoebe sich unbemerkt, was 0012 zusichert.
+select is(
+ (
+ select qual from pg_policies
+ where schemaname = 'public' and tablename = 'idempotency_records' and cmd = 'SELECT'
+ ),
+ '(org_id IN ( SELECT app.user_org_ids() AS user_org_ids))',
+ 'C9 idempotency_records: die SELECT-Policy ist byte-gleich zur 0012-Fassung — die Lesegrenze sitzt auf der Spalte, nicht in der Zeile'
+);
+
+select ok(
+ (
+ select coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'idempotency_records' and cmd = 'INSERT'
+ ) like '%is_runtime_channel%',
+ 'C10 idempotency_records: die INSERT-Policy verlangt den Laufzeitkanal, sobald ein result_payload geschrieben wird'
+);
+
+-- C10b: und zwar OPERATIONSGEBUNDEN ueber die Payloadbedingung, nicht pauschal —
+-- sonst braeche `costs.create_rate_version`, dessen Bestandssuite ueber eine
+-- postgres-Sitzung laeuft.
+select ok(
+ (
+ select coalesce(with_check, '') from pg_policies
+ where schemaname = 'public' and tablename = 'idempotency_records' and cmd = 'INSERT'
+ ) like '%result_payload IS NULL%',
+ 'C11 idempotency_records: die Kanalpflicht haengt an result_payload — ein payloadloser Bestandsvorgang bleibt ohne Kanal schreibbar'
+);
+
+-- ===========================================================================
+-- D. Struktur der neuen Funktionen (Stolperdraehte nach dem 0006-Muster)
+-- ===========================================================================
+
+select is(
+ (
+ select coalesce(array_agg(p.proname::text || '=' || p.prosecdef::text order by p.proname::text), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app'
+ and p.proname in ('reject_worksite_day_configuration_change_in_published_plan',
+ 'remove_assignment_from_worksite_day',
+ 'read_idempotency_result',
+ 'lock_week_draft',
+ 'assignment_belongs_to_worksite_day_configuration')
+ ),
+ array[
+ 'assignment_belongs_to_worksite_day_configuration=false',
+ 'lock_week_draft=true',
+ 'read_idempotency_result=true',
+ 'reject_worksite_day_configuration_change_in_published_plan=true',
+ 'remove_assignment_from_worksite_day=true'
+ ],
+ 'D1 alle fuenf neuen app-Funktionen existieren mit der geplanten Rechtestellung — vier definer, der Zugehoerigkeitstrigger bewusst invoker'
+);
+
+select is(
+ (
+ -- `proconfig::text` rendert die Array-Literalform mit Backslash-Escapes
+ -- (`{"search_path=\"\""}`) und machte den Vergleich zu einer Aussage ueber
+ -- die Quotierung. `array_to_string` liefert den Wert selbst.
+ select coalesce(array_agg(p.proname::text || ' -> '
+ || coalesce(array_to_string(p.proconfig, ','), '')
+ order by p.proname::text), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app'
+ and p.proname in ('reject_worksite_day_configuration_change_in_published_plan',
+ 'remove_assignment_from_worksite_day',
+ 'read_idempotency_result',
+ 'lock_week_draft',
+ 'assignment_belongs_to_worksite_day_configuration')
+ ),
+ array[
+ 'assignment_belongs_to_worksite_day_configuration -> search_path=""',
+ 'lock_week_draft -> search_path=""',
+ 'read_idempotency_result -> search_path=""',
+ 'reject_worksite_day_configuration_change_in_published_plan -> search_path=""',
+ 'remove_assignment_from_worksite_day -> search_path=""'
+ ],
+ 'D2 alle fuenf neuen Funktionen setzen search_path = "" — kein Suchpfad aus der Sitzung'
+);
+
+select is(
+ (
+ select coalesce(array_agg(g.g order by g.g), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ cross join lateral unnest(p.proacl::text[]) as g(g)
+ where n.nspname = 'app' and p.proname = 'read_idempotency_result'
+ ),
+ array['authenticated=X/postgres', 'postgres=X/postgres'],
+ 'D3 app.read_idempotency_result: execute nur fuer authenticated und den Eigentuemer — public ist entzogen'
+);
+
+select is(
+ (
+ select coalesce(array_agg(g.g order by g.g), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ cross join lateral unnest(p.proacl::text[]) as g(g)
+ where n.nspname = 'app' and p.proname = 'lock_week_draft'
+ ),
+ array['authenticated=X/postgres', 'postgres=X/postgres'],
+ 'D4 app.lock_week_draft: execute nur fuer authenticated und den Eigentuemer'
+);
+
+select is(
+ (
+ select coalesce(array_agg(g.g order by g.g), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ cross join lateral unnest(p.proacl::text[]) as g(g)
+ where n.nspname = 'app' and p.proname = 'remove_assignment_from_worksite_day'
+ ),
+ array['authenticated=X/postgres', 'postgres=X/postgres'],
+ 'D5 app.remove_assignment_from_worksite_day: execute nur fuer authenticated und den Eigentuemer'
+);
+
+-- D6/D7: F1-Strukturhaelfte. Die Signatur traegt KEINEN Organisationsparameter —
+-- den duerfte sonst der Client waehlen —, und der ausfuehrbare Rumpf enthaelt
+-- weder `limit 1` noch `order by`, also keine geratene Auswahl unter mehreren
+-- Mitgliedschaften. Der Match strippt vorher die Kommentare: `pg_get_functiondef`
+-- liefert sie mit, und die normative Begruendung des Designs nennt
+-- `order by … limit 1` woertlich als das Ausgeschlossene — die naive Fassung
+-- ginge auf einer KORREKTEN Implementierung rot und belohnte das Loeschen genau
+-- dieser Begruendung.
+select is(
+ (
+ select pg_get_function_arguments(p.oid)
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'read_idempotency_result'
+ ),
+ 'p_operation text, p_key text',
+ 'D6 app.read_idempotency_result traegt KEINEN Organisationsparameter — die Mandantenbindung kommt aus dem authentisierten Kontext'
+);
+
+select ok(
+ (
+ select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'read_idempotency_result'
+ ) !~* '(limit +1|order +by)',
+ 'D7 app.read_idempotency_result: der ausfuehrbare Rumpf enthaelt weder limit 1 noch order by — keine geratene Organisationsauswahl'
+);
+
+-- D8: und die Auswahl ist an den VOLLSTAENDIGEN Unique-Schluessel gebunden.
+select ok(
+ (
+ select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'read_idempotency_result'
+ ) ~* 'org_id *= *v_org',
+ 'D8 app.read_idempotency_result: die Payloadabfrage ist an die aufgeloeste Organisation gebunden, nicht nur nachgefiltert'
+);
+
+-- D9 bis D12: F2/F3-Strukturhaelfte von app.lock_week_draft.
+select is(
+ (
+ select pg_get_function_arguments(p.oid)
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'lock_week_draft'
+ ),
+ 'p_week_key text',
+ 'D9 app.lock_week_draft traegt KEINEN Organisationsparameter (F3)'
+);
+
+select ok(
+ (
+ select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'lock_week_draft'
+ ) ~* 'for +share',
+ 'D10 app.lock_week_draft erwirbt einen Klasse-(2)-Lock mit for share — die Invoker-Variante im Anwendungscode ist damit ausgeschlossen'
+);
+
+select ok(
+ (
+ select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'lock_week_draft'
+ ) ~* 'loop',
+ 'D11 app.lock_week_draft enthaelt die Wiederholungsrunde — nach einem konkurrierenden Publish steht der Aufrufer nicht ohne Entwurf da'
+);
+
+select ok(
+ (
+ select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'lock_week_draft'
+ ) !~* '(limit +1|order +by|org_id +in *\()',
+ 'D12 app.lock_week_draft: kein limit 1, kein order by und KEINE Mengenform org_id in ( — unter definer waere die Menge eine Auswahl, keine Grenze (F3)'
+);
+
+select ok(
+ (
+ select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.proname = 'lock_week_draft'
+ ) ~* 'org_id *= *v_org',
+ 'D13 app.lock_week_draft: jeder Zugriff ist explizit an die eine aufgeloeste Organisation gebunden'
+);
+
+-- D14: die Zusicherung, gegenueber der 0012 A1/A2/B1/C2/C3 strukturell BLIND
+-- sind. Gemessen: bei gruenem A2/B1/C3 loeschte ein definer-Weg die Zeile
+-- trotzdem, weil der Eigentuemer weder Grant noch Policy passiert. Genau EINE
+-- benannte app-Funktion darf mit Ownerrechten aus public.assignments loeschen.
+select is(
+ (
+ select coalesce(array_agg(p.proname::text order by p.proname::text), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app' and p.prokind = 'f' and p.prosecdef
+ and pg_get_functiondef(p.oid) ~* 'delete +from +public\.assignments'
+ ),
+ array['remove_assignment_from_worksite_day'],
+ 'D14 genau eine security-definer-Funktion in app darf aus public.assignments loeschen — jede weitere waere ein Loch, das 0012 A1/A2/B1/C2/C3 nicht sehen'
+);
+
+-- D15: die REICHWEITE beider Trigger, nicht nur ihre Existenz. Der Aufnahme-Guard
+-- muss auch beim UMHAENGEN feuern (`update of plan_version_id`); auf `before
+-- insert` reduziert, liesse sich eine Tageskonfiguration nachtraeglich in eine
+-- veroeffentlichte Version haengen. Der Unveraenderlichkeits-Guard muss `delete`
+-- UND `update` abdecken.
+select is(
+ (
+ select coalesce(array_agg(t.tgname::text || ' :: ' || pg_get_triggerdef(t.oid)
+ order by t.tgname::text), array[]::text[])
+ from pg_trigger t
+ where t.tgrelid = to_regclass('public.worksite_day_configurations') and not t.tgisinternal
+ ),
+ array[
+ 'worksite_day_configurations_immutable_when_published :: CREATE TRIGGER worksite_day_configurations_immutable_when_published BEFORE DELETE OR UPDATE ON public.worksite_day_configurations FOR EACH ROW EXECUTE FUNCTION app.reject_worksite_day_configuration_change_in_published_plan()',
+ 'worksite_day_configurations_reject_published_plan :: CREATE TRIGGER worksite_day_configurations_reject_published_plan BEFORE INSERT OR UPDATE OF plan_version_id ON public.worksite_day_configurations FOR EACH ROW EXECUTE FUNCTION app.reject_assignment_in_published_plan()'
+ ],
+ 'D15 beide Trigger auf worksite_day_configurations haben exakt die geplante Reichweite — der Aufnahme-Guard feuert auch beim Umhaengen, der Unveraenderlichkeits-Guard bei update UND delete'
+);
+
+-- D16: auch die beiden TRIGGERfunktionen haben PUBLIC entzogen. D3/D4/D5 pruefen
+-- nur die drei aufrufbaren Funktionen; ohne diese Zeile bliebe ein
+-- `grant execute … to public` auf einer Triggerfunktion unbemerkt.
+select is(
+ (
+ select coalesce(array_agg(p.proname::text || ' :: '
+ || coalesce(array_to_string(p.proacl::text[], ','), '')
+ order by p.proname::text), array[]::text[])
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'app'
+ and p.proname in ('reject_worksite_day_configuration_change_in_published_plan',
+ 'assignment_belongs_to_worksite_day_configuration')
+ ),
+ array[
+ 'assignment_belongs_to_worksite_day_configuration :: postgres=X/postgres',
+ 'reject_worksite_day_configuration_change_in_published_plan :: postgres=X/postgres'
+ ],
+ 'D16 beide neuen Triggerfunktionen tragen NUR das Eigentuemerrecht — PUBLIC ist entzogen, und sie sind fuer niemanden direkt aufrufbar'
+);
+
+-- ===========================================================================
+-- E. Rechte in der vom Plan woertlich verlangten Form
+-- ===========================================================================
+-- Abschnitt B misst dieselbe Sache als Menge und ist damit schaerfer. Diese vier
+-- Zeilen fuehren die Frage zusaetzlich so, wie der akzeptierte Plan sie stellt —
+-- und sie WERFEN bei fehlender Spalte, stehen deshalb hinter dem katalogbasierten
+-- Teil.
+
+select ok(
+ not has_column_privilege('authenticated', 'public.idempotency_records', 'result_payload', 'SELECT'),
+ 'E1 (P2) result_payload ist fuer authenticated auf KEINEM direkten Weg lesbar — weder ueber die Data-API noch roh im Laufzeitkanal'
+);
+
+select ok(
+ has_column_privilege('authenticated', 'public.idempotency_records', 'subject_id', 'SELECT')
+ and has_column_privilege('authenticated', 'public.idempotency_records', 'request_fingerprint', 'SELECT'),
+ 'E2 (P1) der einzige Bestandsleser (subject_id, request_fingerprint) behaelt sein Recht — alle vier Bestandsvorgaenge bleiben unberuehrt'
+);
+
+select ok(
+ has_column_privilege('authenticated', 'public.idempotency_records', 'result_payload', 'INSERT'),
+ 'E3 das Tabellen-INSERT aus 0012 deckt die neue Spalte mit ab — gemessen statt angenommen'
+);
+
+select ok(
+ has_column_privilege('authenticated', 'public.assignments', 'worksite_day_configuration_id', 'INSERT')
+ and not has_column_privilege('authenticated', 'public.assignments', 'id', 'INSERT'),
+ 'E4 assignments: die neue Spalte ist insert-bar, id bleibt es nicht (0012 A4 laeuft nach der Neuvergabe erneut)'
+);
+
+-- ===========================================================================
+-- F. Fixtures (Eigentuemerpfad — PO-Vorgabe 07.08.2026)
+-- ===========================================================================
+-- Zwei Baustellen, drei Planversionen (ein Entwurf zum Arbeiten, eine
+-- veroeffentlichte fuer die Unveraenderlichkeit, ein Entwurf zum Loeschen), zwei
+-- Tagesidentitaeten und drei Tageskonfigurationen. Alles in Org Alpha; die
+-- Transaktion wird am Ende zurueckgerollt.
+
+insert into public.worksites (id, org_id, name, activity_count)
+values ('00000000-0000-4000-8000-0000005011a1', '00000000-0000-4000-8000-0000000000a1',
+ 'Alpha Allee 2', 1);
+
+insert into public.plan_versions (id, org_id, week_key)
+values ('00000000-0000-4000-8000-0000006040a1', '00000000-0000-4000-8000-0000000000a1', '2026-W40'),
+ ('00000000-0000-4000-8000-0000006041a1', '00000000-0000-4000-8000-0000000000a1', '2026-W41'),
+ ('00000000-0000-4000-8000-0000006042a1', '00000000-0000-4000-8000-0000000000a1', '2026-W42');
+
+insert into public.worksite_days (id, org_id, worksite_id, local_date)
+values ('00000000-0000-4000-8000-0000008010a1', '00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000005010a1', '2026-09-28'),
+ ('00000000-0000-4000-8000-0000008011a1', '00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000005010a1', '2026-09-29');
+
+insert into public.worksite_day_configurations
+ (id, org_id, worksite_day_id, plan_version_id)
+values
+ -- Arbeitsstand im Entwurf W40
+ ('00000000-0000-4000-8000-0000009010a1', '00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000008010a1', '00000000-0000-4000-8000-0000006040a1'),
+ -- Stand in W41, der GLEICH VEROEFFENTLICHT wird
+ ('00000000-0000-4000-8000-0000009011a1', '00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000008010a1', '00000000-0000-4000-8000-0000006041a1'),
+ -- Stand im Entwurf W42, der gleich mit seinem Entwurf geloescht wird
+ ('00000000-0000-4000-8000-0000009012a1', '00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000008011a1', '00000000-0000-4000-8000-0000006042a1');
+
+update public.plan_versions
+ set published_at = '2026-10-05T12:00:00Z',
+ published_by = '00000000-0000-4000-8000-00000000aaa1'
+ where id = '00000000-0000-4000-8000-0000006041a1';
+
+-- ===========================================================================
+-- G. Negative Datenbank-Invarianten
+-- ===========================================================================
+-- SQLSTATE allein waere mehrdeutig: 23514 entsteht aus jedem CHECK und aus jedem
+-- Trigger, der ihn erhebt. Jede Zusicherung nennt deshalb zusaetzlich den
+-- Wortlaut — nur so sagt der rote Lauf, WELCHER Riegel gehalten hat.
+
+select throws_ok(
+ $$insert into public.worksite_day_configurations (org_id, worksite_day_id, plan_version_id)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000008011a1',
+ '00000000-0000-4000-8000-0000006041a1')$$,
+ '23514',
+ 'Planversion 00000000-0000-4000-8000-0000006041a1 ist veroeffentlicht und nimmt keine Zuweisung mehr auf',
+ 'G1 eine veroeffentlichte Planversion nimmt KEINE neue Tageskonfiguration mehr auf (Aufnahme-Guard, generische 0016-Fassung wiederverwendet)'
+);
+
+select throws_ok(
+ $$update public.worksite_day_configurations set lock_version = 1
+ where id = '00000000-0000-4000-8000-0000009011a1'$$,
+ '23514',
+ 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich',
+ 'G2 die Tageskonfiguration einer veroeffentlichten Version laesst sich nicht aendern — ohne eigenen Publish-Marker, der Versionszustand wird gelesen'
+);
+
+select throws_ok(
+ $$delete from public.worksite_day_configurations
+ where id = '00000000-0000-4000-8000-0000009011a1'$$,
+ '23514',
+ 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich',
+ 'G3 die Tageskonfiguration einer veroeffentlichten Version laesst sich nicht loeschen'
+);
+
+select throws_ok(
+ $$insert into public.worksite_day_configurations (org_id, worksite_day_id, plan_version_id)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000008010a1',
+ '00000000-0000-4000-8000-0000006040a1')$$,
+ '23505',
+ NULL,
+ 'G4 (I-3) ein Baustellentag hat je Planversion HOECHSTENS EINEN Stand — der Riegel ist der in A4 benannte unique-Constraint'
+);
+
+select throws_ok(
+ $$insert into public.worksite_days (org_id, worksite_id, local_date)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000005010a1', '2026-09-28')$$,
+ '23505',
+ NULL,
+ 'G5 (I-4) derselbe reale Baustellentag ist immer dieselbe Zeile — der Riegel ist der in A3 benannte unique-Constraint'
+);
+
+select throws_ok(
+ $$insert into public.assignments
+ (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id,
+ starts_at_utc, ends_at_utc)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000006040a1',
+ '00000000-0000-4000-8000-0000009010a1',
+ '00000000-0000-4000-8000-0000004010a1',
+ '00000000-0000-4000-8000-0000005011a1',
+ '2026-09-28T06:00:00Z', '2026-09-28T14:00:00Z')$$,
+ '23514',
+ 'Zuweisung widerspricht Baustelle oder Planversion ihrer Tageskonfiguration',
+ 'G6 (I-1) eine Zuweisung mit FREMDER Baustelle wird abgewiesen — beide Zeilen sind fuer sich gueltig, nur ihre Kombination nicht'
+);
+
+select throws_ok(
+ $$insert into public.assignments
+ (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id,
+ starts_at_utc, ends_at_utc)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000006042a1',
+ '00000000-0000-4000-8000-0000009010a1',
+ '00000000-0000-4000-8000-0000004010a1',
+ '00000000-0000-4000-8000-0000005010a1',
+ '2026-09-28T06:00:00Z', '2026-09-28T14:00:00Z')$$,
+ '23514',
+ 'Zuweisung widerspricht Baustelle oder Planversion ihrer Tageskonfiguration',
+ 'G7 (I-1) eine Zuweisung mit FREMDER Planversion wird abgewiesen — der Fremdschluessel allein saehe hier nichts'
+);
+
+-- G8: die Zugehoerigkeit einer Tageskonfiguration ist UNVERAENDERLICH. Ohne
+-- diesen Riegel liesse sich I-1 NACHTRAEGLICH brechen: der Zugehoerigkeitstrigger
+-- auf `assignments` (G6/G7) feuert nur dort und laeuft bei einer Aenderung HIER
+-- nie erneut. Review-MESSUNG: beide Netze liessen das Umhaengen durch, danach
+-- zeigte die Konfiguration auf Planversion B und ihre Zuweisungen auf A.
+select throws_ok(
+ $$update public.worksite_day_configurations
+ set plan_version_id = '00000000-0000-4000-8000-0000006042a1'
+ where id = '00000000-0000-4000-8000-0000009010a1'$$,
+ '23514',
+ 'Tageskonfiguration kann nicht auf einen anderen Tag oder eine andere Planversion umgehaengt werden',
+ 'G8 eine Tageskonfiguration laesst sich nicht in eine andere Planversion umhaengen — auch nicht in einen ENTWURF, wo der Publish-Guard nichts saehe'
+);
+
+select throws_ok(
+ $$update public.worksite_day_configurations
+ set worksite_day_id = '00000000-0000-4000-8000-0000008011a1'
+ where id = '00000000-0000-4000-8000-0000009010a1'$$,
+ '23514',
+ 'Tageskonfiguration kann nicht auf einen anderen Tag oder eine andere Planversion umgehaengt werden',
+ 'G9 eine Tageskonfiguration laesst sich nicht auf einen anderen Baustellentag umhaengen — der Aufnahme-Guard feuert bei dieser Spalte gar nicht'
+);
+
+-- G10: der Unveraenderlichkeits-Guard loest den Publish-Stand an der ELTERNZEILE
+-- auf. Traegt er dabei einen Sichtbarkeitsfilter, erzeugt eine Sitzung mit
+-- ORG-FREMDER Identitaet denselben `not found` wie die Loeschkaskade — und der
+-- fail-open-Zweig fuer DELETE liesse sie durch. Review-MESSUNG an genau dieser
+-- Fassung: `DELETE 1`, die Tageskonfiguration einer VEROEFFENTLICHTEN
+-- Planversion war weg, waehrend dieselbe Anweisung ohne Claims korrekt mit 23514
+-- scheiterte. Der Bestandsguard `app.reject_published_row_change` (0010) hat die
+-- Luecke nicht, weil er `old.published_at` an der Zeile SELBST liest.
+select set_config(
+ 'request.jwt.claims',
+ json_build_object('sub', '00000000-0000-4000-8000-00000000bbb2', 'role', 'authenticated')::text,
+ true
+);
+
+select throws_ok(
+ $$delete from public.worksite_day_configurations
+ where id = '00000000-0000-4000-8000-0000009011a1'$$,
+ '23514',
+ 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich',
+ 'G10 auch eine Sitzung mit ORG-FREMDER Identitaet kann die Tageskonfiguration einer veroeffentlichten Version nicht loeschen — der Guard sieht den Publish-Stand unabhaengig von der Sitzung'
+);
+
+-- Claims wieder entfernen: die folgenden Zusicherungen laufen ohne
+-- Anwendungsidentitaet, wie die vorherigen auch.
+select set_config('request.jwt.claims', '', true);
+
+-- ===========================================================================
+-- H. Positive Invarianten
+-- ===========================================================================
+
+select lives_ok(
+ $$insert into public.assignments
+ (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id,
+ starts_at_utc, ends_at_utc)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000006040a1',
+ '00000000-0000-4000-8000-0000009010a1',
+ '00000000-0000-4000-8000-0000004010a1',
+ '00000000-0000-4000-8000-0000005010a1',
+ '2026-09-28T06:00:00Z', '2026-09-28T10:00:00Z')$$,
+ 'H1 eine Zuweisung, deren Baustelle und Planversion zu ihrer Tageskonfiguration passen, wird angenommen'
+);
+
+select lives_ok(
+ $$insert into public.assignments
+ (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id,
+ starts_at_utc, ends_at_utc)
+ values ('00000000-0000-4000-8000-0000000000a1',
+ '00000000-0000-4000-8000-0000006040a1',
+ '00000000-0000-4000-8000-0000009010a1',
+ '00000000-0000-4000-8000-0000004010a1',
+ '00000000-0000-4000-8000-0000005010a1',
+ '2026-09-28T12:00:00Z', '2026-09-28T16:00:00Z')$$,
+ 'H2 (F) DIESELBE Person darf an DEMSELBEN Baustellentag ZWEI verschiedene Intervalle haben — der Tag traegt keine Zeit und erzwingt keine'
+);
+
+select is(
+ (
+ select count(*)::int from public.assignments
+ where worksite_day_configuration_id = '00000000-0000-4000-8000-0000009010a1'
+ ),
+ 2,
+ 'H3 beide Intervalle stehen wirklich an derselben Tageskonfiguration — lives_ok allein sagt nicht, WORAN sie haengen'
+);
+
+-- H4: die Loeschkaskaden-Ausnahme. Gemessen im Review: eine fail-closed-Variante
+-- des Unveraenderlichkeits-Guards machte genau das unmoeglich und haette die
+-- Aufraeumpfade der Bestandssuiten gebrochen. Im BEFORE-DELETE des Kindes ist die
+-- Elternzeile bereits entfernt — "nicht gefunden" ist dort der NORMALFALL.
+select lives_ok(
+ $$delete from public.plan_versions
+ where id = '00000000-0000-4000-8000-0000006042a1'$$,
+ 'H4 ein UNVEROEFFENTLICHTER Entwurf mit Tageskonfiguration laesst sich loeschen — die Kaskade laeuft, der Guard blockiert sie nicht'
+);
+
+select is(
+ (
+ select count(*)::int from public.worksite_day_configurations
+ where id = '00000000-0000-4000-8000-0000009012a1'
+ ),
+ 0,
+ 'H5 die Tageskonfiguration ist mit ihrem Entwurf gegangen — die Kaskade hat gewirkt, nicht nur nicht geworfen'
+);
+
+select is(
+ (
+ select count(*)::int from public.worksite_days
+ where id = '00000000-0000-4000-8000-0000008011a1'
+ ),
+ 1,
+ 'H6 die stabile TAGESIDENTITAET ueberlebt das Loeschen des Entwurfs — sie haengt nicht an der Planversion (R-01)'
+);
+
+-- H7: die Gegenprobe zu G8/G9. Der Umhaenge-Riegel darf die EINE Spalte, die der
+-- Plan als schreibbar vorsieht, nicht mitsperren — sonst waere der Riegel kein
+-- Riegel, sondern eine Totalsperre, und B3 (update-Grant nur auf lock_version)
+-- liefe ins Leere.
+select lives_ok(
+ $$update public.worksite_day_configurations
+ set lock_version = lock_version + 1
+ where id = '00000000-0000-4000-8000-0000009010a1'$$,
+ 'H7 lock_version bleibt im ENTWURF schreibbar — der Umhaenge-Riegel trifft nur worksite_day_id und plan_version_id'
+);
+
+select is(
+ (
+ select lock_version from public.worksite_day_configurations
+ where id = '00000000-0000-4000-8000-0000009010a1'
+ ),
+ 1,
+ 'H8 der Zaehler ist wirklich gestiegen — lives_ok allein sagt nicht, dass etwas passiert ist'
+);
+
+-- ===========================================================================
+-- J. Kanalgrenze der neuen Funktionen — hier NUR negativ pruefbar
+-- ===========================================================================
+-- Diese Sitzung ist session_user = postgres und erfuellt app.is_runtime_channel()
+-- nie. Die Zusicherungen belegen deshalb genau eines: der Kanalriegel steht VORNE
+-- und laesst nichts an sich vorbei. Die dahinterliegenden Zweige (P0002 bei
+-- fremder Konfiguration, 23514 bei veroeffentlichter Zuweisung, der positive
+-- Kanalfall) sind hier UNERREICHBAR und liegen in M3/M4.
+
+select ok(
+ not app.is_runtime_channel(),
+ 'J0 diese Sitzung ist NICHT der Laufzeitkanal — die Voraussetzung von J1 bis J3'
+);
+
+select throws_ok(
+ $$select app.remove_assignment_from_worksite_day(
+ (select id from public.assignments
+ where worksite_day_configuration_id = '00000000-0000-4000-8000-0000009010a1'
+ order by starts_at_utc limit 1),
+ '00000000-0000-4000-8000-0000009010a1')$$,
+ '42501',
+ 'Nur der Laufzeitkanal darf Zuweisungen aus einem Baustellentag entfernen',
+ 'J1 app.remove_assignment_from_worksite_day weist einen fremden Kanal ab, BEVOR sie irgendetwas ueber die Zuweisung sagt'
+);
+
+select throws_ok(
+ $$select app.read_idempotency_result('planning.create_assignment', 'k1')$$,
+ '42501',
+ 'Kein Payload-Vorgang',
+ 'J2 (P4d) app.read_idempotency_result laesst nur die beiden Payload-Vorgaenge zu — jeder andere Operationsname ist kein Leseweg'
+);
+
+select throws_ok(
+ $$select app.read_idempotency_result('planning.plan_worksite_day', 'k1')$$,
+ '42501',
+ 'Nur der Laufzeitkanal darf ein Replay-Ergebnis lesen',
+ 'J3 (P4a) app.read_idempotency_result weist einen fremden Kanal ab — und zwar NACH der Operationspruefung, weshalb J2 und J3 verschiedene Wortlaute haben'
+);
+
+select throws_ok(
+ $$select app.lock_week_draft('2026-W44')$$,
+ '42501',
+ 'Nur der Laufzeitkanal darf einen Wochenentwurf sperren',
+ 'J4 app.lock_week_draft weist einen fremden Kanal ab — der Riegel steht vor jeder Mandantenaufloesung'
+);
+
+-- ===========================================================================
+-- K. Payloadpflicht der beiden neuen Vorgaenge
+-- ===========================================================================
+-- Der Check ist eine Datenbankregel, kein Testversprechen: ein payloadloser
+-- Insert der neuen Operationen scheitert, statt einen Zustand zu hinterlassen,
+-- in dem ein Replay weder reproduzieren noch ehrlich scheitern koennte.
+
+select throws_ok(
+ $$insert into public.idempotency_records
+ (org_id, operation, idempotency_key, subject_id, request_fingerprint)
+ values ('00000000-0000-4000-8000-0000000000a1', 'planning.plan_worksite_day',
+ 'k-ohne-payload', '00000000-0000-4000-8000-0000009010a1', 'fp1')$$,
+ '23514',
+ NULL,
+ 'K1 planning.plan_worksite_day ohne result_payload wird abgewiesen — die Pflicht steht in der Datenbank, nicht nur im Test'
+);
+
+select lives_ok(
+ $$insert into public.idempotency_records
+ (org_id, operation, idempotency_key, subject_id, request_fingerprint)
+ values ('00000000-0000-4000-8000-0000000000a1', 'planning.create_assignment',
+ 'k-bestand', '00000000-0000-4000-8000-0000007010a1', 'fp2')$$,
+ 'K2 ein BESTANDSVORGANG bleibt ohne result_payload schreibbar — die Pflicht ist operationsgebunden, nicht pauschal'
+);
+
+select throws_ok(
+ $$insert into public.idempotency_records
+ (org_id, operation, idempotency_key, subject_id, request_fingerprint, result_payload)
+ values ('00000000-0000-4000-8000-0000000000a1', 'planning.update_worksite_day_team',
+ 'k-kein-objekt', '00000000-0000-4000-8000-0000009010a1', 'fp3', '[]'::jsonb)$$,
+ '23514',
+ NULL,
+ 'K3 ein result_payload muss ein JSON-OBJEKT sein — ein Array wird abgewiesen'
+);
+
+select lives_ok(
+ $$insert into public.idempotency_records
+ (org_id, operation, idempotency_key, subject_id, request_fingerprint, result_payload)
+ values ('00000000-0000-4000-8000-0000000000a1', 'planning.update_worksite_day_team',
+ 'k-mit-payload', '00000000-0000-4000-8000-0000009010a1', 'fp4',
+ '{"worksiteDayId":"00000000-0000-4000-8000-0000008010a1"}'::jsonb)$$,
+ 'K4 mit einem Objekt-Payload gelingt der Insert des neuen Vorgangs'
+);
+
+select * from finish();
+rollback;