feat(hrworks): Abwesenheiten taeglich aus HRworks uebernehmen

Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt
sie einmal taeglich und bucht sie auf das nicht abrechenbare Projekt
"Abwesenheiten". Nach HRworks geht nichts zurueck, es wird nur gelesen.

Die Stunden rechnet HRworks selbst: available-working-hours liefert je
Person und Tag das Soll und die verfuegbaren Stunden, die Differenz sind
die Abwesenheitsstunden. Damit stimmen Teilzeit und halbe Tage ohne
eigenen Kalender. relatedEvents nennt den Auslöser; Vorrang hat der
Feiertag, dann Krankheit, dann Abwesenheit.

Abgesprochene Regeln: nur genehmigte Abwesenheiten (validatedOk), bei
Krankmeldungen auch offene Bescheinigungen; nur bis heute buchen, der
Rest waechst taeglich nach; halbe Tage feste 4 Stunden; jeder aktive
Mitarbeiter wird dem Projekt zugewiesen, auch neue.

Jede Buchung traegt hrworks_ref = "<Nummer>@<Tag>" (unique). Ein zweiter
Lauf aendert nichts, Stornos und Verschiebungen verschwinden im naechsten
Lauf, DELETE /api/hrworks/entries entfernt genau diese Buchungen.

Nicht abrechenbare Projekte brauchen keinen Budgettopf mehr. Ein Topf
traegt Volumen, Laufzeit und Satz - das hat nur bei Billable und
Festpreis Sinn. Am Abwesenheitsprojekt war er nur ein Ablaufdatum, das
irgendwann Buchungen blockiert haette.

