Enter the personnel number, tell the two kinds of company car apart, record who to call

Three requests from use, one of which changes the schema's mind about
something.

The personnel number is no longer issued. It was GENERATED ALWAYS AS
IDENTITY, which refuses a supplied value outright — but it has to match Loga
and Interflex, and a number this application invents is unknown there, so the
same person ends up with two. Identity dropped, entered everywhere instead:
in the wizard, in the import, and validated against a duplicate with a
message that names the number.

Worth stating plainly: the column had no unique constraint. The identity
prevented collisions as a side effect, and once the value comes from outside
that side effect is gone. The constraint is the point now, and it was
missing.

Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by
a CHECK so "E-KFZ" cannot appear against someone without a car. The list
filters on it — with, without, only electric, only combustion — which is the
question the report was really about; it was answerable before only through
an export and manual work.

Emergency contact is name, phone and relationship. Relationship stays free
text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not
a list that closes without telling someone their arrangement does not count.
Name and phone are all-or-nothing, in the database and in both forms: a name
without a number helps nobody, a number without a name does not say who
answers.

Two mistakes of mine on the way, both caught by checks I had written into
the migrations rather than by me:

  - The first CHECK on the car type would have permitted exactly the case it
    was written against. `art in (…)` yields NULL rather than false when the
    column is null, and a CHECK counts NULL as satisfied. It needs an
    explicit `is not null` in front.
  - The constraint was added before the backfill, so it rejected every
    existing row with a car.

Existing cars are recorded as Verbrenner, which is an assumption — but a
visible one: "Elektro" appears nowhere nobody confirmed it.

hire_employee and change_employee_data both had to learn the new columns.
They name their columns one by one, and what is missing there is dropped in
silence — the interface would have collected the fields and thrown them
away, which is what happened to the email address this morning.

Verified against the live database, all rolled back: a hire without a number
is refused, a duplicate is refused naming it, a freely chosen one goes
through; E-KFZ plus contact arrive intact; a contact without a phone is
refused. A change records both, with before and after in the audit detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:27:00 +02:00
parent 53d5d41784
commit 9d754359e0
18 changed files with 637 additions and 28 deletions

View File

@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db"; import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> { async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload); const result = await runMutation(await currentUserId(), fn, payload);
@@ -14,6 +14,14 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
} }
export async function hireEmployee(payload: { export async function hireEmployee(payload: {
/**
* Wird eingegeben, nicht vergeben.
*
* Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene
* Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei
* Nummern. Die Datenbank weist eine bereits vergebene Nummer ab.
*/
personnel_number: number;
first_name: string; first_name: string;
last_name: string; last_name: string;
title_prefix?: string[]; title_prefix?: string[];
@@ -47,6 +55,10 @@ export async function hireEmployee(payload: {
work_days?: Weekday[]; work_days?: Weekday[];
is_betriebsrat?: boolean; is_betriebsrat?: boolean;
has_dienstwagen?: boolean; has_dienstwagen?: boolean;
dienstwagen_art?: DienstwagenArt | null;
emergency_contact_name?: string;
emergency_contact_phone?: string;
emergency_contact_relation?: string;
is_laterale_fuehrung?: boolean; is_laterale_fuehrung?: boolean;
is_c_level?: boolean; is_c_level?: boolean;
}): Promise<ActionResult & { employeeId?: string }> { }): Promise<ActionResult & { employeeId?: string }> {

View File

@@ -15,7 +15,15 @@ import type { EmploymentStatus } from "@/lib/supabase/types";
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string }; type SearchParams = {
q?: string;
division?: string;
status?: string;
location?: string;
/** "ja" | "nein" | "elektro" | "verbrenner" — alles andere wird übergangen. */
dienstwagen?: string;
page?: string;
};
type EmployeesPageProps = { type EmployeesPageProps = {
searchParams: Promise<SearchParams>; searchParams: Promise<SearchParams>;
@@ -27,6 +35,7 @@ function pageHref(params: SearchParams, page: number): string {
if (params.division) sp.set("division", params.division); if (params.division) sp.set("division", params.division);
if (params.status) sp.set("status", params.status); if (params.status) sp.set("status", params.status);
if (params.location) sp.set("location", params.location); if (params.location) sp.set("location", params.location);
if (params.dienstwagen) sp.set("dienstwagen", params.dienstwagen);
sp.set("page", String(page)); sp.set("page", String(page));
return `/employees?${sp.toString()}`; return `/employees?${sp.toString()}`;
} }
@@ -126,6 +135,13 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
} }
if (params.location) q = q.where("location_id", "=", params.location); if (params.location) q = q.where("location_id", "=", params.location);
// Auf die vier bekannten Werte eingeschränkt: der Parameter kommt aus
// der Adresszeile und ist damit fremdbestimmt.
if (params.dienstwagen === "ja") q = q.where("has_dienstwagen", "=", true);
else if (params.dienstwagen === "nein") q = q.where("has_dienstwagen", "=", false);
else if (params.dienstwagen === "elektro") q = q.where("dienstwagen_art", "=", "Elektro");
else if (params.dienstwagen === "verbrenner") q = q.where("dienstwagen_art", "=", "Verbrenner");
return q; return q;
}; };

