Files
perinco-Pulse/server/src/lib/hrworks.js
T
schakal00783andClaude Opus 5.5 c0eb00faee feat(kalender): Kalender mit Urlauben und Feiertagen fuer alle
Neuer Menuepunkt "Kalender" fuer alle Rollen: genehmigte Urlaube aller
Mitarbeiter, auch in der Zukunft, dazu Feiertage. Monatsansicht (Standard)
und Zeitleiste, filterbar nach Team und Name.

- Eigene Tabelle pulse_calendar_days, gefuellt von lib/calendarSync.js im
  HRworks-Lauf (Fenster bis 400 Tage voraus). Buchungen entstehen weiter
  erst am jeweiligen Tag.
- Nur validatedOk und nur freigegebene Arten (calendar_types, Haken in den
  HRworks-Einstellungen). Krankmeldungen werden dafuer nicht abgefragt.
- GET /api/calendar fuer alle Angemeldeten, ohne Mails und Profilbilder.
- Timer laeuft zusaetzlich um 12:00 Uhr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:37:26 +02:00

204 lines
7.4 KiB
JavaScript

/*
* 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,
// Fuer den Kalender; wie der Eintrag genau aussieht, ist nicht
// dokumentiert - fehlt der Name, heisst er dort schlicht "Feiertag".
holiday_name: holidayName(events.holidays),
});
}
}
return out;
}
}
export function holidayName(holidays) {
for (const h of Array.isArray(holidays) ? holidays : []) {
const name = typeof h === 'string' ? h : h?.name || h?.description || h?.title;
if (typeof name === 'string' && name.trim()) return name.trim().slice(0, 160);
}
return null;
}
/** { 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',
];