Zum Inhalt springen

Feinkonzept-Addendum · kern/01a — Zeitarten-Katalog, Single-Active-Invariant & Lohn-Flexibilität

In Planung

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):

  1. „Aktive und passive Reisezeit gibt es“ — heute nicht modelliert.
  2. „Wenn ich eine andere Zeit starte, wird die eigentliche nicht beendet“ — Server lässt parallele aktive Stempel zu.
  3. „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.md definiert.


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.


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.


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_arttime_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).

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 AS
SELECT
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_h
FROM time_entries start_e
LEFT 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 TRUE
WHERE 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).


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“

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.

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 start ab. Die Auto-Stops bekommen serverseitig generierte Idempotency-Keys (deterministic: uuidv5(idempotencyKey, 'auto-stop:' + offenStartId)), damit Retry des gleichen Requests nicht zweite Auto-Stops produziert.

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 §6

Der 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 Periode
  • period_group_id wird beim kind='start' als neue UUID-v7 gesetzt; nachfolgende Pause/Stop derselben Periode tragen dieselbe ID.
  • Der kind='stop'-Insert macht im selben TX ein UPDATE auf den korrespondierenden start-Row: SET closed_at = stop.occurred_at, closed_by_entry_id = stop.id. Das verletzt nicht die Append-Only-Invariante, weil closed_at ein „operationelles“ Feld ist und die Hash-Chain (hash_self) nicht über closed_at gerechnet wird (Feld aus hash_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_id ausnehmen — sonst bricht jeder spätere Stop die Kette des dazugehörenden Starts. Wir testen das in C-01-Erweiterung explizit.


Der Punkt, den der User am stärksten betont: Flexibilität für Lohnzettel.

Pfad ab Stempelung:

  1. Mitarbeiter stempelt → time_entries mit time_art_id (z.B. reise_passiv).
  2. Hash-Chain-Trigger schreibt + verkettet.
  3. Monatsabschluss (kern/01 §F-W-06) markiert locked_at.
  4. 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'
  5. 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.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.


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).


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.

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),
}));
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-Verguetung
grundlohn_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 bilden
fuer 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-Schluessel
fuer 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 Weiterberechnen
fakturaStundensatzMultiplikator: 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 zahlen
istWeiterberechenbar: 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.

┌────────────────────────────────────────────────────────────────────────┐
│ 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.

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

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?
  • 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).

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 Pause

Plus 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"

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.

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 : 0

arbzg_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 ✅

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 raucherpause aktiv 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.
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

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).


# 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.

  1. 0051 + Backfill + Default-Katalog-Seeder — read-only-Risiko, sofort releasebar.
  2. Admin-Editor Zeitarten-Stammdaten — gibt Tenants ein Werkzeug, bevor die Server-Logik strenger wird.
  3. 0052 (closed_at-Felder + Backfill) — Voraussetzung für die nachfolgenden Schritte.
  4. Route-Logik §5.2 (Auto-Stop, Stop-all, advisory_xact_lock) + UI Wechsel-Button — der eigentliche Bug-Fix.
  5. 0053 (DROP time_art Freitext) — nach 7 Tagen Beobachtung in Prod.
  6. 0054 (Unique-Partial-Index) — letzte Belt-and-Suspenders-Sicherung.
  7. DATEV-Mapping-Editor in kern/10 an time_arts.datev_lohnart_schluessel koppeln — beendet das heutige Doppel-Mapping per Freitext.

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-01Zeitarten-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 per POST /version (neuer effective_from). Closure-Lock blockt jede Mutation auf abgeschlossenen Perioden (HTTP 409 TIME-ART-LOCKED-BY-CLOSURE).
  • F-A-02Zuschlagsregeln verwalten. Editor für lohn_zuschlag_regeln mit Reihenfolge (Stack/Override pro prio), Wochentags-/Uhrzeit-Filter, Faktor in % (Lohn-Achse) sowie zweite Faktura-Achse (Kunden-Rechnung). Trockenlauf-Button: “Vorschau für letzten abgeschlossenen Monat”.
  • F-A-03Nachtzeit-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-04Pausen-Regelwerk. Auswahl Auto-Insert vs. Auto-Abzug pro Tarifart, Schwellen (>6h/>9h Pflichtpausen), BetrVG-Zustimmungs-Pflicht beim Aktivieren der Auto-Abzug-Variante (siehe §11).
  • F-A-05Tarif-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-06Lohnzettel-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-07Closure-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-08DATEV-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-03 ist Pflicht-Vorbedingung, nicht optional.

Addendum-Stand: 2026-05-02 · ergänzt kern/01-zeiterfassung.md v0.1 · zur Review durch User vor Implementierung