View File

@@ -142,6 +142,10 @@ function employeeExportColumns(
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement }, { header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat }, { header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen }, { header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung }, { header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level }, { header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade }, { header: "Paygrade", get: (e) => e.paygrade },

View File

@@ -104,6 +104,22 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
</option> </option>
))} ))}
</select> </select>
{/* „Wer hat einen Dienstwagen" ist eine wiederkehrende Frage — als
Filter beantwortet sie sich mit einem Klick, statt über einen
Export mit Nachbearbeitung. E-KFZ getrennt, weil das die Frage
hinter der Frage ist. */}
<select
aria-label="Nach Dienstwagen filtern"
defaultValue={searchParams.get("dienstwagen") ?? ""}
onChange={(e) => updateParam("dienstwagen", e.target.value)}
className={FILTER_SELECT_CLASS}
>
<option value="">Dienstwagen: alle</option>
<option value="ja">Mit Dienstwagen</option>
<option value="elektro">Nur E-KFZ</option>
<option value="verbrenner">Nur Verbrenner</option>
<option value="nein">Ohne Dienstwagen</option>
</select>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { SelectField } from "@/components/ui/Field"; import { SelectField } from "@/components/ui/Field";
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types"; import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -11,6 +11,15 @@ export type RoleEmploymentValue = {
workDays: Weekday[]; workDays: Weekday[];
isBetriebsrat: boolean; isBetriebsrat: boolean;
hasDienstwagen: boolean; hasDienstwagen: boolean;
/**
* Nur bedeutsam, solange hasDienstwagen gesetzt ist.
*
* Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden —
* wer versehentlich klickt und zurückklickt, findet seine Angabe wieder.
* Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK
* verlangt.
*/
dienstwagenArt: DienstwagenArt;
isLateraleFuehrung: boolean; isLateraleFuehrung: boolean;
isCLevel: boolean; isCLevel: boolean;
}; };
@@ -78,6 +87,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} /> <input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
Dienstwagen Dienstwagen
</label> </label>
{/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug
ist keine Angabe, sondern eine Frage ohne Gegenstand — und die
Datenbank weist sie ab. */}
{value.hasDienstwagen && (
<SelectField
label="Antriebsart"
dense
value={value.dienstwagenArt}
onChange={(v) => onChange({ dienstwagenArt: v as DienstwagenArt })}
options={[
{ value: "Verbrenner", label: "Verbrenner" },
{ value: "Elektro", label: "Elektro (E-KFZ)" },
]}
/>
)}
<label className="flex items-center gap-2 text-sm text-ink-body"> <label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} /> <input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
Laterale Führung Laterale Führung

View File

