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>
204 lines
7.4 KiB
JavaScript
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',
|
|
];
|