/* * 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', ];