Taeglicher Lauf ueber systemd (perinco-pulse-hrworks.timer, 5:30 Uhr
Berliner Zeit, Persistent). Bedienung unter Einstellungen ->
Abwesenheiten (HRworks) mit Verbindungstest und Probelauf; der geheime
Schluessel liegt AES-verschluesselt in der Datenbank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 14:50:54 +02:00
co-authored by Claude Opus 5
parent 994f39daff
commit 532b15ce99
18 changed files with 1236 additions and 6 deletions
+192
View File
@@ -0,0 +1,192 @@
/*
* Zugriff auf die HRworks-API v2 (https://api.hrworks.de/v2).
*
* Anmeldung: accessKey und secretAccessKey gegen /authentication tauschen, das
* ergibt ein JWT mit 15 Minuten Laufzeit. Das Token wird hier gehalten und
* kurz vor Ablauf erneuert - ein Sync macht hunderte Aufrufe, und fuer jeden
* neu anzumelden waere unnoetig.
*
* Es wird ausschliesslich gelesen. Pulse schreibt nichts nach HRworks zurueck.
*/
const BASE = 'https://api.hrworks.de/v2';
const USER_AGENT = 'perinco Pulse (Abwesenheiten-Abgleich)';
/** Token vorsorglich eine Minute vor Ablauf erneuern. */
const RENEW_BEFORE_MS = 60_000;
export class HrworksError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
/** Ein Mandant, an dem die Zugangsdaten haengen. */
export class Hrworks {
constructor({ access_key, secret_access_key }) {
this.accessKey = access_key;
this.secret = secret_access_key;
this.token = null;
this.expires = 0;
}
async authenticate() {
if (this.token && Date.now() < this.expires - RENEW_BEFORE_MS) return this.token;
if (!this.accessKey || !this.secret) throw new HrworksError('HRworks: Zugangsdaten fehlen.', 400);
const res = await fetch(`${BASE}/authentication`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
body: JSON.stringify({ accessKey: this.accessKey, secretAccessKey: this.secret }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new HrworksError(
res.status === 403 ? 'HRworks: Zugangsdaten wurden abgelehnt.' : `HRworks-Anmeldung fehlgeschlagen (HTTP ${res.status}). ${text.slice(0, 120)}`,
res.status
);
}
const { token } = await res.json();
this.token = token;
// Ablauf aus dem Token lesen; ohne lesbares exp vorsichtig 10 Minuten annehmen.
let exp = 0;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
exp = Number(payload.exp) * 1000;
} catch {
/* egal */
}
this.expires = exp || Date.now() + 10 * 60_000;
return this.token;
}
/** GET mit Wiederholung bei abgelaufenem Token. */
async get(path, params = {}, retry = true) {
const token = await this.authenticate();
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) value.forEach((v) => query.append(key, String(v)));
else query.append(key, String(value));
}
const url = `${BASE}${path}${query.size ? `?${query}` : ''}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, 'User-Agent': USER_AGENT } });
if (res.status === 401 && retry) {
this.token = null;
return this.get(path, params, false);
}
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const body = await res.json();
message = body.errorMessage || body.message || message;
} catch {
/* Text statt JSON */
}
throw new HrworksError(`HRworks ${path}: ${message}`, res.status);
}
return res.json();
}
/* --------------------------------------------------------- Stammdaten -- */
/** Alle Personen mit Benutzername und Mailadresse. */
async persons() {
const data = await this.get('/persons/master-data');
return (data.persons || []).map((p) => ({
person_id: p.personId,
personnel_number: p.personnelNumber ?? null,
email: (p.email || '').toLowerCase() || null,
first_name: p.firstName ?? '',
last_name: p.lastName ?? '',
name: `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim(),
}));
}
/** Abwesenheitsarten: Schluessel -> Name. */
async absenceTypes() {
const data = await this.get('/absences/absence-types', { onlyActive: false });
const list = data.absenceTypes || Object.values(data)[0] || [];
return list.map((t) => ({ key: String(t.key), name: t.name, active: t.isActive !== false }));
}
async sickLeaveTypes() {
const data = await this.get('/sick-leaves/sick-leave-types', { onlyActive: false });
const list = data.sickLeaveTypes || Object.values(data)[0] || [];
return list.map((t) => ({ key: String(t.key), name: t.name, active: t.isActive !== false }));
}
/* ------------------------------------------------------- Abwesenheiten -- */
/**
* Abwesenheiten im Zeitraum, flach je Person.
* Antwort von HRworks: { "BenutzerName": [{ beginDate, endDate, absences: [...] }] }
*/
async absences(from, to) {
const data = await this.get('/absences', { beginDate: from, endDate: to });
return flatten(data, 'absences');
}
async sickLeaves(from, to) {
const data = await this.get('/sick-leaves', { beginDate: from, endDate: to });
return flatten(data, 'sickLeaves');
}
/**
* Arbeitszeiten je Person und Tag.
*
* Hier kommt die eigentliche Rechenarbeit her: `regularWorkingHours` ist das
* Soll des Tages, `workingHours` das, was nach Abzug von Abwesenheit,
* Krankheit und Feiertag uebrig bleibt. Die Differenz sind die
* Abwesenheitsstunden - inklusive Teilzeit und halber Tage, ohne dass Pulse
* einen eigenen Kalender braucht. `relatedEvents` nennt den Auslöser.
*
* HRworks erlaubt bei interval=days hoechstens 31 Tage je Aufruf.
*/
async days(from, to, persons) {
const data = await this.get('/persons/available-working-hours', {
beginDate: from,
endDate: to,
interval: 'days',
persons,
});
const out = [];
for (const [person, entries] of Object.entries(data)) {
for (const day of entries || []) {
const events = day.relatedEvents || {};
out.push({
person,
date: day.beginDate,
regular_hours: Number(day.regularWorkingHours || 0),
available_hours: Number(day.workingHours || 0),
absences: (events.absences || []).map((e) => e?.id).filter(Boolean),
sick_leaves: (events.sickLeaves || []).map((e) => e?.id).filter(Boolean),
// Feiertage tragen keine id, nur das Vorhandensein zaehlt.
holiday: Array.isArray(events.holidays) && events.holidays.length > 0,
});
}
}
return out;
}
}
/** { person: [{ absences: [...] }] } -> [{ person, ...absence }] */
function flatten(data, key) {
const out = [];
for (const [person, intervals] of Object.entries(data || {})) {
for (const interval of intervals || []) {
for (const item of interval[key] || []) out.push({ person, ...item });
}
}
return out;
}
/** Status, die als verbindlich gelten (Absprache mit dem Auftraggeber). */
export const ABSENCE_STATES = ['validatedOk'];
export const SICK_STATES = [
'validatedOk',
'reported',
'certificatePending',
'electronicCertificatePending',
'electronicCertificateAvailable',
'electronicCertificateRequestSent',
];