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>
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import { ABSENCE_TYPES } from "@/lib/absence";
|
|
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
|
|
|
|
// Was in einer Importdatei stehen darf.
|
|
//
|
|
// Diese Datei ist die einzige Quelle für drei Dinge, die sonst
|
|
// auseinanderlaufen: die Prüfung beim Import, die Vorlage zum Herunterladen
|
|
// und die Hilfetexte in der Oberfläche. Eine Spalte, die hier nicht steht,
|
|
// gibt es nirgends — und eine, die hier dazukommt, taucht überall auf.
|
|
//
|
|
// Die Blattnamen sind zugleich die Dateinamen für den CSV-Weg: „Personen.csv"
|
|
// wird als Blatt „Personen" gelesen.
|
|
|
|
export type FeldArt =
|
|
| { art: "text" }
|
|
| { art: "datum" }
|
|
| { art: "zahl" }
|
|
| { art: "ganzzahl" }
|
|
| { art: "janein" }
|
|
| { art: "liste"; werte?: readonly string[] }
|
|
| { art: "auswahl"; werte: readonly string[] };
|
|
|
|
export type Spalte = {
|
|
/** Überschrift in der Datei. */
|
|
name: string;
|
|
/** Feld im Datensatz, den der Lader schreibt. */
|
|
ziel: string;
|
|
pflicht: boolean;
|
|
typ: FeldArt;
|
|
hinweis: string;
|
|
beispiel: string;
|
|
};
|
|
|
|
export type BlattSchema = {
|
|
name: string;
|
|
zweck: string;
|
|
/**
|
|
* Spalte, an der eine Zeile erkennbar ist. Doppelte Werte darin sind ein
|
|
* Fehler — sonst überschriebe die zweite Zeile die erste, und welche
|
|
* gewinnt, hinge an der Reihenfolge in der Datei.
|
|
*/
|
|
schluessel: string | null;
|
|
spalten: Spalte[];
|
|
};
|
|
|
|
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
|
|
const VERHAELTNIS = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"] as const;
|
|
const EREIGNIS = [
|
|
"Eintritt",
|
|
"Beförderung",
|
|
"Versetzung",
|
|
"Karenz",
|
|
"Vertragsänderung",
|
|
"Stammdatenänderung",
|
|
"Austritt",
|
|
"Wiedereintritt",
|
|
"Reorganisation",
|
|
"Gehaltsanpassung",
|
|
"Rückkehr",
|
|
] as const;
|
|
|
|
export const BLATT_STANDORTE: BlattSchema = {
|
|
name: "Standorte",
|
|
zweck: "Betriebsstätten, auf die sich Personen beziehen.",
|
|
schluessel: "Bezeichnung",
|
|
spalten: [
|
|
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "Wien" },
|
|
{
|
|
name: "Land",
|
|
ziel: "country",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Ausgeschrieben. Steuert unter anderem, ob eine SV-Nummer verlangt wird.",
|
|
beispiel: "Österreich",
|
|
},
|
|
],
|
|
};
|
|
|
|
export const BLATT_ORGANISATION: BlattSchema = {
|
|
name: "Organisation",
|
|
zweck: "Der Aufbau: Gesellschaft, Bereiche, Abteilungen, Teams.",
|
|
schluessel: "Orgnummer",
|
|
spalten: [
|
|
{
|
|
name: "Orgnummer",
|
|
ziel: "org_number",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Eindeutig. Wird von Planstellen als Verweis benutzt.",
|
|
beispiel: "10000001",
|
|
},
|
|
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Produktion" },
|
|
{
|
|
name: "Art",
|
|
ziel: "unit_type",
|
|
pflicht: true,
|
|
typ: { art: "auswahl", werte: ["Gesellschaft", "Bereich", "Abteilung", "Team"] },
|
|
hinweis: "Genau eine Zeile darf „Gesellschaft“ sein — sie ist die Wurzel.",
|
|
beispiel: "Bereich",
|
|
},
|
|
{
|
|
name: "Übergeordnet",
|
|
ziel: "parent_org_number",
|
|
pflicht: false,
|
|
typ: { art: "text" },
|
|
hinweis: "Orgnummer der übergeordneten Einheit. Nur bei „Gesellschaft“ leer.",
|
|
beispiel: "10000000",
|
|
},
|
|
{
|
|
name: "Gültig ab",
|
|
ziel: "valid_from",
|
|
pflicht: false,
|
|
typ: { art: "datum" },
|
|
hinweis: "Leer = heute.",
|
|
beispiel: "01.01.2020",
|
|
},
|
|
{
|
|
name: "Gültig bis",
|
|
ziel: "valid_to",
|
|
pflicht: false,
|
|
typ: { art: "datum" },
|
|
hinweis: "Leer = unbefristet.",
|
|
beispiel: "",
|
|
},
|
|
],
|
|
};
|
|
|
|
export const BLATT_JOBKATALOG: BlattSchema = {
|
|
name: "Jobkatalog",
|
|
zweck: "Die Tätigkeiten, auf die Planstellen verweisen.",
|
|
schluessel: "Jobcode",
|
|
spalten: [
|
|
{ name: "Jobcode", ziel: "code", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "50000123" },
|
|
{
|
|
name: "Bezeichnung",
|
|
ziel: "title",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "",
|
|
beispiel: "Maschinenführer:in",
|
|
},
|
|
],
|
|
};
|
|
|
|
export const BLATT_PLANSTELLEN: BlattSchema = {
|
|
name: "Planstellen",
|
|
zweck: "Die Stellen selbst — unabhängig davon, wer sie besetzt.",
|
|
schluessel: "Planstellennummer",
|
|
spalten: [
|
|
{
|
|
name: "Planstellennummer",
|
|
ziel: "position_number",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Eindeutig. Personen verweisen darauf.",
|
|
beispiel: "60000124",
|
|
},
|
|
{
|
|
name: "Orgnummer",
|
|
ziel: "org_number",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Muss im Blatt „Organisation“ oder bereits im System stehen.",
|
|
beispiel: "10000001",
|
|
},
|
|
{
|
|
name: "Jobcode",
|
|
ziel: "job_code",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Muss im Blatt „Jobkatalog“ oder bereits im System stehen.",
|
|
beispiel: "50000123",
|
|
},
|
|
{
|
|
name: "Leitung",
|
|
ziel: "is_chief",
|
|
pflicht: false,
|
|
typ: { art: "janein" },
|
|
hinweis: "Ja = Leitungsstelle der Einheit. Höchstens eine je Einheit.",
|
|
beispiel: "nein",
|
|
},
|
|
{ name: "Gültig ab", ziel: "valid_from", pflicht: false, typ: { art: "datum" }, hinweis: "Leer = heute.", beispiel: "01.01.2020" },
|
|
{ name: "Gültig bis", ziel: "valid_to", pflicht: false, typ: { art: "datum" }, hinweis: "", beispiel: "" },
|
|
],
|
|
};
|
|
|
|
export const BLATT_PERSONEN: BlattSchema = {
|
|
name: "Personen",
|
|
zweck: "Stammdaten, Vertrag und die Planstelle, auf der die Person sitzt.",
|
|
schluessel: "Personalnummer",
|
|
spalten: [
|
|
{
|
|
name: "Personalnummer",
|
|
ziel: "personnel_number",
|
|
pflicht: true,
|
|
typ: { art: "ganzzahl" },
|
|
hinweis: "Eindeutig. Historie und Angehörige verweisen darauf.",
|
|
beispiel: "2219",
|
|
},
|
|
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Sabine" },
|
|
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
|
{
|
|
name: "Geschlecht",
|
|
ziel: "gender",
|
|
pflicht: true,
|
|
typ: { art: "auswahl", werte: ["m", "w"] },
|
|
hinweis: "Wird für die SV-Nummer und die Anrede gebraucht.",
|
|
beispiel: "w",
|
|
},
|
|
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "15.08.1968" },
|
|
{
|
|
name: "SV-Nummer",
|
|
ziel: "sv_nummer",
|
|
pflicht: false,
|
|
typ: { art: "text" },
|
|
hinweis: "Zehnstellig. Prüfziffer und Geburtsdatum müssen zusammenpassen.",
|
|
beispiel: "7960 150868",
|
|
},
|
|
{
|
|
name: "Staatsbürgerschaft",
|
|
ziel: "nationality",
|
|
pflicht: false,
|
|
typ: { art: "text" },
|
|
hinweis: "Leer = Österreich.",
|
|
beispiel: "Österreich",
|
|
},
|
|
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
|
|
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
|
|
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
|
|
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
|
|
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
|
|
{ name: "Land", ziel: "address_country", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Österreich" },
|
|
{
|
|
name: "Titel vorangestellt",
|
|
ziel: "title_prefix",
|
|
pflicht: false,
|
|
// Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat.
|
|
// Ohne die Aufzählung hier bräche der Import erst beim Schreiben ab.
|
|
typ: { art: "liste", werte: TITLE_PREFIXES },
|
|
hinweis: "Mehrere mit Semikolon. In einer CSV die Zelle in Anführungszeichen setzen.",
|
|
beispiel: "Mag.",
|
|
},
|
|
{
|
|
name: "Titel nachgestellt",
|
|
ziel: "title_suffix",
|
|
pflicht: false,
|
|
typ: { art: "liste", werte: TITLE_SUFFIXES },
|
|
hinweis: "Mehrere mit Semikolon.",
|
|
beispiel: "MSc",
|
|
},
|
|
{
|
|
name: "Tätigkeit",
|
|
ziel: "job_title",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Die Bezeichnung der Person; kann von der Planstelle abweichen.",
|
|
beispiel: "Projektingenieur:in",
|
|
},
|
|
{
|
|
name: "Standort",
|
|
ziel: "location",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Muss im Blatt „Standorte“ oder bereits im System stehen.",
|
|
beispiel: "Wien",
|
|
},
|
|
{
|
|
name: "Planstellennummer",
|
|
ziel: "position_number",
|
|
pflicht: true,
|
|
typ: { art: "text" },
|
|
hinweis: "Muss im Blatt „Planstellen“ oder bereits im System stehen und frei sein.",
|
|
beispiel: "60000124",
|
|
},
|
|
{ name: "Eintritt", ziel: "entry_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
|
{
|
|
name: "Austritt",
|
|
ziel: "exit_date",
|
|
pflicht: false,
|
|
typ: { art: "datum" },
|
|
hinweis: "Leer = weiterhin beschäftigt. Muss nach dem Eintritt liegen.",
|
|
beispiel: "",
|
|
},
|
|
{ name: "Austrittsgrund", ziel: "exit_reason", pflicht: false, typ: { art: "text" }, hinweis: "Pflicht, wenn ein Austritt steht.", beispiel: "" },
|
|
{
|
|
name: "Beschäftigung",
|
|
ziel: "employment_type",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ["Vollzeit", "Teilzeit"] },
|
|
hinweis: "Leer = Vollzeit.",
|
|
beispiel: "Vollzeit",
|
|
},
|
|
{ name: "Wochenstunden", ziel: "weekly_hours", pflicht: false, typ: { art: "zahl" }, hinweis: "Leer = 38,5.", beispiel: "38,5" },
|
|
{
|
|
name: "Arbeitstage",
|
|
ziel: "work_days",
|
|
pflicht: false,
|
|
typ: { art: "liste", werte: WOCHENTAGE },
|
|
hinweis: "Leer = Mo;Di;Mi;Do;Fr.",
|
|
beispiel: "Mo;Di;Mi;Do;Fr",
|
|
},
|
|
{
|
|
name: "Vertragsart",
|
|
ziel: "contract_type",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ["unbefristet", "befristet"] },
|
|
hinweis: "Leer = unbefristet.",
|
|
beispiel: "unbefristet",
|
|
},
|
|
{
|
|
name: "Befristet bis",
|
|
ziel: "contract_end_date",
|
|
pflicht: false,
|
|
typ: { art: "datum" },
|
|
hinweis: "Pflicht bei „befristet“, sonst leer.",
|
|
beispiel: "",
|
|
},
|
|
{
|
|
name: "Verwendungsgruppe",
|
|
ziel: "paygrade",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
|
|
hinweis: "Leer = B.",
|
|
beispiel: "C",
|
|
},
|
|
{
|
|
name: "Kollektivvertrag",
|
|
ziel: "collective_agreement",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ["Handel", "Süßwaren"] },
|
|
hinweis: "Leer = Handel.",
|
|
beispiel: "Handel",
|
|
},
|
|
{
|
|
name: "Beschäftigtengruppe",
|
|
ziel: "worker_type",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] },
|
|
hinweis: "Leer = Angestellte:r.",
|
|
beispiel: "Angestellte:r",
|
|
},
|
|
{ name: "Monatsgehalt brutto", ziel: "monthly_salary_gross", pflicht: false, typ: { art: "zahl" }, hinweis: "", beispiel: "3450,00" },
|
|
{
|
|
name: "Herkunft",
|
|
ziel: "source",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ["Intern", "Extern"] },
|
|
hinweis: "Leer = Extern.",
|
|
beispiel: "Extern",
|
|
},
|
|
{ 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: "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: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
|
{
|
|
name: "Abwesenheit ab",
|
|
ziel: "karenz_start_date",
|
|
pflicht: false,
|
|
typ: { art: "datum" },
|
|
hinweis: "Beginn einer Langzeitabwesenheit.",
|
|
beispiel: "",
|
|
},
|
|
{
|
|
name: "Rückkehr geplant",
|
|
ziel: "karenz_return_date",
|
|
pflicht: false,
|
|
typ: { art: "datum" },
|
|
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
|
beispiel: "",
|
|
},
|
|
{
|
|
name: "Abwesenheitsart",
|
|
ziel: "absence_type",
|
|
pflicht: false,
|
|
typ: { art: "auswahl", werte: ABSENCE_TYPES },
|
|
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
|
beispiel: "",
|
|
},
|
|
],
|
|
};
|
|
|
|
export const BLATT_HISTORIE: BlattSchema = {
|
|
name: "Historie",
|
|
zweck: "Ereignisse je Person. Alles, was vor dem Eintritt liegt, wird abgewiesen.",
|
|
schluessel: null,
|
|
spalten: [
|
|
{
|
|
name: "Personalnummer",
|
|
ziel: "personnel_number",
|
|
pflicht: true,
|
|
typ: { art: "ganzzahl" },
|
|
hinweis: "Muss im Blatt „Personen“ oder bereits im System stehen.",
|
|
beispiel: "2219",
|
|
},
|
|
{ name: "Datum", ziel: "event_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
|
{
|
|
name: "Ereignis",
|
|
ziel: "event_type",
|
|
pflicht: true,
|
|
typ: { art: "auswahl", werte: EREIGNIS },
|
|
hinweis: "",
|
|
beispiel: "Eintritt",
|
|
},
|
|
{ name: "Beschreibung", ziel: "description", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Eintritt als Projektingenieur:in" },
|
|
],
|
|
};
|
|
|
|
export const BLATT_ANGEHOERIGE: BlattSchema = {
|
|
name: "Angehörige",
|
|
zweck: "Angehörige je Person.",
|
|
schluessel: null,
|
|
spalten: [
|
|
{ name: "Personalnummer", ziel: "personnel_number", pflicht: true, typ: { art: "ganzzahl" }, hinweis: "", beispiel: "2219" },
|
|
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Julian" },
|
|
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
|
{
|
|
name: "Verhältnis",
|
|
ziel: "relationship",
|
|
pflicht: true,
|
|
typ: { art: "auswahl", werte: VERHAELTNIS },
|
|
hinweis: "",
|
|
beispiel: "Kind",
|
|
},
|
|
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "04.06.2014" },
|
|
{ name: "SV-Nummer", ziel: "sv_nummer", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "" },
|
|
],
|
|
};
|
|
|
|
/**
|
|
* Reihenfolge = Ladereihenfolge.
|
|
*
|
|
* Standorte, Organisation und Jobkatalog müssen stehen, bevor Planstellen
|
|
* darauf verweisen können; Personen brauchen Planstellen; Historie und
|
|
* Angehörige brauchen Personen.
|
|
*/
|
|
export const BLAETTER: BlattSchema[] = [
|
|
BLATT_STANDORTE,
|
|
BLATT_ORGANISATION,
|
|
BLATT_JOBKATALOG,
|
|
BLATT_PLANSTELLEN,
|
|
BLATT_PERSONEN,
|
|
BLATT_HISTORIE,
|
|
BLATT_ANGEHOERIGE,
|
|
];
|
|
|
|
export function blattSchema(name: string): BlattSchema | undefined {
|
|
const gesucht = name.trim().toLowerCase();
|
|
return BLAETTER.find((b) => b.name.toLowerCase() === gesucht);
|
|
}
|