@@ -58,6 +58,9 @@ export function DatenAendernPanel({
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich"); const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email); const [email, setEmail] = useState(employee.email);
const [phone, setPhone] = useState(employee.phone ?? ""); const [phone, setPhone] = useState(employee.phone ?? "");
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type); const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours)); const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
@@ -70,6 +73,7 @@ export function DatenAendernPanel({
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"], workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false, isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false, hasDienstwagen: employee.has_dienstwagen ?? false,
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false, isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false, isCLevel: employee.is_c_level ?? false,
}); });
@@ -99,6 +103,12 @@ export function DatenAendernPanel({
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error"); showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return; return;
} }
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
// andere ab, und die Meldung dort erklärt es nicht.
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
return;
}
setPending(true); setPending(true);
const result = await changeEmployeeData({ const result = await changeEmployeeData({
employee_id: employee.id, employee_id: employee.id,
@@ -118,6 +128,9 @@ export function DatenAendernPanel({
address_country: addressCountry, address_country: addressCountry,
email, email,
phone, phone,
emergency_contact_name: notfallName.trim(),
emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(),
}, },
contract: { contract: {
employment_type: employmentType, employment_type: employmentType,
@@ -131,6 +144,7 @@ export function DatenAendernPanel({
work_days: role.workDays, work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat, is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen, has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
is_laterale_fuehrung: role.isLateraleFuehrung, is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel, is_c_level: role.isCLevel,
}, },
@@ -208,6 +222,23 @@ export function DatenAendernPanel({
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} /> <TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} /> <TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div> </div>
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
er nicht zwischen den Adressfeldern der Person untergehen. */}
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
<TextField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
hint="Zum Beispiel Gattin, Bruder, Freundin."
/>
</div>
</fieldset>
</div> </div>
<div> <div>

View File

@@ -27,6 +27,21 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
["Adresse", formatAddress(employee)], ["Adresse", formatAddress(employee)],
["Land", employee.address_country ?? "–"], ["Land", employee.address_country ?? "–"],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"], ["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
// Der Notfallkontakt steht als eigene Zeile und nicht in einer Zeile mit
// dem Telefon der Person: im Ernstfall greift jemand danach, und dann
// darf keine Verwechslung möglich sein.
[
"Notfallkontakt",
employee.emergency_contact_name
? [
employee.emergency_contact_name,
employee.emergency_contact_relation ? `(${employee.emergency_contact_relation})` : "",
employee.emergency_contact_phone,
]
.filter(Boolean)
.join(" ")
: "–",
],
]; ];
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">

View File

@@ -61,8 +61,18 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne // E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten // die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
// Schritts, nach allen Eingaben. // Schritts, nach allen Eingaben.
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) && Boolean(
svNummerOk, draft.personnelNumber.trim() &&
draft.firstName &&
draft.lastName &&
draft.birthDate &&
draft.locationId &&
draft.email.trim()
) &&
svNummerOk &&
// Name und Nummer des Notfallkontakts gehören zusammen — die Datenbank
// weist eines ohne das andere ab (chk_emergency_contact).
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
Boolean(draft.positionId && draft.besetzung), Boolean(draft.positionId && draft.besetzung),
Boolean(draft.entryDate && draft.workDays.length > 0), Boolean(draft.entryDate && draft.workDays.length > 0),
true, true,
@@ -83,6 +93,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
if (!selectedPosition || !draft.besetzung) return; if (!selectedPosition || !draft.besetzung) return;
setSubmitting(true); setSubmitting(true);
const result = await hireEmployee({ const result = await hireEmployee({
personnel_number: Number(draft.personnelNumber),
first_name: draft.firstName, first_name: draft.firstName,
last_name: draft.lastName, last_name: draft.lastName,
title_prefix: draft.titlePrefix, title_prefix: draft.titlePrefix,
@@ -106,6 +117,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
work_days: draft.workDays, work_days: draft.workDays,
is_betriebsrat: draft.isBetriebsrat, is_betriebsrat: draft.isBetriebsrat,
has_dienstwagen: draft.hasDienstwagen, has_dienstwagen: draft.hasDienstwagen,
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
// sonst nicht zu.
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
is_laterale_fuehrung: draft.isLateraleFuehrung, is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel, is_c_level: draft.isCLevel,
}); });

View File

@@ -12,6 +12,14 @@ type StepPersonProps = {
export function StepPerson({ draft, update, locations }: StepPersonProps) { export function StepPerson({ draft, update, locations }: StepPersonProps) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<TextField
label="Personalnummer"
required
inputMode="numeric"
value={draft.personnelNumber}
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} /> <TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} /> <TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
@@ -40,6 +48,34 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} /> <TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} /> <TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
</div> </div>
{/* Daten einer dritten Person — nur für den Notfall erhoben. Name und
Nummer gehören zusammen: eines allein nützt im Ernstfall nichts,
und die Datenbank weist es ab. */}
<fieldset className="rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField
label="Name"
value={draft.emergencyContactName}
onChange={(emergencyContactName) => update({ emergencyContactName })}
/>
<TextField
label="Telefon"
type="tel"
value={draft.emergencyContactPhone}
onChange={(emergencyContactPhone) => update({ emergencyContactPhone })}
/>
</div>
<TextField
label="Verhältnis"
value={draft.emergencyContactRelation}
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
hint="Zum Beispiel Gattin, Bruder, Freundin. Freitext — die Aufzählung liesse sich nicht schliessen."
/>
</div>
</fieldset>
<SelectField <SelectField
label="Standort" label="Standort"
required required

View File

