Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.
Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.
Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.
Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:
- weekly_hours is bound to employment type by a CHECK constraint: full time
is exactly 38.5. The import reached the insert and was rolled back. Now
it is a finding with a row number.
- Titles are restricted to a fixed list by another CHECK. Same treatment.
- setval() needs UPDATE on the sequence, which `usage, select` does not
grant. Migration 20260803120000 adds it; until it is applied, an import
containing people will fail at the last step and take itself back.
I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.
Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
450 lines
15 KiB
TypeScript
450 lines
15 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: "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);
|
|
}
|