Feinkonzept-Addendum · kern/01a — Zeitarten-Katalog, Single-Active-Invariant & Lohn-Flexibilität
Feinkonzept-Addendum · kern/01a — Zeitarten-Katalog, Single-Active-Invariant & Lohn-Flexibilität
Abschnitt betitelt „Feinkonzept-Addendum · kern/01a — Zeitarten-Katalog, Single-Active-Invariant & Lohn-Flexibilität“Status: Draft v0.1 · 2026-05-02 · ergänzt
kern/01-zeiterfassung.md. Treiber: drei reale Defekte aus dem Beta-Smoke-Test (2026-05-02):
- „Aktive und passive Reisezeit gibt es“ — heute nicht modelliert.
- „Wenn ich eine andere Zeit starte, wird die eigentliche nicht beendet“ — Server lässt parallele aktive Stempel zu.
- „Stop soll alle Zeiten beenden“ — ebenfalls nicht erzwungen. Lohn-Hebel: der Zeitarten-Katalog ist gleichzeitig der einzige saubere Punkt, um den DATEV-LODAS-Export pro Lohnart zu steuern (siehe
kern/10-datev-integration§F-W-03 / F-A-01).Scope-Disziplin: dieses Addendum redet ausschließlich über Zeitarten, die Single-Active-Invariante und das Lohn-Mapping. Stempel-UI, Offline, NFC, Voice, Hash-Chain bleiben unverändert wie in
01-zeiterfassung.mddefiniert.
1. Problem (kompakt)
Abschnitt betitelt „1. Problem (kompakt)“Das aktuelle Schema hat drei Lecks:
| Leck | Symptom | Wurzel |
|---|---|---|
| L-01 Zeitart als Freitext | time_entries.time_art varchar(32) ohne Tenant-Katalog → Tippfehler („Reise“/„Reisezeit“/„Fahrt“) landen nebeneinander, Lohn-Mapping unmöglich |
F-X-01 nennt Tenant-Stammdaten, ist aber als Hardcoded-Enum implementiert |
| L-02 Keine Single-Active-Invariante | User stempelt 09:00 Arbeit, 12:00 Reise — Server speichert beides als zwei start-Einträge ohne stop dazwischen. EC-04 nennt das, aber als nachgelagertes Manager-Ticket statt als Server-Block |
POST /entries macht stumpfen INSERT, prüft keine offenen Perioden |
| L-03 Reisezeit binär | „Reise“ ist eine Zeitart. Aktive Fahrt (Selbstfahrer = Arbeitszeit, BAG 17.10.2018 5 AZR 553/17) und passive Mitfahrt (i.d.R. nicht Arbeitszeit, oft tariflich teilvergütet) sind dasselbe Bucket | Datenmodell hat keine Lohn-Dimension pro Zeitart |
Und jeder dieser drei Lecks blockiert das Lohnzettel-Ziel: ohne saubere Lohnart-Klassifikation ist DATEV-LODAS-Export Wackel-Mapping in Excel.
2. Zielbild
Abschnitt betitelt „2. Zielbild“Ein Tenant pflegt einen Zeitarten-Katalog. Jeder Eintrag im Katalog trägt die Lohn-Semantik (Vergütungsfaktor, Steuer-Behandlung, SOKA-Relevanz, DATEV-Lohnart). Stempel-Buchungen referenzieren per Foreign Key auf den Katalog. Der Server erzwingt: pro (tenant_id, user_id) ist zu jedem Zeitpunkt höchstens eine Periode aktiv. Ein Wechsel der Zeitart oder ein expliziter Stop schließt alle laufenden Perioden atomar in einer Transaktion.
Konsequenz für den Lohnzettel: der DATEV-Export aus kern/10 aggregiert pro Mitarbeiter × Lohnart-ID (nicht mehr pro Freitext-String). Tarif-Zuschläge (Nacht/Sonntag/Feiertag) bleiben in kern/02-arbeitszeit-compliance als orthogonale Dimension, weil sie aus Uhrzeit + Wochentag + Feiertagskalender abgeleitet werden, nicht aus der Zeitart.
3. Datenmodell-Erweiterung
Abschnitt betitelt „3. Datenmodell-Erweiterung“3.1 Neue Tabelle time_arts
Abschnitt betitelt „3.1 Neue Tabelle time_arts“Tenant-scoped Katalog, append-only mit Versionierung (Tarifänderungen müssen rückwirkend rekonstruierbar sein — §147 AO 10 J).
// apps/api/src/db/schemas/zeit.ts (Erweiterung)export const timeArtsTable = pgTable('time_arts', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id), code: varchar('code', { length: 48 }).notNull(), // 'arbeit','reise_aktiv','reise_passiv','bereitschaft','rufbereitschaft','schulung','arzt','dienstgang' label: varchar('label', { length: 80 }).notNull(), // i18n via slang im Frontend, hier Tenant-spezifisch ('Reise (selbst gefahren)') kategorie: timeArtKategorieEnum('kategorie').notNull(), // 'arbeit' | 'reise' | 'bereitschaft' | 'pause' | 'abwesenheit' | 'fortbildung'
// --- Single-Active-Verhalten --- istAktivePeriode: boolean('ist_aktive_periode').notNull().default(true), // false bei reinen Markern wie 'Pause' (die schliessen keine Arbeitsperiode, sondern ergaenzen sie)
// --- Lohn-Konfiguration (Lohnzettel-Hebel) --- verguetungsFaktor: numeric('verguetung_faktor', { precision: 4, scale: 3 }).notNull().default('1.000'), // 1.000 = voller Stundenlohn, 0.500 = halb (z.B. passive Reise nach TV), 0.000 = unbezahlt (z.B. private Pause) istArbeitszeitArbZG: boolean('ist_arbeitszeit_arbzg').notNull().default(true), // true = zaehlt fuer Hoechstarbeitszeit (§3 ArbZG); false z.B. fuer Bereitschaft mit reduzierter Anrechnung istSokaRelevant: boolean('ist_soka_relevant').notNull().default(true), // SOKA-BAU: nicht jede Zeit fliesst ins Bruttolohnsumme-Meldewesen steuerlicheBehandlung: timeArtSteuerEnum('steuerliche_behandlung').notNull().default('lohnsteuer_pflichtig'), // 'lohnsteuer_pflichtig' | 'steuerfrei_3_16_estg' (Spesen) | 'pauschal_25_estg' | 'sachbezug_8_2_estg' sozialversPflichtig: boolean('sozialvers_pflichtig').notNull().default(true),
// --- DATEV-Mapping (kern/10 §F-W-03) --- datevLohnartSchluessel: integer('datev_lohnart_schluessel'), // z.B. 1=Grundlohn, 9=Sonntagszuschlag, 58=Entgeltfortzahlung datevKostenstelleHint: varchar('datev_kostenstelle_hint', { length: 16 }),
// --- Tarif-Referenz fuer Audit --- tarifReferenz: varchar('tarif_referenz', { length: 200 }), // 'BRTV-Bau §7 Nr. 4.2 — passive Reisezeit 50%' oder 'Einzelvereinbarung 2026-04-12 mit BR-Beschluss BR-2026-014'
// --- Lifecycle --- status: timeArtStatusEnum('status').notNull().default('aktiv'), // aktiv | deaktiviert gueltigAb: date('gueltig_ab').notNull(), gueltigBis: date('gueltig_bis'), vorgaengerArtId: uuid('vorgaenger_art_id').references((): any => timeArtsTable.id), // Bei tariflicher Aenderung: neue Zeile, alte bekommt gueltig_bis, neue setzt vorgaenger_art_id
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdBy: uuid('created_by').notNull().references(() => usersTable.id),}, (t) => ({ tenantCodeUniq: uniqueIndex('time_arts_tenant_code_gueltig_uniq').on(t.tenantId, t.code, t.gueltigAb), tenantStatusIdx: index('time_arts_tenant_status_idx').on(t.tenantId, t.status),}));3.2 Migration time_entries.time_art → time_art_id
Abschnitt betitelt „3.2 Migration time_entries.time_art → time_art_id“-- 0051_zeitarten_katalog.sql (Skizze)ALTER TABLE time_entries ADD COLUMN time_art_id uuid REFERENCES time_arts(id);
-- Backfill: pro Tenant Default-Katalog seeden (siehe §4), dann Mapping-Update.-- Danach in 0052 (separate Migration nach Backfill-Verifikation):ALTER TABLE time_entries ALTER COLUMN time_art_id SET NOT NULL, DROP COLUMN time_art;Der Freitext bleibt eine Migration lang parallel als Fallback, bis alle alten Buchungen gemappt sind. Erst die zweite Migration entfernt ihn — gleiche Sicherheits-Choreografie wie bei jeder NOT-NULL-Backfill (vgl. unsere tenants.status-Erfahrung in 0049).
3.3 Neue Tabelle time_periods (optional, Phase 2)
Abschnitt betitelt „3.3 Neue Tabelle time_periods (optional, Phase 2)“Für die Single-Active-Invariante reichen time_entries mit serverseitiger Logik. Eine projizierte Sicht erleichtert aber Abfragen für UI + DATEV-Aggregation:
CREATE MATERIALIZED VIEW time_periods_mv ASSELECT start_e.tenant_id, start_e.user_id, start_e.time_art_id, start_e.project_id, start_e.site_id, start_e.id AS start_entry_id, stop_e.id AS stop_entry_id, start_e.occurred_at AS started_at, stop_e.occurred_at AS ended_at, EXTRACT(EPOCH FROM (stop_e.occurred_at - start_e.occurred_at))/3600.0 AS dauer_hFROM time_entries start_eLEFT JOIN LATERAL ( SELECT id, occurred_at FROM time_entries s WHERE s.tenant_id = start_e.tenant_id AND s.user_id = start_e.user_id AND s.kind = 'stop' AND s.occurred_at > start_e.occurred_at ORDER BY s.occurred_at ASC LIMIT 1) stop_e ON TRUEWHERE start_e.kind = 'start';
CREATE UNIQUE INDEX ON time_periods_mv (start_entry_id);REFRESH MATERIALIZED VIEW CONCURRENTLY nach jedem Stempel-Trigger oder per Cron. Für Live-Dashboard reicht aber ein direkter Query auf time_entries. MV-Entscheidung erst in Phase 2 (Performance-Bedarf nachweisen).
4. Default-Katalog (Branchen-Seed)
Abschnitt betitelt „4. Default-Katalog (Branchen-Seed)“Bei Tenant-Anlage seeden wir einen branchengerechten Katalog. Das ist gleichzeitig die Antwort auf “Lohnzettel-Flexibilität”: der Admin bekommt einen kuratierten Stand, kann ihn aber ändern.
Allgemein-Handwerk (Default):
| Code | Label | kategorie | aktive Periode | Vergütg.-Faktor | ArbZG | SOKA | Steuer | DATEV-Lohnart |
|---|---|---|---|---|---|---|---|---|
arbeit |
Arbeit | arbeit | ✅ | 1.000 | ✅ | ✅ | lohnsteuer_pflichtig | 1 (Grundlohn) |
reise_aktiv |
Reise (selbst gefahren) | reise | ✅ | 1.000 | ✅ | ✅ | lohnsteuer_pflichtig | 1 (Grundlohn) |
reise_passiv |
Reise (Mitfahrer) | reise | ✅ | 0.500 | ❌ | ❌ | lohnsteuer_pflichtig | 1 (Grundlohn, halb) |
bereitschaft |
Bereitschaft | bereitschaft | ✅ | 0.500 | ✅ | ✅ | lohnsteuer_pflichtig | 1 (Grundlohn, halb) |
rufbereitschaft |
Rufbereitschaft | bereitschaft | ✅ | 0.125 | ❌ | ❌ | lohnsteuer_pflichtig | 80 (Pauschale) |
schulung |
Schulung / Fortbildung | fortbildung | ✅ | 1.000 | ✅ | ✅ | lohnsteuer_pflichtig | 1 |
arzt |
Arzt / Behörde | abwesenheit | ✅ | 1.000 | ❌ | ❌ | lohnsteuer_pflichtig | 58 (Entgeltfortzahlung) |
pause |
Pause | pause | ❌ | 0.000 | ❌ | ❌ | (kein Lohn) | — |
SOKA-Bau-Tenant (zusätzlicher Seed-Wizard, getriggert über Branche=Bau):
| Code | Label | tarifReferenz |
|---|---|---|
reise_aktiv_brtv |
Reise selbst gefahren (BRTV) | BRTV-Bau §7 Nr. 4.2 — voll vergütet |
reise_passiv_brtv |
Reise Mitfahrer (BRTV) | BRTV-Bau §7 Nr. 4.3 — 50 % |
winterausfall |
Winterausfallgeld | SOKA-Bau-Verfahren — getrennt |
bg_bau_arbeit |
Arbeit (BG-BAU U1/U2-relevant) | BG-BAU |
Der Branchen-Wizard ist derselbe wie in kern/10 §F-A-01 — wir teilen den Code-Pfad, nicht zwei parallele Wizard-Implementierungen.
5. Single-Active-Invariante — Server-Choreografie
Abschnitt betitelt „5. Single-Active-Invariante — Server-Choreografie“5.1 Regel (verbalisiert)
Abschnitt betitelt „5.1 Regel (verbalisiert)“Pro (tenant_id, user_id) darf zu jedem Zeitpunkt höchstens eine Periode mit time_art.ist_aktive_periode = true offen sein. „Offen“ = letzter Eintrag dieser Periode ist kind='start', kein nachfolgender kind='stop' mit gleicher period_group_id existiert.
Pause-Einträge (ist_aktive_periode = false) ergänzen die laufende Arbeitsperiode, schließen sie nicht.
5.2 Implementierung in POST /v1/kern/zeit/entries
Abschnitt betitelt „5.2 Implementierung in POST /v1/kern/zeit/entries“Die Route bekommt eine neue Branche:
// apps/api/src/routes/kern/zeit/entries.ts (Skizze)await db.transaction(async (tx) => { // Tenant-scoped advisory lock pro user — verhindert Race zwischen Mobile + Web await tx.execute(sql`SELECT pg_advisory_xact_lock( hashtextextended(${tenantId}::text || ':' || ${userId}::text, 0) )`);
if (input.kind === 'start') { const targetArt = await tx .select().from(timeArtsTable) .where(and( eq(timeArtsTable.id, input.timeArtId), eq(timeArtsTable.tenantId, tenantId), )).then(rows => rows[0]);
if (!targetArt) throw new ValidationError('time_art_unknown');
if (targetArt.istAktivePeriode) { // Auto-Stop aller offenen aktiven Perioden DIESES Users. const offenePerioden = await tx .select(/* offene starts ohne stop */)...; for (const offen of offenePerioden) { await tx.insert(timeEntriesTable).values({ ..., kind: 'stop', timeArtId: offen.timeArtId, occurredAt: input.occurredAt, // gleicher Server-TS wie der neue start source: 'auto_switch', // neuer source-Wert, audit-relevant reasonText: `Auto-Stop wegen Wechsel auf ${targetArt.code}`, supersedesId: null, // hashPrev/hashSelf werden vom hash-chain-trigger gesetzt }); } } // dann den eigentlichen Start einfuegen await tx.insert(timeEntriesTable).values({ ...startInput }); }
if (input.kind === 'stop' && !input.targetEntryId) { // Stop ohne Spezifikation = "alle aktiven Perioden beenden" const offenePerioden = await tx.select(...).where(...); for (const offen of offenePerioden) { await tx.insert(timeEntriesTable).values({ kind: 'stop', timeArtId: offen.timeArtId, occurredAt: input.occurredAt, source: input.source ?? 'mobile', }); } }});Wichtige Eigenschaften:
pg_advisory_xact_lock(tenant:user)statt globaler Lock — Skaliert linear, blockiert nur denselben Mitarbeiter (verhindert das EC-04-Doppelstempel-Race zwischen Smartphone + Werkstatt-Tablet).source = 'auto_switch'ist ein neuer Enum-Wert, damit Audit-Log und Manager-Dashboard einen Auto-Stop sichtbar von einem manuellen Stop trennen können.- Hash-Chain bleibt intakt: der Trigger aus 01-zeiterfassung §10.1 läuft weiter über jede Zeile, die in der Transaktion eingefügt wird; mehrere Zeilen pro TX hängen sich korrekt aneinander, weil die Kette per
(tenant_id, user_id)läuft. - Idempotency-Key des eintreffenden Requests deckt nur den eigentlichen
startab. Die Auto-Stops bekommen serverseitig generierte Idempotency-Keys (deterministic:uuidv5(idempotencyKey, 'auto-stop:' + offenStartId)), damit Retry des gleichen Requests nicht zweite Auto-Stops produziert.
5.3 DB-seitige Belt-and-Suspenders-Constraint
Abschnitt betitelt „5.3 DB-seitige Belt-and-Suspenders-Constraint“Optional, kostet aber wenig: ein Partial Unique Index, der maximal eine offene Periode pro User erzwingt. Bei Verletzung bricht der INSERT — sicherer als reine Transaktions-Logik.
-- nur kind='start' ohne korrespondierenden stop -> "offen"-- praktisch: wir markieren Auto-Schliessung mit einem closed_at; siehe §6Der Constraint setzt time_entries.closed_at-Spalte voraus (siehe §6) — Reihenfolge: Code-Pfad (§5.2) zuerst, Constraint (§5.3) als zweite Migration nachschalten, sonst zerlegt die Constraint historische Daten.
6. Schema-Refinement: closed_at statt rein implizit
Abschnitt betitelt „6. Schema-Refinement: closed_at statt rein implizit“Die heutige Modellierung („Stop ist ein eigener Entry mit kind='stop'“) ist GoBD-elegant, aber Performance-feindlich für die „ist-noch-offen?”-Abfrage. Daher zusätzlich ein denormalisiertes Schliess-Feld:
ALTER TABLE time_entries ADD COLUMN closed_at timestamptz, ADD COLUMN closed_by_entry_id uuid REFERENCES time_entries(id), ADD COLUMN period_group_id uuid; -- gemeinsame ID fuer start+stop+pauses einer Periodeperiod_group_idwird beimkind='start'als neue UUID-v7 gesetzt; nachfolgende Pause/Stop derselben Periode tragen dieselbe ID.- Der
kind='stop'-Insert macht im selben TX einUPDATEauf den korrespondierendenstart-Row:SET closed_at = stop.occurred_at, closed_by_entry_id = stop.id. Das verletzt nicht die Append-Only-Invariante, weilclosed_atein „operationelles“ Feld ist und die Hash-Chain (hash_self) nicht überclosed_atgerechnet wird (Feld aushash_self-Kanonisierung explizit ausnehmen).
Dann ist „offene Perioden dieses Users“ trivial:
SELECT * FROM time_entries WHERE tenant_id = $1 AND user_id = $2 AND kind = 'start' AND closed_at IS NULL AND time_art_id IN (SELECT id FROM time_arts WHERE ist_aktive_periode = true);Und die Constraint (§5.3) wird:
CREATE UNIQUE INDEX time_entries_one_open_active_per_user ON time_entries (tenant_id, user_id) WHERE kind = 'start' AND closed_at IS NULL AND time_art_id IN (SELECT id FROM time_arts WHERE ist_aktive_periode = true);Hash-Chain-Sorgfalt: Die SHA-256-Kanonisierung muss
closed_at,closed_by_entry_id,period_group_idausnehmen — sonst bricht jeder spätere Stop die Kette des dazugehörenden Starts. Wir testen das in C-01-Erweiterung explizit.
7. Lohnzettel-Pfad (Ende-zu-Ende)
Abschnitt betitelt „7. Lohnzettel-Pfad (Ende-zu-Ende)“Der Punkt, den der User am stärksten betont: Flexibilität für Lohnzettel.
Pfad ab Stempelung:
- Mitarbeiter stempelt →
time_entriesmittime_art_id(z.B.reise_passiv). - Hash-Chain-Trigger schreibt + verkettet.
- Monatsabschluss (kern/01 §F-W-06) markiert
locked_at. - DATEV-Export (kern/10 §F-W-02) aggregiert pro
(user_id, time_art.datev_lohnart_schluessel):- Brutto-Stunden = Σ (
ended_at - started_at) - Lohnrelevante Stunden = Brutto ×
verguetung_faktor - SOKA-Stunden = nur, wo
time_art.ist_soka_relevant = true - Steuerfrei-Block = nur, wo
time_art.steuerliche_behandlung = 'steuerfrei_3_16_estg'
- Brutto-Stunden = Σ (
- LODAS-ASCII-Writer (kern/10 §3.10 / TECH-STACK) emittiert pro Lohnart eine Zeile mit der richtigen DATEV-Schlüsselzahl.
Tarif-Zuschläge (orthogonal): Nacht (25 % §6 Abs. 5 ArbZG), Sonntag (50 %), Feiertag (125 %) werden aus Uhrzeit + Wochentag + Feiertagskalender abgeleitet (kern/02 macht das heute schon für ArbZG-Flags). Sie ergeben eigene DATEV-Lohnart-Zeilen (Schlüssel 13/9/usw.) und multiplizieren sich mit dem Vergütungsfaktor der Zeitart — d.h. eine Sonntags-Reise-Passiv ist 1h × 0.5 (Reise-Passiv) + Sonntagszuschlag auf 0.5 h (Reise mit halbem Faktor schlägt sich bis in den Zuschlag durch). Tariflich ist das diskutabel; wir machen den Multiplikator-Stack in der Zeitart-Konfiguration explizit:
zuschlagBasis: 'verguetete_stunden' | 'brutto_stunden', // Default 'verguetete_stunden'Damit kann ein Tarif, der „Sonntagszuschlag auf Brutto, nicht auf vergütete Stunden“ verlangt, sauber abgebildet werden, ohne Sonderfall-Code im LODAS-Writer.
8. UI-Konsequenzen (Skizzen)
Abschnitt betitelt „8. UI-Konsequenzen (Skizzen)“8.1 Mobile Stempel-Screen (Erweiterung von 01-zeiterfassung §6.2)
Abschnitt betitelt „8.1 Mobile Stempel-Screen (Erweiterung von 01-zeiterfassung §6.2)“┌────────────────────────────────┐│ ← Zeit erfassen ⚙ │├────────────────────────────────┤│ Mo 19.04.2026 🟢 Eingestempelt││ Arbeit · Lehrer Allee 7 ││ 06:40 h ││ ││ ╭──────────╮ ╭──────────╮ ││ │ ■ STOP │ │ ⇄ Wechsel│ ││ ╰──────────╯ ╰──────────╯ ││ ││ Wechseln zu … ││ ┌─ Reise (selbst gefahren) ─┐ ││ │ + Bautagebuch-Eintrag │ ││ ├─ Reise (Mitfahrer) ─50%─┤ ││ ├─ Bereitschaft ─50% ──┤ ││ ├─ Schulung ─┤ ││ └─ Pause (Arbeit pausiert)─┘ ││ ││ ℹ Wechsel beendet die laufende││ Arbeit um 13:42 automatisch.│└────────────────────────────────┘Wichtig: der Vergütungsfaktor (50%) erscheint im UI als kleiner Hinweis. Mitarbeiter sehen bevor sie wechseln, dass passive Reise nur halb zählt — verhindert Beschwerden später.
8.2 Web Admin · Zeitarten-Editor (neu, §F-A-01-Erweiterung)
Abschnitt betitelt „8.2 Web Admin · Zeitarten-Editor (neu, §F-A-01-Erweiterung)“┌────────────────────────────────────────────────────────────────────────┐│ Einstellungen → Zeitarten · musterbetrieb-bau + Anlegen │├────────────────────────────────────────────────────────────────────────┤│ Code Label Faktor ArbZG SOKA DATEV ⋮ ││ arbeit Arbeit 1.000 ✅ ✅ 1 ⋮ ││ reise_aktiv_brtv Reise (selbst, BRTV) 1.000 ✅ ✅ 1 ⋮ ││ reise_passiv_brtv Reise (Mitf., BRTV) 0.500 — — 1 ⋮ ││ bereitschaft Bereitschaft 0.500 ✅ ✅ 1 ⋮ ││ pause Pause 0.000 — — — ⋮ │├────────────────────────────────────────────────────────────────────────┤│ Tarif-Referenz: BRTV-Bau §7 Nr. 4.3 (50 % passive Reisezeit) ││ Gültig ab: 2026-01-01 Vorgänger: reise_passiv (gültig bis 2025-12-31)│└────────────────────────────────────────────────────────────────────────┘Der Editor ist Schreib-geschützt, sobald ein Monatsabschluss diese Zeitart referenziert — Änderungen erfolgen nur über „Neue Version anlegen“, die vorgaenger_art_id setzt.
9. Migrations-Reihenfolge (Belastungs-arm)
Abschnitt betitelt „9. Migrations-Reihenfolge (Belastungs-arm)“| Migration | Inhalt | Risiko |
|---|---|---|
| 0051 | time_arts + time_entries.time_art_id (NULLABLE) + Default-Katalog-Seed pro Tenant + Backfill time_art_id aus Freitext |
mittel — read-only auf bestehenden Daten |
| 0052 | time_entries.closed_at, closed_by_entry_id, period_group_id + Backfill (paarweise zuordnen via Window-Function) |
mittel — schreibend auf historischen Daten, aber hash_self unberührt (closed_at aus Kanonisierung ausgenommen) |
| App-Release | Route-Logik §5.2 mit Auto-Stop + advisory_xact_lock; UI-Wechsel-Button; Source-Enum erweitert um auto_switch |
niedrig (additiv) |
| 0053 | time_entries.time_art DROP COLUMN + NOT NULL auf time_art_id |
mittel — irreversibel, vorher Data-Quality-Audit |
| 0054 | Constraint §5.3 (Unique Partial Index time_entries_one_open_active_per_user) |
hoch — bricht harten DB-Insert; vorher 100 % der Daten konsistent |
Jede Stufe ist alleine releasefähig und bricht nicht den Vorgänger. Rollback-Pfad: 0054 rückwärtskompatibel droppbar; 0053 per Compat-View time_entries_legacy mit time_art aus JOIN time_arts rekonstruierbar (für Notfall-Backup-Restore).
10. Test-Plan-Erweiterung
Abschnitt betitelt „10. Test-Plan-Erweiterung“Diese Tests sind zusätzlich zu den 13.x-Tests in 01-zeiterfassung.md:
| ID | Typ | Inhalt |
|---|---|---|
| U-06 | Unit | verguetungBerechnen(period, art) → korrekt für Faktor 1.0/0.5/0.125/0.0 |
| I-07 | Integration (Testcontainers) | POST /entries kind=start art=arbeit dann POST /entries kind=start art=reise_aktiv → erwartet 3 Zeilen in DB: start arbeit, stop arbeit (auto_switch), start reise_aktiv. closed_at des ersten = occurredAt des zweiten. |
| I-08 | Integration | POST /entries kind=stop ohne target_entry_id während 2 aktive Perioden offen → schließt beide atomar. |
| I-09 | Integration | Concurrent: zwei POST /entries kind=start mit gleichem Server-TS für gleichen User, parallel auf 2 Connections. Erwartet: nur einer gewinnt, der zweite blockiert via advisory_lock und sieht den Auto-Stop des ersten. |
| I-10 | Integration | pause-Eintrag während laufender Arbeit-Periode schließt diese nicht (closed_at bleibt NULL). |
| C-01-ext | Compliance | Nach Auto-Stop bleibt Hash-Chain pro (tenant, user) intakt. Kanonisierung des start-Rows ignoriert closed_at — Test manipuliert closed_at direkt in DB und prüft, dass hash_self unverändert bleibt. |
| C-07 | Compliance | Tenant A kann keine Zeitart von Tenant B referenzieren in time_entries.time_art_id (FK + RLS). |
| C-08 | Compliance | DATEV-LODAS-Export aggregiert korrekt: 1h Arbeit + 1h Reise-Passiv → 1.5h Vergütungs-Stunden auf DATEV-Lohnart 1; SOKA-Meldung enthält nur die 1h Arbeit. |
| E-06 | E2E (Patrol) | Mobile: Wechsel von Arbeit auf Reise-Passiv via Bottom-Sheet; UI zeigt korrekt „Auto-Stop um 13:42“; Liste enthält 3 Einträge in der richtigen Reihenfolge. |
7a. Lohn-Engine — Multi-Lohnart pro Zeitfenster (User-Erweiterung 2026-05-02)
Abschnitt betitelt „7a. Lohn-Engine — Multi-Lohnart pro Zeitfenster (User-Erweiterung 2026-05-02)“Treiber: „Wie kann man flexibel auch zu einer Zeit mehrere Lohnarten hinterlegen?“ + Nacht-Stufen + Wochenend-/Schicht-Spillover-Regeln.
7a.1 Mentales Modell — eine Stunde = mehrere Lohnart-Zeilen
Abschnitt betitelt „7a.1 Mentales Modell — eine Stunde = mehrere Lohnart-Zeilen“Eine einzelne Stempel-Periode (z.B. Sa 22:00 → So 06:00 Arbeit auf Notdienst) erzeugt im DATEV-Export N Lohnart-Zeilen, weil sich Zuschläge stapeln:
| Lohnart | DATEV-Schl. | Stunden | Wo kommt’s her? |
|---|---|---|---|
| Grundlohn | 1 | 8.0 | Zeitart arbeit × Vergütg.-Faktor 1.0 |
| Nachtarbeitszuschlag (Nachtzeit) | 12 | 1.0 | 22:00–23:00 Sa fällt in „Nachtzeit“ 20:00–06:00 |
| Nachtarbeitszuschlag (Nachtkernz.) | 13 | 7.0 | 23:00 Sa – 06:00 So fällt in „Nachtkernzeit“ 23:00–06:00 |
| Sonntagszuschlag | 9 | 6.0 | 00:00–06:00 So |
| Samstagszuschlag (tariflich) | 14 | 2.0 | 22:00–24:00 Sa |
Der Grundlohn kommt aus der Zeitart (kern/01a §3). Die Zuschläge kommen aus einer separaten Regel-Engine, die Zeitfenster × Wochentag → DATEV-Schlüssel + Faktor abbildet. Beide Wege sind orthogonal: der Mitarbeiter stempelt eine Zeitart, der Server zerlegt die Periode beim Export in alle anwendbaren Lohnart-Zeilen.
7a.2 Datenmodell lohn_zuschlag_regeln
Abschnitt betitelt „7a.2 Datenmodell lohn_zuschlag_regeln“Tenant-scoped, versioniert (Tarif-Änderungen rückwirkend rekonstruierbar):
export const lohnZuschlagRegelnTable = pgTable('lohn_zuschlag_regeln', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull(), code: varchar('code', { length: 64 }).notNull(), // 'nachtzeit', 'nachtkernzeit', 'sonntag', 'samstag', 'feiertag', 'feiertag_125' label: varchar('label', { length: 120 }).notNull(),
// --- Zeitfenster --- startUhrzeit: time('start_uhrzeit').notNull(), // '22:00' endeUhrzeit: time('ende_uhrzeit').notNull(), // '06:00' (kann ueber Mitternacht) ueberschreitetMitternacht: boolean('ueberschreitet_mitternacht').notNull().default(false),
// --- Wochentage (Bitfeld) --- wochentage: smallint('wochentage').notNull(), // bit 0 = Mo, bit 1 = Di, ..., bit 6 = So // 0b0111110 = Mo-Fr, 0b1000000 = So, 0b1100000 = Sa+So feiertagsAnwendung: feiertagsAnwendungEnum('feiertags_anwendung').notNull().default('ignorieren'), // 'ignorieren' | 'wie_sonntag' | 'eigene_regel' (z.B. Feiertag = 125 % statt 50 %)
// --- Zuschlags-Konfiguration --- zuschlagFaktor: numeric('zuschlag_faktor', { precision: 4, scale: 3 }).notNull(), // 0.250 = +25 %, 0.500 = +50 %, 1.250 = +125 % (Feiertag) zuschlagBasis: zuschlagBasisEnum('zuschlag_basis').notNull().default('verguetete_stunden'), // 'verguetete_stunden' = Faktor mal (Stundenlohn × Verguetungsfaktor) // 'brutto_stunden' = Faktor mal Stundenlohn (ignoriert Zeitart-Faktor) steuerfreiNach3bEstg: boolean('steuerfrei_nach_3b_estg').notNull().default(false), // §3b EStG: Nacht 25%, So 50%, Feiertag 125% steuerfrei (Stundenlohn-Cap 50 € beachten!)
// --- DATEV --- datevLohnartSchluessel: integer('datev_lohnart_schluessel').notNull(),
// --- Spillover (Schicht-Übergang) --- spilloverModus: spilloverEnum('spillover_modus').notNull().default('hart_geschnitten'), // 'hart_geschnitten' = Periode wird am Wochentagswechsel (00:00) hart geteilt // 'tarif_4_uhr_morgens' = §3b Abs. 2 Satz 2 EStG: Sonntags-Schicht // gilt steuerfrei bis 04:00 Mo, wenn vor 00:00 So begonnen // 'kein_zuschlag_montags' = Sonntags-Schicht, die in den Montag laeuft, kriegt keinen // Sonntags-Zuschlag fuer den Montagsanteil (Tenant-Einstellung) spilloverBisUhrzeit: time('spillover_bis_uhrzeit'), // z.B. '04:00' fuer §3b giltNurFuerVorperiodeAm: smallint('gilt_nur_fuer_vorperiode_am'), // Wochentag-Bit: Spillover gilt nur, wenn Vorperiode am So begann
// --- Stack-Verhalten (welche Zuschlaege addieren sich, welche nicht?) --- exklusivMitCodes: text('exklusiv_mit_codes').array(), // ['samstag'] => nicht zusammen mit Samstagzuschlag (z.B. wenn Sa nach 22:00 schon // in Nachtkernzeit reinläuft und der Tarif nur eines erlaubt) prioritaet: smallint('prioritaet').notNull().default(100), // bei Exklusivitaet gewinnt die hoehere Prioritaet
// --- Lifecycle --- status: regelStatusEnum('status').notNull().default('aktiv'), gueltigAb: date('gueltig_ab').notNull(), gueltigBis: date('gueltig_bis'), vorgaengerRegelId: uuid('vorgaenger_regel_id').references((): any => lohnZuschlagRegelnTable.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdBy: uuid('created_by').notNull(),}, (t) => ({ tenantCodeUniq: uniqueIndex('lzr_tenant_code_gueltig_uniq').on(t.tenantId, t.code, t.gueltigAb), tenantStatusIdx: index('lzr_tenant_status_idx').on(t.tenantId, t.status),}));7a.3 Default-Regelwerk (Seed pro Tenant)
Abschnitt betitelt „7a.3 Default-Regelwerk (Seed pro Tenant)“| Code | Zeitfenster | Wochentage | Faktor | §3b? | DATEV | Spillover |
|---|---|---|---|---|---|---|
nachtzeit |
20:00–23:00 | Mo–So | 0.10 | ❌ | 11 | hart |
nachtkernzeit |
23:00–06:00 | Mo–So | 0.25 | ✅ | 12 | hart |
samstag |
13:00–24:00 | Sa | 0.20 | ❌ | 14 | hart |
sonntag |
00:00–24:00 | So | 0.50 | ✅ | 9 | tarif_4_uhr_morgens |
feiertag |
00:00–24:00 | (alle, feiertag=true) | 1.25 | ✅ | 10 | tarif_4_uhr_morgens |
Default „Sonntag → Montag“: Spillover-Modus tarif_4_uhr_morgens — gesetzlich konform §3b EStG (Sonntagsarbeit, die vor 00:00 So begann, gilt steuerfrei bis 04:00 Mo). Tenants, die das anders wollen, schalten auf kein_zuschlag_montags (kein Sonntagszuschlag mehr ab 00:00 Mo, harter Schnitt) — exakt der vom User genannte Fall „kein Sonntagszuschlag wenn die Schicht in den Montag reinläuft“.
7a.4 Berechnungs-Algorithmus (am Beispiel Sa 22:00 → So 06:00, Zeitart arbeit)
Abschnitt betitelt „7a.4 Berechnungs-Algorithmus (am Beispiel Sa 22:00 → So 06:00, Zeitart arbeit)“period.intervalle = [Sa 22:00 → So 06:00] (8 h Brutto)
# Schritt 1: Zeitart-Verguetunggrundlohn_h = 8.0 × time_art.verguetung_faktor (1.0) = 8.0 h → DATEV 1
# Schritt 2: Pro Zuschlagsregel die Schnittmenge der Periode mit dem Regel-Fenster bildenfuer regel in active_zuschlag_regeln(period.tenant, period.gueltig_am): schnitte = intersect(period.intervall, regel.fenster, regel.wochentage, regel.spillover_modus) fuer schnitt in schnitte: kandidaten.append({regel, dauer_h: schnitt.dauer_h})
# Schritt 3: Exklusivitaet aufloesen (Prioritaet)fuer kandidat in kandidaten: falls existiert anderer kandidat mit gleichem schnitt UND sich gegenseitig in exklusiv_mit_codes haben: gewinner = der mit hoeherer prioritaet sonst: kandidat zaehlt
# Schritt 4: Aggregation pro DATEV-Schluesselfuer kandidat: lohnart_zeilen[regel.datev_lohnart_schluessel] += kandidat.dauer_h × regel.zuschlag_faktor (Verguetungsbasis = brutto oder verguetet je regel.zuschlag_basis)
# Beispiel-Ergebnis:# DATEV 1 (Grundlohn): 8.0 h# DATEV 11 (Nachtzeit 20-23): 1.0 h × 0.10 = 0.10 h × Stundenlohn# DATEV 12 (Nachtkernzeit 23-06): 7.0 h × 0.25 = 1.75 h × Stundenlohn# DATEV 14 (Samstag 13-24): 2.0 h × 0.20 = 0.40 h × Stundenlohn# DATEV 9 (Sonntag 00-06): 6.0 h × 0.50 = 3.00 h × Stundenlohn (steuerfrei §3b)7a.5 Fakturierung (Kunden-Rechnung) als zweite Achse
Abschnitt betitelt „7a.5 Fakturierung (Kunden-Rechnung) als zweite Achse“Der User erwähnt „Fakturen“ — Werkszeit-Stunden landen nicht nur im Lohn, sondern oft gleichzeitig in der Kundenrechnung (Stundennachweis → Position auf Rechnung, kern/05 + handwerk/05).
Zweites Mapping pro Zeitart:
// Erweiterung von time_arts (§3.1)fakturaArtikelHint: uuid('faktura_artikel_hint'), // Standard-Artikel beim WeiterberechnenfakturaStundensatzMultiplikator: numeric('faktura_stundensatz_multiplikator', { precision: 4, scale: 3 }) .notNull().default('1.000'), // Tenant kann z.B. fuer 'reise_aktiv' Multiplikator 0.5 setzen, weil Kunden nur halbe Reisezeit zahlenistWeiterberechenbar: boolean('ist_weiterberechenbar').notNull().default(true),Und Zuschläge können separat weiterberechnet werden oder nicht:
// Erweiterung von lohn_zuschlag_regeln (§7a.2)istKundenWeiterberechenbar: boolean('ist_kunden_weiterberechenbar').notNull().default(false),fakturaArtikelHint: uuid('faktura_artikel_hint'),Beispiel: Notdienst SHK Sa 22:00 → So 06:00 für Kunde X. Lohn = 5 Lohnart-Zeilen wie oben. Rechnung an Kunde X = 1 Position „Notdienst-Stunden 8 h × Notdienst-Stundensatz“ (mit eingepreistem Zuschlag) oder 3 Positionen „Reguläre Stunden / Nacht-Aufschlag / Sonntags-Aufschlag“ — je nach Tenant-Konfiguration und Kunden-Vereinbarung.
7a.6 Admin-UI Skizze · Zuschlagsregel-Editor
Abschnitt betitelt „7a.6 Admin-UI Skizze · Zuschlagsregel-Editor“┌────────────────────────────────────────────────────────────────────────┐│ Einstellungen → Lohn-Zuschläge · musterbetrieb-shk + Anlegen │├────────────────────────────────────────────────────────────────────────┤│ Code Zeitfenster Tage Faktor §3b DATEV Spillover ││ nachtzeit 20:00–23:00 Mo-So +10 % ❌ 11 hart ││ nachtkernzeit 23:00–06:00 Mo-So +25 % ✅ 12 hart ││ samstag 13:00–24:00 Sa +20 % ❌ 14 hart ││ sonntag 00:00–24:00 So +50 % ✅ 9 bis Mo 04:00 ││ feiertag 00:00–24:00 alle* +125 % ✅ 10 bis Mo 04:00 │├────────────────────────────────────────────────────────────────────────┤│ Vorschau (Sa 19.04.2026 22:00 – So 20.04.2026 06:00, 8 h Arbeit): ││ Grundlohn 8.0 h × 1.00 = 8.0 h DATEV 1 ││ Nachtzeit 22-23 1.0 h × 0.10 = 0.10 h DATEV 11 ││ Nachtkernzeit 23-06 7.0 h × 0.25 = 1.75 h DATEV 12 ││ Samstag 22-24 2.0 h × 0.20 = 0.40 h DATEV 14 ││ Sonntag 00-06 6.0 h × 0.50 = 3.00 h ✅§3b DATEV 9 ││ ││ [Tarif-Profil laden ▾] [Test-Kalkulator] [DATEV-Validator] │└────────────────────────────────────────────────────────────────────────┘Tarif-Profile als Templates: „BRTV-Bau 2026“, „IG-Metall NRW 2026“, „SHK-Innung-NRW 2026“, „TVöD-V 2026“ — kuratiert von uns, jährlich aktualisiert. Tenant lädt ein Profil → bekommt das passende Regelset, kann dann individuell justieren. Versionierung über vorgaenger_regel_id macht Tarifwechsel rückwärts auditierbar.
7a.6a Dedizierter Nachtzeit-Konfigurator (User-Erweiterung 2026-05-02)
Abschnitt betitelt „7a.6a Dedizierter Nachtzeit-Konfigurator (User-Erweiterung 2026-05-02)“Treiber: „Lass mich die Nachtzeiten konfigurierbar machen, sowohl von der Zeit als auch von dem Zuschlag her.“
Technisch ist das durch §7a.2 (
lohn_zuschlag_regeln) bereits abgedeckt — Zeitfenster (startUhrzeit/endeUhrzeit) und Zuschlag (zuschlagFaktor) sind frei konfigurierbar. Der generische Regel-Editor (§7a.6) ist für Buchhaltung mit DATEV-Wissen aber zu nackt; deshalb gibt es zusätzlich einen fokussierten Nachtzeit-Editor, der dieselbe Tabelle bedient, aber für den Admin freundlich aufbereitet ist.
Beliebig viele Nachtstufen — nicht hartcodiert auf zwei. Tenant kann auch 3 Stufen anlegen (z.B. „Frühnacht 18-22 +5 %“, „Nacht 22-23 +10 %”, „Kernzeit 23-06 +25 %“) oder nur eine. Die Stufen sind einfach Einträge in lohn_zuschlag_regeln mit code startend mit nacht_*.
UI-Skizze · Nachtzeit-Editor (Web Admin → Einstellungen → Nachtzeiten):
┌────────────────────────────────────────────────────────────────────────┐│ Einstellungen → Nachtzeiten · musterbetrieb-shk [+ Stufe anlegen] │├────────────────────────────────────────────────────────────────────────┤│ ││ Stufe 1 · „Nachtzeit" [Bearbeiten] [✕] ││ ┌────────────────────────────────────────────────────────────────┐ ││ │ Bezeichnung: [ Nachtzeit ] │ ││ │ Beginn: [ 20 : 00 ] Ende: [ 23 : 00 ] │ ││ │ Wochentage: [✓Mo] [✓Di] [✓Mi] [✓Do] [✓Fr] [✓Sa] [✓So] │ ││ │ Zuschlag: [ 10 ] % Steuerfrei §3b EStG: [ ❌ ] │ ││ │ DATEV-Schlüssel:[ 11 ] Tarif-Referenz: [ TV-SHK §6 ]│ ││ └────────────────────────────────────────────────────────────────┘ ││ ││ Stufe 2 · „Nachtkernzeit" [Bearbeiten] [✕] ││ ┌────────────────────────────────────────────────────────────────┐ ││ │ Bezeichnung: [ Nachtkernzeit ] │ ││ │ Beginn: [ 23 : 00 ] Ende: [ 06 : 00 ] │ ││ │ Wochentage: [✓Mo] [✓Di] [✓Mi] [✓Do] [✓Fr] [✓Sa] [✓So] │ ││ │ Zuschlag: [ 25 ] % Steuerfrei §3b EStG: [ ✅ ] │ ││ │ DATEV-Schlüssel:[ 12 ] Tarif-Referenz: [ TV-SHK §6 ]│ ││ │ ⓘ §3b EStG-Cap: nur bis 50 €/h Stundenlohn steuerfrei. │ ││ └────────────────────────────────────────────────────────────────┘ ││ ││ Vorschau: Sa 19.04.2026 22:00 → So 20.04.2026 06:00 (8 h Arbeit) ││ ┌────────────────────────────────────────────────────────────────┐ ││ │ Stufe Anteil ×Faktor Stunden DATEV Steuerfrei │ ││ │ Nachtzeit 1.0 h ×0.10 0.10 h 11 ❌ │ ││ │ Nachtkernz. 7.0 h ×0.25 1.75 h 12 ✅ (bis 50 €/h) │ ││ └────────────────────────────────────────────────────────────────┘ ││ ││ [Tarif-Profil laden ▾] [Speichern als neue Version (Tarif-Audit)] │└────────────────────────────────────────────────────────────────────────┘Wichtige Editor-Eigenschaften:
- Time-Picker mit 5-min-Raster für Beginn/Ende. Über-Mitternacht-Eingabe (z.B. 23:00–06:00) automatisch erkannt → setzt
ueberschreitet_mitternacht = true. - Wochentag-Toggles statt Bitfeld — UI-friendly, schreibt das Bitfeld in DB.
- Zuschlag in % statt Faktor 0.250 — interner Faktor wird daraus berechnet.
- §3b-EStG-Hinweis automatisch eingeblendet, wenn Stufe ≥ 25 % (ArbZG-Mindest-Zuschlag) UND
steuerfreiNach3bEstg = true— mit Cap-Warnung („nur bis 50 €/h Stundenlohn“). - Live-Vorschau mit konfigurierbarem Test-Zeitraum (Default: kommendes Wochenende mit Sa-Nacht-Schicht), zeigt sofort die Auswirkung jeder Änderung auf den DATEV-Export.
- Speichern erzeugt neue Version der Regel (
vorgaenger_regel_id,gueltig_ab=heute+1d); alte Version bleibt für historische Monate gültig. Damit ist jede Tarif-/Zuschlagsänderung rückwärts auditierbar und betrifft keine bereits abgeschlossenen Lohnläufe. - „Stufe anlegen“ öffnet leere Maske mit Code-Vorschlag
nacht_<n+1>— beliebig viele Stufen möglich. - Validierung: Stufen dürfen sich überlappen (Default-Stack); wenn der Tenant Exklusivität will, setzt er das im Advanced-Panel über
exklusiv_mit_codes.
Validierungs-Regeln (serverseitig, beim Speichern):
| Check | Reaktion |
|---|---|
| Faktor < 0 oder > 5.0 | 422 — „Zuschlag muss zwischen 0 % und 500 % liegen“ |
endeUhrzeit == startUhrzeit (Null-Fenster) |
422 — „Zeitfenster muss > 0 min sein“ |
| Wochentage = 0 (kein Tag aktiv) | 422 — „Mindestens ein Wochentag muss aktiv sein“ |
steuerfreiNach3bEstg=true aber Faktor < 0.25 für Nacht |
Warnung — „§3b EStG verlangt min 25 % für Steuerfreiheit“ (kein Block) |
datevLohnartSchluessel nicht im Jahrgang 2026 |
422 — Auswahl aus Lookup-Tabelle |
| Beginn-Ende identisch wie bestehende aktive Regel im selben Tenant | 422 — „Regel bereits vorhanden, bitte bearbeiten“ |
Mobile-Sicht: der Mitarbeiter sieht die Nacht-Stufen nicht als Konfiguration, aber bei laufender Schicht erscheint ein dezenter Hinweis im Verlauf:
22:00 Start Arbeit · Notdienst Müllerstr. 14 🌙 Nachtzeit aktiv — +10 %23:00 ⓘ Nachtkernzeit ab jetzt — +25 % (steuerfrei §3b)Das schafft Transparenz und reduziert „warum verdiene ich heute mehr?“-Rückfragen.
7a.7 Test-Plan-Erweiterung (Zuschläge)
Abschnitt betitelt „7a.7 Test-Plan-Erweiterung (Zuschläge)“| ID | Typ | Inhalt |
|---|---|---|
| U-10 | Unit | intersectFenster(periode, regel) für Periode über Mitternacht + Wochentagswechsel |
| U-11 | Unit | spillover('tarif_4_uhr_morgens', So-23:00→Mo-05:00) → Sonntags-Anteil bis 04:00 Mo, Rest Grundlohn |
| U-12 | Unit | spillover('kein_zuschlag_montags', So-22:00→Mo-06:00) → Sonntags-Zuschlag nur 22:00–24:00 |
| I-14 | Integration | Tenant-Wechsel der Zuschlagsregel rückwirkend auf historischen Monatsabschluss → Export bleibt stabil (Versionierung greift) |
| C-11 | Compliance | §3b-EStG-Stundenlohn-Cap 50 € (steuerfrei nur bis 50 €/h) im LODAS-Writer |
| C-12 | Compliance | DATEV-LODAS-Validator akzeptiert alle Default-Schlüsselzahlen für SKR03 + SKR04 |
7a.8 Migrations-Reihenfolge (ergänzt)
Abschnitt betitelt „7a.8 Migrations-Reihenfolge (ergänzt)“0055: lohn_zuschlag_regeln + Default-Seed-Regelwerk pro Tenant + Tarif-Profil-Bibliothek (tarif_profile).
0056: Erweiterungen time_arts.faktura_* + lohn_zuschlag_regeln.ist_kunden_weiterberechenbar.
Diese folgen nach 0054 (Single-Active-Constraint), weil sie nur den Aggregator (kern/10 LODAS-Writer + kern/05 Faktura) betreffen, nicht den Stempel-Pfad.
11. Pausen-Regelwerk (User-Erweiterung 2026-05-02)
Abschnitt betitelt „11. Pausen-Regelwerk (User-Erweiterung 2026-05-02)“Treiber: drei Pausen-Themen aus dem User-Feedback:
- Raucherpause — manchmal als Pause gewertet, manchmal nicht.
- Bezahlte vs. unbezahlte Pause — z.B. „erste 30 min bezahlt, Rest unbezahlt“.
- Implizite Pause durch Aus-/Wieder-Einstempeln — User stoppt um 12:00, startet um 13:00 wieder. Ist die Lücke automatisch eine Pause?
11.1 Rechtsrahmen (knapp)
Abschnitt betitelt „11.1 Rechtsrahmen (knapp)“- ArbZG §4 Pflicht: ≥ 6 h Arbeit → ≥ 30 min Pause; ≥ 9 h → ≥ 45 min. Pausen-Stück ≥ 15 min sonst zählt es nicht als ArbZG-Pause (Splittung erlaubt: 2 × 15 min ist OK, 3 × 10 min ist es nicht).
- ArbZG §4 Satz 2 Pausen sind grundsätzlich unbezahlt, sofern nicht Arbeitsvertrag/Tarif anders. BRTV-Bau §3 Nr. 1.41: 30 min unbezahlte Pause Standard.
- Raucherpause: kein gesetzlicher Anspruch auf Bezahlung (BAG 12.04.1973 - 5 AZR 86/72; LAG Nürnberg 2010 - 5 Sa 17/10). Arbeitgeber darf Raucherpause als unbezahlte Pause anrechnen oder im Sinne von Toleranz unangerechnet lassen — Tenant-Konfiguration nötig, kein Default-Recht.
- Maximal-Pause-Länge: ArbZG kennt keine Obergrenze, aber tariflich oft (z.B. „Pausen > 90 min splitten den Arbeitstag“ → kann arbeitsrechtlich Schichtteilung auslösen).
11.2 Datenmodell-Erweiterung
Abschnitt betitelt „11.2 Datenmodell-Erweiterung“Drei neue Felder pro Zeitart (für kategorie='pause'-Einträge):
// Erweiterung von time_arts (§3.1)bezahltBisMinuten: integer('bezahlt_bis_minuten'), // NULL = komplett unbezahlt (Default fuer 'pause'); // 30 = erste 30 min bezahlt, alles darueber unbezahlt; // 9999 = komplett bezahlt (z.B. 'pause_bezahlt_betrieblich')mindestdauerMinuten: integer('mindestdauer_minuten').default(0), // < dieser Dauer wird die Pause NICHT als ArbZG-Pause gezaehlt (Default 15 fuer ArbZG-konforme Pause)zaehltAlsArbzgPause: boolean('zaehlt_als_arbzg_pause').default(true), // false fuer Raucherpause (zaehlt nicht gegen 30/45-min-Pflicht), true fuer regulaere PausePlus eine Tenant-globale Konfiguration (kein neues Schema, in tenant_security_policies oder neuer tenant_zeit_policy-Tabelle):
implizitePauseSchwelleMinuten: integer().default(30), // Lueckezwischen Stop und naechstem Start, ab der Werkszeit eine "implizite Pause" annimmt. // 0 = keine implizite Pausen-Erkennung (alle Luecken bleiben "Feierabend / Stempel-Luecke")implizitePauseMaxMinuten: integer().default(120), // Lueckenlaenge, bis zu der noch "Pause" angenommen wird; daruber = "Schichtende, neuer Tag"11.3 Default-Katalog-Erweiterung
Abschnitt betitelt „11.3 Default-Katalog-Erweiterung“Drei neue Pausen-Zeitarten im Default-Seed:
| Code | Label | bezahltBis | mindestdauer | zaehltAlsArbzgPause | Vergütg.-Faktor |
|---|---|---|---|---|---|
pause |
Pause (unbezahlt) | NULL | 15 | ✅ | 0.000 |
pause_bezahlt_30 |
Pause bezahlt (erste 30) | 30 | 15 | ✅ | 1.000 (auf bezahltem Anteil) |
raucherpause |
Raucherpause | NULL | 0 | ❌ | 0.000 |
Tenant kann die Defaults überschreiben: ein Maler-Betrieb mit großzügigerer Kultur setzt z.B. raucherpause.bezahltBisMinuten = 9999 und zaehltAlsArbzgPause = true.
11.4 Berechnungslogik
Abschnitt betitelt „11.4 Berechnungslogik“Pro Pause-Eintrag (in der Lohnaggregation, kern/10):
dauer_min = ended_at - started_at (Minuten)
bezahlte_min = min(dauer_min, art.bezahlt_bis_minuten ?? 0)unbezahlte_min = dauer_min - bezahlte_min
arbzg_anrechenbar_min = ( art.zaehlt_als_arbzg_pause AND dauer_min >= art.mindestdauer_minuten) ? dauer_min : 0arbzg_anrechenbar_min fließt in die ArbZG-Pflichtpause-Prüfung (kern/02). Eine 10-min-Raucherpause hat arbzg_anrechenbar_min = 0 → der Mitarbeiter braucht trotzdem noch 30 min ArbZG-Pause ab 6 h.
bezahlte_min × Stundensatz fließt regulär in den Lohn (DATEV-Lohnart 1 oder eine separate „Pause bezahlt“-Lohnart, je nach Tenant-Mapping).
11.5 Implizite Pause durch Aus-/Wieder-Einstempeln
Abschnitt betitelt „11.5 Implizite Pause durch Aus-/Wieder-Einstempeln“Regel: wenn ein User um t1 stoppt und um t2 (mit t2 - t1 ≤ implizitePauseMaxMinuten) wieder startet, erzeugt das System kein zusätzliches time_entry (kein „Phantom-Pause-Insert“), aber:
- Die Lücke wird im UI-Verlauf als „⏸ Stempel-Lücke 60 min“ ausgewiesen.
- Im DATEV-Aggregator zählt sie als unbezahlte Lücke (= keine Lohnposition).
- Im ArbZG-Check (kern/02): wenn
t2 - t1 ≥ tenant_zeit_policy.implizite_pause_schwelle_minuten→ wird als ArbZG-Pause angerechnet (Default 30 min Schwelle = sicher ≥ 15-min-Stück-Mindestdauer).
Warum kein Phantom-Insert? Append-Only-Hash-Chain. Ein Server-generierter Pause-Eintrag bräuchte Idempotency, eigene source (z.B. 'inferred'), und würde EC-04-artige Fragen aufwerfen (“wer hat das eingetragen?”). Stattdessen ist die Lücke ein berechnetes Feld in der View time_periods_mv (§3.3) — flüchtig, jederzeit aus den Roh-Entries reproduzierbar, GoBD-kompatibel.
Audit-Konsequenz: der Manager-Report „offene Tage“ (kern/01 §10.2 letzter Absatz) muss die implizite Pause zeigen. UI-Skizze:
Mo 19.04.2026 — Hannes Krüger 07:02 Start Arbeit · Lehrer Allee 7 12:00 Stop Arbeit ⏸ Stempel-Lücke 60 min (gilt als ArbZG-Pause) 13:00 Start Arbeit · Lehrer Allee 7 16:42 Stop Arbeit Σ Arbeit: 8:40 h · ArbZG-Pause: 60 min ✅11.6 Raucherpause — UX-Hebel
Abschnitt betitelt „11.6 Raucherpause — UX-Hebel“Damit Mitarbeiter ehrlich Raucherpause stempeln (statt einfach durchzustempeln), muss sie einfacher sein als „Pause regulär“:
- Ein zweiter Quick-Action-Button neben „Pause“: „🚬 Raucherpause“ (nur sichtbar, wenn die Tenant-Zeitart
raucherpauseaktiv ist). - Auto-Stop nach 5 min mit Push-Reminder „Wieder einstempeln?“ — verhindert vergessene Beendigung, die zur stundenlangen Lücke wird.
- Tenant-Schalter „Raucherpausen aktivieren“ — viele Betriebe wollen sie schlicht nicht modellieren, Default = aus.
11.7 Test-Plan-Erweiterung (Pausen)
Abschnitt betitelt „11.7 Test-Plan-Erweiterung (Pausen)“| ID | Typ | Inhalt |
|---|---|---|
| U-07 | Unit | berechnePauseLohn(dauer=45min, art.bezahlt_bis=30) → {bezahlt: 30, unbezahlt: 15} |
| U-08 | Unit | arbzgPauseAnrechnung(dauer=10min, art.mindestdauer=15) → 0 |
| U-09 | Unit | arbzgPauseAnrechnung(dauer=10min, art.zaehlt_als_arbzg_pause=false) → 0 (Raucher) |
| I-11 | Integration | User stempelt Pause dauer=45min mit art=pause_bezahlt_30 → DATEV-Aggregat: 0.5h Lohn + 0.25h unbezahlt |
| I-12 | Integration | User stoppt 12:00, startet 13:00 → keine zusätzliche Zeile in DB; View time_periods_mv zeigt 60-min-Lücke; ArbZG-Aggregat zählt 60 min Pause |
| I-13 | Integration | User stoppt 12:00, startet 15:00 (3h > implizite_pause_max_minuten=120) → Lücke zählt nicht als Pause; gilt als „Schicht-Ende“; ArbZG bekommt 0 min Pause |
| C-09 | Compliance | ArbZG-Check für 8h-Tag mit nur Raucherpausen → Flag pause_verkuerzt = true, weil zaehlt_als_arbzg_pause = false |
| C-10 | Compliance | Tarif-Edge-Case BRTV-Bau: pause mit 30 min komplett unbezahlt; Property-Test über 1.000 zufälliger Tagesketten |
| E-07 | E2E | Mobile: Raucherpause-Button, Auto-Stop-Reminder nach 5 min |
11.8 Migrations-Reihenfolge (ergänzt)
Abschnitt betitelt „11.8 Migrations-Reihenfolge (ergänzt)“0051 enthält bereits die neuen Pause-Felder (bezahlt_bis_minuten, mindestdauer_minuten, zaehlt_als_arbzg_pause) — additiv ohne Default-Bruch. Die Tenant-Policy-Felder (implizite_pause_*) gehen in eine neue Tabelle tenant_zeit_policy (oder fügen sich an tenant_security_policies an, wenn dort Platz ist).
12. Offene Annahmen / Klärung mit User
Abschnitt betitelt „12. Offene Annahmen / Klärung mit User“| # | Annahme | Was wir brauchen |
|---|---|---|
| A-01 | Default-Vergütungsfaktor für passive Reise = 0.5 (BRTV-Bau-Standard). | Bestätigung oder branchenspezifischer Default; pro Tenant ohnehin überschreibbar. |
| A-02 | Rufbereitschaft zählt nicht als ArbZG-Arbeitszeit (Default ist_arbeitszeit_arbzg = false). |
Korrekt nach EuGH C-518/15 Matzak nur wenn Zeit „nicht ohne Erheblichkeit eingeschränkt“; Tenant-Konfiguration nötig. |
| A-03 | Auto-Stop bekommt source = 'auto_switch' als sichtbares Feld im Audit-Log + Mitarbeiter-Verlauf. |
OK — oder lieber „transparent“ im Verlauf als „stop“ ohne Sonder-Markierung? Empfehlung: sichtbar, weil Mitarbeiter sonst „wer hat mich ausgestempelt?“ fragen. |
| A-04 | closed_at aus Hash-Chain-Kanonisierung ausnehmen ist sauber, weil GoBD nur Manipulationsfreiheit der Lohn-relevanten Felder verlangt. |
Legal-Review (kurz) vor Migration 0052. |
| A-05 | period_group_id ist nur für UI/Performance-Convenience, nicht für GoBD-Beweisführung. |
Korrekt? Ja, weil die Zuordnung start↔stop auch ohne period_group_id aus Reihenfolge + Hash-Chain rekonstruierbar bleibt. |
13. Senior-Empfehlung (Build-Sequenz)
Abschnitt betitelt „13. Senior-Empfehlung (Build-Sequenz)“- 0051 + Backfill + Default-Katalog-Seeder — read-only-Risiko, sofort releasebar.
- Admin-Editor
Zeitarten-Stammdaten— gibt Tenants ein Werkzeug, bevor die Server-Logik strenger wird. - 0052 (
closed_at-Felder + Backfill) — Voraussetzung für die nachfolgenden Schritte. - Route-Logik §5.2 (Auto-Stop, Stop-all, advisory_xact_lock) + UI Wechsel-Button — der eigentliche Bug-Fix.
- 0053 (DROP
time_artFreitext) — nach 7 Tagen Beobachtung in Prod. - 0054 (Unique-Partial-Index) — letzte Belt-and-Suspenders-Sicherung.
- DATEV-Mapping-Editor in
kern/10antime_arts.datev_lohnart_schluesselkoppeln — beendet das heutige Doppel-Mapping per Freitext.
14. Admin-Konfiguration
Abschnitt betitelt „14. Admin-Konfiguration“Die zugehörigen Web-Admin-Editoren sind in §8.2 (Zeitarten-Editor), §7a.6 (Zuschlagsregel-Editor) und §7a.6a (Nachtzeit-Konfigurator) als UI-Skizzen ausgearbeitet. Diese Sektion fasst die F-A-* Feature-IDs konsolidiert zusammen — Admin-Modul-Stub-URL: /admin/kern/zeitarten-lohnflex/.
- F-A-01 — Zeitarten-Katalog (CRUD + Versionierung). Anlage neuer Zeitarten mit
kategorie,steuerliche_behandlung,zuschlag_basis,default_lohnart. Editierbare Felder im aktiven Status:label,tarif_referenz,datev_kostenstelle_hint. Steuer-/Lohn-relevante Felder nur perPOST /version(neuer effective_from). Closure-Lock blockt jede Mutation auf abgeschlossenen Perioden (HTTP 409TIME-ART-LOCKED-BY-CLOSURE). - F-A-02 — Zuschlagsregeln verwalten. Editor für
lohn_zuschlag_regelnmit Reihenfolge (Stack/Override proprio), Wochentags-/Uhrzeit-Filter, Faktor in % (Lohn-Achse) sowie zweite Faktura-Achse (Kunden-Rechnung). Trockenlauf-Button: “Vorschau für letzten abgeschlossenen Monat”. - F-A-03 — Nachtzeit-Konfigurator. Tarif-abweichende Nachtzeit-Grenzen (z.B. BRTV-Bau 20:00–06:00 vs. ArbZG 23:00–06:00), Default vs. tarifvertrags-spezifische Override, Hinweis-Toast bei Konflikt mit ArbZG-Definition.
- F-A-04 — Pausen-Regelwerk. Auswahl
Auto-Insertvs.Auto-Abzugpro Tarifart, Schwellen (>6h/>9h Pflichtpausen), BetrVG-Zustimmungs-Pflicht beim Aktivieren der Auto-Abzug-Variante (siehe §11). - F-A-05 — Tarif-Template-Katalog. Ein-Klick-Seed pro Tarifvertrag (BRTV-Bau, TV-Metall NRW, Tarif-frei). Befüllt Zeitarten + Zuschlagsregeln + Nachtzeit + Pausen passend, Override pro Feld bleibt möglich.
- F-A-06 — Lohnzettel-Was-wäre-wenn. Simulation für offene Periode: “Wenn Tarif X aktiv wäre, wie sähe der Lohnzettel von Mitarbeiter Y aus?”. Nur lesend, keine Buchung.
- F-A-07 — Closure-Lock-Statusanzeige. Tabelle aller Zeitarten/Regeln mit
is_locked,locked_until_period, Erklärtext warum eine Mutation gerade blockt. Verlinkt in den Periodenabschluss-Editor. - F-A-08 — DATEV-Kostenstellen-Mapping. Pro Zeitart Zuordnung Kostenstelle/Kostenträger für den DATEV-Lohn-Export, Validierung gegen DATEV-Stamm (siehe
kern/10).
Stop-the-Bus-Trigger:
- I-09 (Concurrent-Race-Test) flakey → Migration 0054 verschieben, Lock-Strategie überdenken.
- C-01-ext (Hash-Chain bricht durch
closed_at) → 0052 sofort rückwärts, Kanonisierung re-auditen. - DATEV-LODAS-Validator (C-08) lehnt Schlüsselzahlen ab → Mapping-Editor
kern/10 §F-W-03ist Pflicht-Vorbedingung, nicht optional.
Addendum-Stand: 2026-05-02 · ergänzt kern/01-zeiterfassung.md v0.1 · zur Review durch User vor Implementierung