@@ -21,17 +21,23 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
const location = locations.find((l) => l.id === draft.locationId); const location = locations.find((l) => l.id === draft.locationId);
const flags = [ const flags = [
draft.isBetriebsrat && "Betriebsrat", draft.isBetriebsrat && "Betriebsrat",
draft.hasDienstwagen && "Dienstwagen", draft.hasDienstwagen && `Dienstwagen (${draft.dienstwagenArt === "Elektro" ? "E-KFZ" : "Verbrenner"})`,
draft.isLateraleFuehrung && "Laterale Führung", draft.isLateraleFuehrung && "Laterale Führung",
draft.isCLevel && "C-Level", draft.isCLevel && "C-Level",
].filter(Boolean); ].filter(Boolean);
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
.filter(Boolean)
.join(" ");
const rows: [string, string][] = [ const rows: [string, string][] = [
["Personalnummer", draft.personnelNumber || "–"],
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)], ["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"], ["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", fmtDate(draft.birthDate)], ["Geburtsdatum", fmtDate(draft.birthDate)],
["SV-Nummer", draft.svNummer || "–"], ["SV-Nummer", draft.svNummer || "–"],
["E-Mail (privat)", draft.email || "–"], ["E-Mail (privat)", draft.email || "–"],
["Telefon", draft.phone || "–"], ["Telefon", draft.phone || "–"],
["Notfallkontakt", notfall || "–"],
["Standort", location ? `${location.name} (${location.country})` : "–"], ["Standort", location ? `${location.name} (${location.country})` : "–"],
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"], ["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"], ["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],

View File

@@ -1,9 +1,16 @@
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types"; import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even // The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them // though both are NOT NULL on employees — added here (defaults keep them
// effectively "free" for the user, same treatment as the karenz-start gap). // effectively "free" for the user, same treatment as the karenz-start gap).
export type HireDraftData = { export type HireDraftData = {
/**
* Eingabe, nicht Vergabe.
*
* Muss mit Loga und Interflex übereinstimmen — als Zeichenkette geführt,
* weil ein leeres Zahlenfeld sonst als 0 im Entwurf landet.
*/
personnelNumber: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
titlePrefix: string[]; titlePrefix: string[];
@@ -27,11 +34,17 @@ export type HireDraftData = {
workDays: Weekday[]; workDays: Weekday[];
isBetriebsrat: boolean; isBetriebsrat: boolean;
hasDienstwagen: boolean; hasDienstwagen: boolean;
/** Nur ausgewertet, wenn hasDienstwagen gesetzt ist — so will es der CHECK. */
dienstwagenArt: DienstwagenArt;
emergencyContactName: string;
emergencyContactPhone: string;
emergencyContactRelation: string;
isLateraleFuehrung: boolean; isLateraleFuehrung: boolean;
isCLevel: boolean; isCLevel: boolean;
}; };
export const EMPTY_HIRE_DRAFT: HireDraftData = { export const EMPTY_HIRE_DRAFT: HireDraftData = {
personnelNumber: "",
firstName: "", firstName: "",
lastName: "", lastName: "",
titlePrefix: [], titlePrefix: [],
@@ -55,6 +68,10 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
workDays: ["Mo", "Di", "Mi", "Do", "Fr"], workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: false, isBetriebsrat: false,
hasDienstwagen: false, hasDienstwagen: false,
dienstwagenArt: "Verbrenner",
emergencyContactName: "",
emergencyContactPhone: "",
emergencyContactRelation: "",
isLateraleFuehrung: false, isLateraleFuehrung: false,
isCLevel: false, isCLevel: false,
}; };

View File

@@ -226,6 +226,13 @@ export async function laden(
source: (txt(w.source) ?? "Extern") as never, source: (txt(w.source) ?? "Extern") as never,
is_betriebsrat: bool(w.is_betriebsrat, false), is_betriebsrat: bool(w.is_betriebsrat, false),
has_dienstwagen: bool(w.has_dienstwagen, false), has_dienstwagen: bool(w.has_dienstwagen, false),
// Ohne Dienstwagen zwingend null, mit Dienstwagen zwingend gesetzt —
// so verlangt es chk_dienstwagen_art. Fehlt die Angabe in der Datei,
// gilt Verbrenner, wie im übernommenen Bestand.
dienstwagen_art: bool(w.has_dienstwagen, false) ? (txt(w.dienstwagen_art) ?? "Verbrenner") : null,
emergency_contact_name: txt(w.emergency_contact_name),
emergency_contact_phone: txt(w.emergency_contact_phone),
emergency_contact_relation: txt(w.emergency_contact_relation),
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false), is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
is_c_level: bool(w.is_c_level, false), is_c_level: bool(w.is_c_level, false),
entry_date: eintritt, entry_date: eintritt,
@@ -244,18 +251,15 @@ export async function laden(
), ),
}; };
// Von Hand geschrieben statt über den Abfragebauer, wegen genau eines // Weiterhin von Hand geschrieben, aber ohne OVERRIDING SYSTEM VALUE:
// Wortes: OVERRIDING SYSTEM VALUE. // personnel_number ist seit 20260811100000 keine Identitätsspalte mehr,
// // sondern eine gewöhnliche Pflichtangabe — sie muss mit Loga und
// personnel_number ist GENERATED ALWAYS AS IDENTITY — die Datenbank // Interflex übereinstimmen und wird deshalb überall eingegeben, nicht
// vergibt sie und weist einen eigenen Wert sonst ab. Für eine Übernahme // vergeben. Für eine Identitätsspalte war das Schlüsselwort nötig; für
// aus einem Altsystem ist das die falsche Richtung: die Nummer steht auf // eine gewöhnliche wäre es ein Fehler.
// Lohnzetteln, in Akten und auf Ausweisen. Ein Import, der sie neu
// würfelt, ist keine Übernahme.
const spalten = Object.keys(werte); const spalten = Object.keys(werte);
const r = await sql<{ id: string; personnel_number: number }>` const r = await sql<{ id: string; personnel_number: number }>`
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))}) insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
overriding system value
values (${sql.join(Object.values(werte).map((v) => sql.val(v)))}) values (${sql.join(Object.values(werte).map((v) => sql.val(v)))})
returning id, personnel_number returning id, personnel_number
`.execute(tx); `.execute(tx);
@@ -276,18 +280,9 @@ export async function laden(
} }
bericht.Personen = personenZeilen.length; bericht.Personen = personenZeilen.length;
// Den Zähler nachziehen. Ohne das vergibt die Datenbank für die nächste // Kein Fortschreiben eines Zählers mehr: es gibt keinen. Die Nummer wird
// Neueinstellung eine Nummer, die der Import bereits verbraucht hat — und // bei jeder Einstellung eingegeben, und die Eindeutigkeit sichert der
// der eindeutige Index weist sie ab. Der Fehler träte erst Wochen später // Index — beim Import wie im Assistenten.
// auf, beim ersten Eintritt nach der Übernahme.
if (personenZeilen.length > 0) {
await sql`
select setval(
pg_get_serial_sequence('employees', 'personnel_number'),
(select max(personnel_number) from employees)
)
`.execute(tx);
}
await einfuegen(tx, "position_assignments", besetzungen); await einfuegen(tx, "position_assignments", besetzungen);

