@devolta/rostersolver-sdk
v0.4.0
Published
Client für den RosterSolver — prüft und plant Dienstpläne nach ArG, L-GAV Gastgewerbe, AZG und ArbZG (CH, AT, DE).
Downloads
644
Maintainers
Readme
@devolta/rostersolver-sdk
Client für den RosterSolver — den Dienst, der Dienstpläne prüft (check)
und plant (generate). Länder CH, AT und DE.
- Keine Laufzeitabhängigkeiten, nur das globale
fetchvon Node 18+ - Vollständige TypeScript-Typen für Anfrage und Antwort
- Geprüft wird nach Arbeitsgesetz und L-GAV Gastgewerbe: Tagesspanne, Ruhetage, Feiertage, freie Sonntage, Nachtarbeit, Überzeit
- Fehlerklassen statt Zeichenkettenvergleiche
- Zeitgrenzen nach Spezifikation: 5 s für
check, 20 s fürgenerate safeSolve()für den Rückfallpfad, der nie wirft
npm install @devolta/rostersolver-sdkLoslegen
import { RosterSolverClient } from '@devolta/rostersolver-sdk'
const solver = new RosterSolverClient({
baseUrl: process.env.ROSTERSOLVER_URL ?? 'https://rostersolver.devolta.de',
apiKey: process.env.ROSTERSOLVER_KEY!,
})
const plan = await solver.generate({
timezone: 'Europe/Zurich',
rule_pack: {
country_code: 'CH',
pack_id: 'CH_GASTRO_LGAV',
business_type: 'seasonal', // 42 / 43,5 / 45 Stunden
},
// Ohne planning_window hält der Dienst jede Woche für angeschnitten, in der
// niemand am Sonntag arbeitet — Ruhetage und freie Sonntage werden dann
// nicht geprüft.
planning_window: { starts_on: '2026-09-14', ends_on: '2026-09-27' },
holidays: [{ holiday_date: '2026-09-21', name: 'Bettag' }],
employees: [
{
id: 'a1b2…',
is_minor: false,
employment_status: 'active',
weekly_hours: 43.5,
has_family_duties: true,
cost_per_minute: 0.5,
home_restaurant_id: 'r1',
qualifications: [{ qualification_id: 'service', level: 'senior', level_rank: 3 }],
// Was vor dem Zeitraum liegt. Ohne diese Angaben beginnt jede Anfrage
// bei null: der Solver sieht nicht, dass gestern bis Mitternacht
// gearbeitet wurde.
history: {
consecutive_work_days_before: 3,
last_shift_end: '2026-09-13T23:30:00+02:00',
free_sundays_ytd: 2,
overtime_balance_minutes: 4200,
},
},
],
shifts: [
{
id: 's1',
restaurant_id: 'r1',
shift_date: '2026-09-14',
starts_at: '2026-09-14T09:00:00+02:00',
ends_at: '2026-09-14T17:00:00+02:00',
break_minutes: 30,
headcount: 1,
requirements: [
{ qualification_id: 'service', min_level_rank: 1, headcount: 1, severity: 'hard' },
],
},
],
objective: { weight_coverage: 100, weight_wishes: 60 },
})
for (const assignment of plan.assignments) {
console.log(assignment.shift_id, '→', assignment.employee_id, assignment.score)
for (const reason of assignment.reasons) console.log(' ', reason.code, reason.detail)
}mode und contract_version setzt der Client selbst.
Jugendliche brauchen zusätzlich minor_age_band: bis 16 Jahren endet der
Arbeitstag um 20 Uhr, darüber um 22 Uhr (ArG Art. 31 Abs. 2).
Was geprüft wird
Über die fünf Grundwerte — Tages- und Wochenarbeitszeit, Ruhezeiten, Pausen, Arbeitstage in Folge — hinaus: Tagesspanne von 14 Stunden, herabgesetzte Ruhezeit, Nachtschichtgrenzen, zwei Ruhetage pro Woche mit einem ganzen darunter, ein freier Sonntag innert zweier Wochen, Feiertage als Sonntage, Endzeiten für Jugendliche, Überzeit und Überstundensaldo.
Regelpakete
| pack_id | Grundlage |
| --- | --- |
| CH_ARG | Arbeitsgesetz, Jugendliche nach ArGV 5 |
| CH_GASTRO_LGAV | L-GAV Gastgewerbe Art. 15/16 auf dem ArG |
| AT_AZG | AZG / ARG, Jugendliche nach KJBG |
| DE_ARBZG | ArbZG, Jugendliche nach JArbSchG |
Ohne pack_id gilt das gesetzliche Grundpaket des Landes. business_type
gilt nur beim L-GAV.
Jeder Verstoss trägt seine Fundstelle:
for (const v of plan.violations) {
console.log(v.code, v.legal_reference, v.message)
// MISSING_REST_DAYS L-GAV Art. 16 Ziff. 1 Nur 1 statt 2 Ruhetage in der Woche
// MAX_WORK_SPAN ArG Art. 10 Abs. 3 Arbeitsspanne zu lang: 900 statt …
}Der Rückfallpfad
Ein Ausfall des Solvers darf die Schichtplanung nie blockieren — so steht es in
der Spezifikation, und safeSolve() bildet genau das ab. Es wirft nicht,
sondern meldet, dass zurückgefallen werden soll:
const result = await solver.safeSolve({ mode: 'check', ...payload })
if (result.ok) {
await speichereEntwurf(result.response)
} else {
// Zeitüberschreitung, 503, Netzwerkfehler, Schemafehler — alles landet hier.
console.warn('RosterSolver nicht verfügbar:', result.error.code, result.error.message)
await pruefeMitEingebauterRegelpruefung(payload)
}Was offenbleibt, und warum
unfilled[] ist der aussagekräftigste Teil der Antwort: er sagt, ob
eingestellt, umgeplant oder eine Qualifikation nachgeschult werden muss.
for (const gap of plan.unfilled) {
console.log(`${gap.shift_id}: ${gap.missing_headcount} offen (${gap.reason})`)
for (const candidate of gap.candidates_rejected) {
console.log(` ${candidate.employee_id}: ${candidate.reason} — ${candidate.detail ?? ''}`)
}
}Häufige Gründe: QUALIFICATION_MISSING, QUALIFICATION_EXPIRED,
ABSENCE_CONFLICT, YOUTH_NIGHT_WORK, MIN_DAILY_REST, ASSIGNED_ELSEWHERE.
Prüfen statt planen
const geprüft = await solver.check({
timezone: 'Europe/Zurich',
rule_pack: { country_code: 'DE', overrides: { maxWeeklyWorkMinutes: 2400 } },
planning_window: { starts_on: '2026-09-14', ends_on: '2026-09-20' },
employees,
shifts: shifts.map((s) => ({ ...s, employee_id: zuteilung[s.id] })),
})
if (!geprüft.ok) {
for (const v of geprüft.violations.filter((v) => v.severity === 'error')) {
console.error(v.code, v.message, v.legal_reference)
}
}Abweichungen (overrides) dürfen schärfen, nicht lockern. Ein Versuch,
eine gesetzliche Grenze zu lockern, endet in einem
RulePackOverrideRejectedError. Welche Schlüssel angenommen werden und in
welche Richtung sie verschoben werden dürfen, steht maschinenlesbar in
(await solver.contract()).override_keys — damit prüft die Oberfläche vorab,
statt den Fehler abzuwarten.
Beim Prüfen erfasster Zeiten sind restaurant_id, headcount und
qualifications entbehrlich: eine Stempelung hat nicht immer einen
Betriebsbezug, und geprüft wird die Zeit, nicht der Ort. shift_date ist der
fachliche Schichttag in der Betriebszeitzone — bei einer Schicht über
Mitternacht der Tag des Beginns, nicht der UTC-Kalendertag.
const zeiten = await solver.check({
timezone: 'Europe/Zurich',
rule_pack: { country_code: 'CH', pack_id: 'CH_GASTRO_LGAV' },
planning_window: { starts_on: '2026-08-01', ends_on: '2026-08-31' },
employees: erfasste.map((e) => ({ id: e.id, is_minor: e.is_minor, employment_status: 'active' })),
shifts: eintraege.map((e) => ({
id: e.id,
employee_id: e.employee_id,
shift_date: e.shift_date, // fachlicher Tag, Ortszeit
starts_at: e.started_at,
ends_at: e.ended_at, // nur abgeschlossene Einträge
break_minutes: e.break_minutes, // tatsächlich genommene Pause
})),
options: { explain: true },
})
for (const v of zeiten.violations) {
console.error(v.code, v.legal_reference, v.explain?.measure, v.actual, '>', v.limit)
}Optionen
| Option | Standard | Bedeutung |
| --- | --- | --- |
| baseUrl | — | Basis-URL des Dienstes |
| apiKey | — | Wert für X-Solver-Key |
| timeoutMs | 5 000 (check) / 20 000 (generate) | Überschreibt die Voreinstellung |
| retries | 1 | Wiederholungen — nur bei 503 und Netzwerkfehlern, nie bei 4xx |
| retryDelayMs | 250 | Grundwartezeit, verdoppelt sich je Versuch |
| fetch | global | Eigene Implementierung, etwa für einen Proxy |
| headers | {} | Zusätzliche Header, etwa zur Ablaufverfolgung |
Jeder Aufruf nimmt zusätzlich ein AbortSignal:
const controller = new AbortController()
setTimeout(() => controller.abort(), 3_000)
await solver.generate(payload, { signal: controller.signal })Fehler
Alle Fehler erben von RosterSolverError und tragen code, httpStatus,
details und requestId.
| Klasse | Code | HTTP |
| --- | --- | --- |
| InvalidRequestError | INVALID_REQUEST | 400 |
| UnsupportedContractVersionError | UNSUPPORTED_CONTRACT_VERSION | 400 |
| UnsupportedCountryError | UNSUPPORTED_COUNTRY | 400 |
| RulePackOverrideRejectedError | RULE_PACK_OVERRIDE_REJECTED | 422 |
| ProblemTooLargeError | PROBLEM_TOO_LARGE | 413 |
| RateLimitedError | RATE_LIMITED | 429 |
| UnauthorizedError | UNAUTHORIZED | 401 |
| SolverUnavailableError | SOLVER_UNAVAILABLE | 503 |
| SolverTimeoutError | TIMEOUT | — |
| SolverNetworkError | NETWORK | — |
| MalformedResponseError | MALFORMED_RESPONSE | — |
error.retryable sagt, ob ein zweiter Versuch lohnt. error.shouldFallback
ist immer true — die manuelle Planung muss in jedem Fall bedienbar bleiben.
error.retryAfterSeconds steht bei 429 und 503; der Client hält sich beim
Wiederholen daran, statt blind nach 250 ms erneut zu fragen.
Weitere Endpunkte
await solver.health() // Bereitschaft und rules_version, auch ohne gültigen Schlüssel
await solver.contract() // Modi, Länder, Pakete, alle Codes, angenommene Abweichungen
await solver.rulepacks() // Alle Regelpakete mit sämtlichen Grundwertenrulepacks() ist die Quelle für die Oberfläche: die geprüften Schwellen
anzeigen, ohne sie zu duplizieren.
const { rulepacks } = await solver.rulepacks()
const lgav = rulepacks.find((p) => p.pack_id === 'CH_GASTRO_LGAV')!
lgav.adult.restDays.daysPerWeek // 2
lgav.adult.restDays.extendedStreakRestMinutes // 4980 (83 Stunden)
lgav.adult.maxWorkSpanMinutes // 840 (14 Stunden)
lgav.adult.night.maxDailyWorkMinutes // 540 (9 Stunden)Zuschlagsfenster
payroll steht neben night, nicht darin: night ist die geprüfte
Rechtsgrenze, payroll das Fenster für den Lohnzuschlag. Sie fallen nur in
der Schweiz zusammen.
const de = rulepacks.find((p) => p.pack_id === 'DE_ARBZG')!
de.adult.night.fromHour // 20 — geprüfte Nachtarbeit
de.payroll.nightSupplementFromHour // 20 — § 3b EStG
de.payroll.sundaySupplementApplies // truePausenstaffel
const pause = await solver.breaks({ country_code: 'CH', worked_minutes: 570 })
pause.required_break_minutes // 60
pause.applied_rule // { after_minutes: 540, min_break_minutes: 60 }
pause.selection_rule // die Auswahlregel im KlartextEs gilt die höchste Stufe, deren Schwelle die Nettoarbeitszeit echt
überschreitet; Stufen kumulieren nicht. Wer overrides speichert, schickt sie
mit — sonst antwortet die Zeiterfassung mit einer anderen Staffel als die
Prüfung.
Zwischenspeicher billig halten
const erste = await solver.rulepacksCached()
// … später, mit dem gemerkten ETag:
const wieder = await solver.rulepacksCached({ etag: erste.etag })
if (wieder.notModified) {
// Der eigene Stand gilt weiter — es wurde nichts übertragen.
}rulesVersion steht in beiden Fällen dabei; sie steigt bei jeder Änderung an
den Paketen. Wer nur wissen will, ob sich etwas geändert hat, fragt
health() — das geht ohne Schlüssel und ohne Kontingent.
Regelstand für einen Ausfall
Fällt der Dienst aus, ist ein Plan ungeprüft. Pausensetzung und Kostenrechnung sollen deswegen nicht stillstehen:
import { verifySnapshot } from '@devolta/rostersolver-sdk'
const stand = await solver.rulepacksSnapshot()
if (!verifySnapshot(stand, process.env.SNAPSHOT_SIGNING_KEY!)) {
throw new Error('Regelstand nicht vertrauenswürdig')
}
// ausliefern und beim Start laden — Grundwerte, kein PrüfalgorithmusGrenzen
generate: höchstens 500 Mitarbeitende und 2000 Schichten je Anfrage.check: 2000 und 20 000 — die Prüfung rechnet ohne Modell, eine Monatsprüfung passt in einen Lauf. DarüberProblemTooLargeErrorviolations[]trägt höchstens 5000 Einträge;meta.violations_totalnennt die tatsächliche Zahl,okwird vor dem Kürzen bestimmtmeta.truncated: trueheisst: die Lösung ist gültig, aber nicht beweisbar optimal — bei grossen Anfragen der Normalfall- Regeln, die eine Vorgeschichte brauchen — Überzeit im Jahr, Überstundensaldo, freie Sonntage —, werden gemeldet, aber nicht eingeplant
assignments[]ist ein Vorschlag, keine Buchung
Entwicklung
npm install
npm run typecheck
npm test
# gegen einen laufenden Dienst
ROSTERSOLVER_URL=https://rostersolver.devolta.de \
ROSTERSOLVER_KEY=… \
npm run test:integrationLizenz
MIT © devolta UG
