Load a whole organisation from a file, or none of it
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>
This commit is contained in:
@@ -316,7 +316,9 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
|
||||
const austritt = s(w.exit_date);
|
||||
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
|
||||
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", eintritt);
|
||||
if (austritt && eintritt && austritt <= eintritt) melde("Personen", z.zeile, "Austritt", "Liegt nicht nach dem Eintritt.", austritt);
|
||||
// Gleicher Tag ist erlaubt — jemand, der den Dienst nicht antritt, tritt
|
||||
// am selben Tag ein und aus. Die Datenbank sieht das genauso.
|
||||
if (austritt && eintritt && austritt < eintritt) melde("Personen", z.zeile, "Austritt", "Liegt vor dem Eintritt.", austritt);
|
||||
if (austritt && !s(w.exit_reason)) melde("Personen", z.zeile, "Austrittsgrund", "Pflicht, sobald ein Austritt steht.");
|
||||
|
||||
if (s(w.contract_type) === "befristet" && !s(w.contract_end_date)) {
|
||||
@@ -333,9 +335,31 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
|
||||
if (eintritt && abVon < eintritt) melde("Personen", z.zeile, "Abwesenheit ab", "Liegt vor dem Eintritt.", abVon);
|
||||
}
|
||||
|
||||
if (abVon && s(w.karenz_return_date) && s(w.karenz_return_date)! < (eintritt ?? "")) {
|
||||
melde("Personen", z.zeile, "Rückkehr geplant", "Liegt vor dem Eintritt.", s(w.karenz_return_date)!);
|
||||
}
|
||||
|
||||
// Vollzeit bedeutet in diesem Kollektivvertrag genau 38,5 Stunden, und
|
||||
// Teilzeit alles darunter über null. Die Datenbank hat dafür eine
|
||||
// CHECK-Bedingung; ohne diese Prüfung bräche der Import erst beim
|
||||
// Schreiben ab — mit einer Meldung ohne Zeilennummer. Genau so ist der
|
||||
// erste Durchstich gescheitert.
|
||||
const stunden = n(w.weekly_hours);
|
||||
if (stunden !== null && (stunden <= 0 || stunden > 60)) {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Ausserhalb eines plausiblen Bereichs (0–60).", String(stunden));
|
||||
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
|
||||
if (stunden !== null) {
|
||||
if (beschaeftigung === "Vollzeit" && stunden !== 38.5) {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Vollzeit sind genau 38,5 Stunden. Für weniger „Teilzeit“ eintragen.", String(stunden));
|
||||
}
|
||||
if (beschaeftigung === "Teilzeit" && (stunden <= 0 || stunden >= 38.5)) {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Teilzeit liegt zwischen 0 und 38,5 Stunden.", String(stunden));
|
||||
}
|
||||
} else if (beschaeftigung === "Teilzeit") {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Pflicht bei Teilzeit — sonst gälten 38,5 und damit Vollzeit.");
|
||||
}
|
||||
|
||||
const tage = w.work_days;
|
||||
if (Array.isArray(tage) && tage.length === 0) {
|
||||
melde("Personen", z.zeile, "Arbeitstage", "Mindestens ein Tag. Leer lassen für Mo–Fr.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user