View File

@@ -350,6 +350,38 @@ export const BLATT_PERSONEN: BlattSchema = {
}, },
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" }, { name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" }, { name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{
name: "Antriebsart",
ziel: "dienstwagen_art",
pflicht: false,
typ: { art: "auswahl", werte: ["Verbrenner", "Elektro"] },
hinweis: "Pflicht, wenn ein Dienstwagen eingetragen ist; sonst leer lassen.",
beispiel: "",
},
{
name: "Notfallkontakt",
ziel: "emergency_contact_name",
pflicht: false,
typ: { art: "text" },
hinweis: "Name. Nur zusammen mit der Telefonnummer.",
beispiel: "",
},
{
name: "Notfallkontakt Telefon",
ziel: "emergency_contact_phone",
pflicht: false,
typ: { art: "text" },
hinweis: "Nur zusammen mit dem Namen.",
beispiel: "",
},
{
name: "Notfallkontakt Verhältnis",
ziel: "emergency_contact_relation",
pflicht: false,
typ: { art: "text" },
hinweis: "Zum Beispiel Gattin, Bruder, Freundin.",
beispiel: "",
},
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" }, { name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" }, { name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ {

View File

@@ -13,6 +13,15 @@ export type WorkerType = "Angestellte:r" | "Arbeiter:in";
export type CollectiveAgreement = "Handel" | "Süßwaren"; export type CollectiveAgreement = "Handel" | "Süßwaren";
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So"; export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
/**
* Antriebsart des Dienstwagens.
*
* Bewusst keine dritte Möglichkeit „unbekannt": die Angabe hängt per CHECK
* an has_dienstwagen, und wer einen Dienstwagen hat, weiss auch, ob er lädt
* oder tankt.
*/
export type DienstwagenArt = "Verbrenner" | "Elektro";
/** /**
* Eine einzelne Feldänderung im Protokoll. * Eine einzelne Feldänderung im Protokoll.
* *
@@ -135,6 +144,12 @@ export type Database = {
work_days: Weekday[]; work_days: Weekday[];
is_betriebsrat: boolean; is_betriebsrat: boolean;
has_dienstwagen: boolean; has_dienstwagen: boolean;
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
dienstwagen_art: DienstwagenArt | null;
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
emergency_contact_name: string | null;
emergency_contact_phone: string | null;
emergency_contact_relation: string | null;
is_laterale_fuehrung: boolean; is_laterale_fuehrung: boolean;
is_c_level: boolean; is_c_level: boolean;
title_prefix: string[]; title_prefix: string[];
@@ -177,6 +192,10 @@ export type Database = {
work_days?: Weekday[]; work_days?: Weekday[];
is_betriebsrat?: boolean; is_betriebsrat?: boolean;
has_dienstwagen?: boolean; has_dienstwagen?: boolean;
dienstwagen_art?: DienstwagenArt | null;
emergency_contact_name?: string | null;
emergency_contact_phone?: string | null;
emergency_contact_relation?: string | null;
is_laterale_fuehrung?: boolean; is_laterale_fuehrung?: boolean;
is_c_level?: boolean; is_c_level?: boolean;
title_prefix?: string[]; title_prefix?: string[];

View File

@@ -0,0 +1,120 @@
-- Die Personalnummer wird eingegeben, nicht vergeben.
--
-- Sie muss mit Loga und Interflex übereinstimmen. Eine von dieser Anwendung
-- selbst gezogene Nummer ist dort unbekannt, und die Person hätte in drei
-- Systemen zwei Nummern.
--
-- Zwei Dinge ändern sich dadurch:
--
-- 1. Die Identität fällt weg. `GENERATED ALWAYS` weist eigene Werte
-- ausdrücklich ab — deshalb brauchte der Import bisher OVERRIDING SYSTEM
-- VALUE.
-- 2. Die Eindeutigkeit muss ausdrücklich her. Bisher gab es **keine**: die
-- Identität verhinderte Doppelte nur als Nebenwirkung. Sobald der Wert von
-- aussen kommt, ist das die eigentliche Zusicherung — und sie fehlte.
-- Gegenprobe vor dem Umbau: was jetzt doppelt ist, liesse sich danach nicht
-- mehr eindeutig machen.
do $$
declare v_doppelt int;
begin
select count(*) into v_doppelt from (
select personnel_number from employees group by 1 having count(*) > 1
) x;
if v_doppelt > 0 then
raise exception '% Personalnummern kommen mehrfach vor — vor der Umstellung bereinigen.', v_doppelt;
end if;
end;
$$;
alter table employees alter column personnel_number drop identity if exists;
alter table employees add constraint employees_personnel_number_key unique (personnel_number);
comment on column employees.personnel_number is
'Wird eingegeben und muss mit Loga/Interflex übereinstimmen. Nicht automatisch vergeben.';
-- ═══ hire_employee nimmt die Nummer entgegen ═════════════════════
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
-- Pflichtprüfung direkt nach der Planstellenprüfung einhängen.
v_neu := regexp_replace(
v_def,
'(if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Es muss eine Planstelle angegeben werden\.'';\s+end\s+if;)',
$neu$\1
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;$neu$,
'g'
);
if v_neu = v_def then
raise exception 'Die Pflichtprüfung liess sich nicht einhängen — hire_employee blieb unverändert.';
end if;
v_def := v_neu;
-- Und in die Einfügung aufnehmen. Die Spaltenliste beginnt mit
-- first_name; davor kommt personnel_number, in beiden Listen an gleicher
-- Stelle.
v_neu := regexp_replace(v_def, 'insert\s+into\s+employees\s*\(\s*first_name,', 'insert into employees (' || chr(10) || ' personnel_number, first_name,', 'g');
if v_neu = v_def then
raise exception 'Die Spaltenliste liess sich nicht ergänzen.';
end if;
v_def := v_neu;
v_neu := regexp_replace(v_def, 'values\s*\(\s*payload->>''first_name'',', 'values (' || chr(10) || ' (payload->>''personnel_number'')::int, payload->>''first_name'',', 'g');
if v_neu = v_def then
raise exception 'Die Werteliste liess sich nicht ergänzen.';
end if;
-- OVERRIDING SYSTEM VALUE gibt es nur für Identitätsspalten; nach dem
-- Wegfall wäre es ein Fehler. Der Import setzt es selbst nicht mehr, hier
-- steht es vorsorglich, falls eine ältere Fassung es doch trägt.
v_neu := regexp_replace(v_neu, '\s+overriding\s+system\s+value', '', 'gi');
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare
v_ist_identitaet text;
v_def text;
begin
select is_identity into v_ist_identitaet
from information_schema.columns
where table_name = 'employees' and column_name = 'personnel_number';
if v_ist_identitaet <> 'NO' then
raise exception 'personnel_number ist weiterhin eine Identitätsspalte.';
end if;
if not exists (
select 1 from pg_constraint
where conrelid = 'employees'::regclass and contype = 'u'
and pg_get_constraintdef(oid) = 'UNIQUE (personnel_number)'
) then
raise exception 'Die Eindeutigkeit auf personnel_number fehlt.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
if v_def not like '%bereits vergeben%' then
raise exception 'hire_employee() prüft die Personalnummer nicht.';
end if;
if v_def ilike '%overriding system value%' then
raise exception 'hire_employee() enthält weiterhin OVERRIDING SYSTEM VALUE.';
end if;
end;
$$;

View File

@@ -0,0 +1,96 @@
-- Notfallkontakt und Antriebsart des Dienstwagens.
-- ═══ Notfallkontakt ══════════════════════════════════════════════
-- Als Spalten und nicht als eigene Tabelle: gefragt war *der* Notfallkontakt,
-- einer je Person. Eine Tabelle wäre die richtige Antwort auf „mehrere", und
-- die Frage stellt sich hier nicht.
--
-- Das Verhältnis bleibt Freitext. Die Beispiele — Gattin/Gatte,
-- Schwester/Bruder, Freund — sind keine Aufzählung, die sich schliessen
-- lässt, ohne jemandem die eigene Lebensform abzusprechen.
alter table employees add column if not exists emergency_contact_name text;
alter table employees add column if not exists emergency_contact_phone text;
alter table employees add column if not exists emergency_contact_relation text;
comment on column employees.emergency_contact_name is
'Notfallkontakt: Name. Daten einer dritten Person — nur für den Notfall erhoben.';
-- Alles oder nichts: ein Name ohne Nummer nützt im Notfall nichts, eine
-- Nummer ohne Namen sagt nicht, wen man da anruft.
alter table employees drop constraint if exists chk_emergency_contact;
alter table employees add constraint chk_emergency_contact check (
(emergency_contact_name is null and emergency_contact_phone is null)
or (btrim(coalesce(emergency_contact_name, '')) <> '' and btrim(coalesce(emergency_contact_phone, '')) <> '')
);
-- ═══ Antriebsart des Dienstwagens ════════════════════════════════
-- Eine zweite Spalte statt eines Umbaus von has_dienstwagen: die bestehende
-- Angabe bleibt gültig, und alle Auswertungen darauf ebenfalls.
alter table employees add column if not exists dienstwagen_art text;
comment on column employees.dienstwagen_art is
'Antriebsart des Dienstwagens: Verbrenner oder Elektro. Null, wenn keiner vorhanden.';
-- Der CHECK bindet die beiden Angaben aneinander. Ohne ihn stünde irgendwann
-- „E-KFZ" bei jemandem ohne Dienstwagen, und niemand wüsste, welche der
-- beiden Angaben stimmt.
-- Zuerst den Bestand füllen, dann prüfen — andersherum weist die Bedingung
-- jede vorhandene Zeile mit Dienstwagen ab. Bestehende gelten als
-- Verbrenner, bis jemand es besser weiss; das ist eine Annahme, aber eine
-- sichtbare: „Elektro" steht nirgends, wo es niemand bestätigt hat.
update employees set dienstwagen_art = 'Verbrenner'
where has_dienstwagen and dienstwagen_art is null;
-- `is not null and` steht bewusst davor: `dienstwagen_art in (…)` ergibt bei
-- NULL nicht `false`, sondern NULL — und ein CHECK gilt als erfüllt, wenn er
-- NULL liefert. Ohne die ausdrückliche Prüfung hätte diese Bedingung genau
-- den Fall durchgelassen, gegen den sie geschrieben ist. Aufgefallen ist das
-- der Gegenprobe am Ende dieser Datei, nicht mir.
alter table employees drop constraint if exists chk_dienstwagen_art;
alter table employees add constraint chk_dienstwagen_art check (
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner', 'Elektro'))
or (not has_dienstwagen and dienstwagen_art is null)
);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
begin
create temp table probe_emp (
has_dienstwagen boolean not null default false,
dienstwagen_art text,
emergency_contact_name text,
emergency_contact_phone text,
constraint p_art check (
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner','Elektro'))
or (not has_dienstwagen and dienstwagen_art is null)),
constraint p_kontakt check (
(emergency_contact_name is null and emergency_contact_phone is null)
or (btrim(coalesce(emergency_contact_name,'')) <> '' and btrim(coalesce(emergency_contact_phone,'')) <> ''))
) on commit drop;
-- E-KFZ ohne Dienstwagen muss abgewiesen werden.
begin
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (false, 'Elektro');
raise exception 'Antriebsart ohne Dienstwagen wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
-- Dienstwagen ohne Antriebsart ebenso.
begin
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, null);
raise exception 'Dienstwagen ohne Antriebsart wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, 'Elektro');
-- Name ohne Nummer muss abgewiesen werden.
begin
insert into probe_emp (emergency_contact_name) values ('Maria Muster');
raise exception 'Notfallkontakt ohne Nummer wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
insert into probe_emp (emergency_contact_name, emergency_contact_phone) values ('Maria Muster', '+43 660 1234567');
end;
$$;

View File

@@ -0,0 +1,91 @@
-- change_employee_data kennt Notfallkontakt und Antriebsart.
--
-- Ohne diesen Schritt liessen sich die neuen Felder anlegen, aber nie
-- ändern: die Funktion vergleicht und schreibt namentlich aufgezählte
-- Spalten, und was dort fehlt, wird stillschweigend übergangen. Das ist die
-- unangenehme Sorte Lücke — die Oberfläche zeigt ein Eingabefeld, das
-- Speichern meldet Erfolg, und der Wert bleibt stehen.
--
-- Beide Ergänzungen laufen über app_aenderung(), landen also mit Vorher und
-- Nachher im Protokoll wie alles andere auch.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' limit 1;
-- ── Vergleiche: Notfallkontakt hinter Telefon, Antriebsart hinter C-Level
v_neu := regexp_replace(
v_def,
'(if\s+v_person\s+\?\s+''phone''\s+then\s+v_person_changes\s*:=\s*app_aenderung\(v_person_changes,\s*''Telefon'',\s*v_old\.phone,\s*v_person->>''phone''\);\s*end\s+if;)',
$neu$\1
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Vergleichsblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
v_def := v_neu;
v_neu := regexp_replace(
v_def,
'(if\s+v_role\s+\?\s+''is_c_level''\s+then\s+v_contract_changes\s*:=\s*app_aenderung\(v_contract_changes,\s*''C-Level'',\s*v_old\.is_c_level::text,\s*v_role->>''is_c_level''\);\s*end\s+if;)',
$neu$\1
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Vergleich für die Antriebsart liess sich nicht einhängen.'; end if;
v_def := v_neu;
-- ── Schreiben
v_neu := regexp_replace(
v_def,
'(phone\s*=\s*coalesce\(v_person->>''phone'',\s*phone\),)',
$neu$\1
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Schreibblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
v_def := v_neu;
-- Die Antriebsart hängt an has_dienstwagen: wird der Dienstwagen
-- abgemeldet, muss sie mit, sonst weist der CHECK die Zeile ab.
v_neu := regexp_replace(
v_def,
'(is_c_level\s*=\s*coalesce\(\(v_role->>''is_c_level''\)::boolean,\s*is_c_level\))',
$neu$\1,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Schreibblock für die Antriebsart liess sich nicht einhängen.'; end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' limit 1;
if v_def not like '%Notfallkontakt Verhältnis%' then
raise exception 'change_employee_data() vergleicht den Notfallkontakt nicht.';
end if;
if v_def not like '%dienstwagen_art = case%' then
raise exception 'change_employee_data() schreibt die Antriebsart nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,62 @@
-- hire_employee schreibt Notfallkontakt und Antriebsart mit.
--
-- Die Spalten gibt es seit 20260811110000, aber die Einstellung zählt ihre
-- Spalten namentlich auf — was dort fehlt, wird beim Anlegen verworfen. Der
-- Assistent hätte die Felder erhoben und stillschweigend weggeworfen; genau
-- der Fall, der heute schon einmal mit der E-Mail passiert ist.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
v_neu := regexp_replace(
v_def,
'(is_betriebsrat,\s*has_dienstwagen,\s*is_laterale_fuehrung,\s*is_c_level\s*\))',
'is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,'
|| chr(10) || ' dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation'
|| chr(10) || ' )',
'g'
);
if v_neu = v_def then raise exception 'Die Spaltenliste liess sich nicht ergänzen.'; end if;
v_def := v_neu;
-- Die Werteliste endet mit is_c_level; davor steht der Abschluss der
-- values-Klammer.
v_neu := regexp_replace(
v_def,
'(coalesce\(\(payload->>''is_c_level''\)::boolean,\s*false\))',
E'\\1,\n'
-- Antrieb nur, wenn es einen Dienstwagen gibt — sonst weist der CHECK
-- die Zeile ab. Ohne Angabe gilt Verbrenner, wie im Bestand.
|| ' case when coalesce((payload->>''has_dienstwagen'')::boolean, false)'
|| ' then coalesce(nullif(payload->>''dienstwagen_art'', ''''), ''Verbrenner'') else null end,' || chr(10)
|| ' nullif(payload->>''emergency_contact_name'', ''''),' || chr(10)
|| ' nullif(payload->>''emergency_contact_phone'', ''''),' || chr(10)
|| ' nullif(payload->>''emergency_contact_relation'', '''')',
'g'
);
if v_neu = v_def then raise exception 'Die Werteliste liess sich nicht ergänzen.'; end if;
execute v_neu;
end;
$$;
do $$
declare v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
if v_def not like '%emergency_contact_relation%' then
raise exception 'hire_employee() schreibt den Notfallkontakt nicht.';
end if;
if v_def not like '%dienstwagen_art%' then
raise exception 'hire_employee() schreibt die Antriebsart nicht.';
end if;
end;
$$;