diff --git a/.gitignore b/.gitignore index 7e643b0..962744c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ Anforderungen.txt /Logo*.png /Logo*.jpg /Pulse-Logo*.png + +# Zugangsdaten, die nie ins Repository gehoeren. +/hrworks_api_keys.txt +/hrworks*.txt +*api_keys*.txt +*secret*.txt diff --git a/README.md b/README.md index f334d51..df53cf2 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,48 @@ mit Pulse arbeitet. Angezeigt wird es überall dort, wo bisher die Initialen standen: Seitenleiste, Kopfzeile, Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Initialen. +## Abwesenheiten aus HRworks + +Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt sie einmal täglich +und bucht sie auf das nicht abrechenbare Projekt `PERINCO-2024-001` („Abwesenheiten"). +Geschrieben wird nur in Pulse – nach HRworks geht nichts zurück. + +**Einrichtung** (*Einstellungen → Abwesenheiten (HRworks)*, nur Administrator): Schlüsselpaar in +HRworks unter *Grundlagen → Integrationen → HRworks-API → Schlüsselpaare* erzeugen und hier +eintragen. Der geheime Teil wird wie das SSO-Secret AES-verschlüsselt gespeichert und nie wieder +ausgeliefert. „Verbindung testen" prüft die Zugangsdaten, „Probelauf" zeigt ohne zu schreiben, +welche Buchungen entstehen würden. + +**Wie die Stunden entstehen:** `GET /persons/available-working-hours?interval=days` liefert je +Person und Tag `regularWorkingHours` (Soll) und `workingHours` (nach Abzug der Abwesenheit). +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. Halbe Tage werden auf einen festen Wert gesetzt (Voreinstellung 4 h). + +| Regel | Umsetzung | +| --- | --- | +| Übernommen werden | Abwesenheiten mit Status `validatedOk`; Krankmeldungen auch mit offener Bescheinigung (`reported`, `certificatePending`, `electronicCertificate*`) | +| Zeitraum | nur bis heute – genehmigter Urlaub in der Zukunft wächst täglich nach | +| Fenster | die letzten 60 Tage (einstellbar); dort werden Änderungen und Stornos nachgezogen | +| Zuordnung | HRworks-Benutzername → Pulse-Benutzer über die Mailadresse, sonst über den Namen; Unzuordenbares wird gemeldet, nie stillschweigend verworfen | +| Mitglieder | jeder aktive Mitarbeiter wird dem Projekt zugewiesen, auch neue | +| Arten → Tasks | `type_map` in den Einstellungen, z. B. `absence:1 → Urlaub`, `sick:KEK → Kind krank` | + +Jede Buchung trägt `pulse_time_entries.hrworks_ref` = `@` (unique). +Ein zweiter Lauf ändert deshalb nichts; was in HRworks storniert oder verschoben wurde, +verschwindet im nächsten Lauf. `DELETE /api/hrworks/entries` entfernt genau diese Buchungen. + +**Täglich** läuft `server/src/jobs/hrworks-sync.js` über den systemd-Timer +`perinco-pulse-hrworks.timer` (5:30 Uhr Berliner Zeit, `Persistent=true` holt verpasste Läufe +nach). Ausgang im Journal: `journalctl -u perinco-pulse-hrworks`. Die Unit-Dateien liegen in +`deploy/`; sie werden nach `/etc/systemd/system/` kopiert und einmal mit +`systemctl enable --now perinco-pulse-hrworks.timer` eingeschaltet. + +**Nicht abrechenbare Projekte brauchen keinen Budgettopf** mehr (`NEEDS_POT` in +`server/src/routes/projects.js`). Ein Topf trägt Volumen, Laufzeit und Satz – das hat nur bei +Billable und Festpreis Sinn. Am Abwesenheitsprojekt wäre er nur ein Ablaufdatum, das irgendwann +Buchungen blockiert; es hat deshalb keinen. + ## SSO / Active Directory **Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung. @@ -203,6 +245,7 @@ Aus Auftrag und Call-Mitschrift. | Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter | | Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste | | Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten | +| Abwesenheiten automatisch aus HRworks | ✅ täglich 5:30 Uhr | Einstellungen → Abwesenheiten (HRworks) | | Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste | | Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog | | Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog | diff --git a/deploy/perinco-pulse-hrworks.service b/deploy/perinco-pulse-hrworks.service new file mode 100644 index 0000000..e6985ac --- /dev/null +++ b/deploy/perinco-pulse-hrworks.service @@ -0,0 +1,24 @@ +[Unit] +Description=perinco Pulse - Abwesenheiten aus HRworks abgleichen +After=network-online.target mariadb.service +Wants=network-online.target + +[Service] +Type=oneshot +User=pulse +Group=pulse +WorkingDirectory=/var/www/pulse/server +EnvironmentFile=/var/www/pulse/server/.env +# Wie der Webdienst: "heute" und Monatsgrenzen gelten in Berlin. +Environment=TZ=Europe/Berlin +Environment=NODE_ENV=production +ExecStart=/usr/bin/node src/jobs/hrworks-sync.js +# Ein hoher Wert waere hier falsch: laeuft HRworks nicht, soll der Lauf +# abbrechen und es morgen erneut versuchen, statt haengen zu bleiben. +TimeoutStartSec=600 +MemoryMax=512M +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/www/pulse/server diff --git a/deploy/perinco-pulse-hrworks.timer b/deploy/perinco-pulse-hrworks.timer new file mode 100644 index 0000000..2f127f7 --- /dev/null +++ b/deploy/perinco-pulse-hrworks.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Taeglicher Abgleich der Abwesenheiten aus HRworks + +[Timer] +# Frueh am Morgen, bevor jemand seine Zeiten ansieht. RandomizedDelay +# verteilt die Last, Persistent holt einen verpassten Lauf nach (Neustart). +# Ortszeit im Kalenderausdruck - der Server laeuft auf UTC, der Lauf soll +# aber morgens in Berlin liegen, auch nach der Zeitumstellung. +OnCalendar=*-*-* 05:30:00 Europe/Berlin +RandomizedDelaySec=300 +Persistent=true +Unit=perinco-pulse-hrworks.service + +[Install] +WantedBy=timers.target diff --git a/server/src/db.js b/server/src/db.js index 2f7cd30..4121293 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -43,6 +43,7 @@ export async function initDatabase() { await migrateAvatarColumn(); await migrateTaskRate(); await migrateHarvestId(); + await migrateHrworksRef(); await ensureAdminUser(); await ensureInternalCustomer(); } @@ -118,6 +119,19 @@ async function migrateHarvestId() { } } +/* Herkunftsschluessel fuer Abwesenheiten aus HRworks (Nummer plus Tag). */ +async function migrateHrworksRef() { + const [columns] = await pool.query( + `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_time_entries' AND COLUMN_NAME = 'hrworks_ref'` + ); + if (!columns.length) { + await pool.query('ALTER TABLE pulse_time_entries ADD COLUMN hrworks_ref VARCHAR(80) NULL AFTER harvest_id'); + await pool.query('ALTER TABLE pulse_time_entries ADD UNIQUE KEY uq_pulse_entries_hrworks (hrworks_ref)'); + console.log('[Pulse] Spalte pulse_time_entries.hrworks_ref ergaenzt.'); + } +} + async function ensureAdminUser() { const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users'); if (rows[0].n > 0) return; diff --git a/server/src/jobs/hrworks-sync.js b/server/src/jobs/hrworks-sync.js new file mode 100644 index 0000000..94f58d3 --- /dev/null +++ b/server/src/jobs/hrworks-sync.js @@ -0,0 +1,48 @@ +/* + * Taeglicher Abgleich der Abwesenheiten aus HRworks. + * + * Laeuft als eigener Prozess ueber einen systemd-Timer, nicht im Webdienst: + * ein haengender Fremdaufruf soll die Anwendung nicht blockieren, und der + * Ausgang landet so im Journal (journalctl -u perinco-pulse-hrworks). + * + * Aufruf: + * node src/jobs/hrworks-sync.js echter Lauf + * node src/jobs/hrworks-sync.js --dry-run nur berichten + */ +import 'dotenv/config'; +import { initDatabase, pool } from '../db.js'; +import { getSettings } from '../lib/settings.js'; +import { syncHrworks } from '../lib/hrworksSync.js'; + +const dryRun = process.argv.includes('--dry-run'); + +try { + await initDatabase(); + const { hrworks } = await getSettings(); + if (!hrworks.enabled) { + console.log('[Pulse/HRworks] Abgleich ist in den Einstellungen abgeschaltet - nichts zu tun.'); + process.exit(0); + } + const report = await syncHrworks({ dryRun }); + console.log( + `[Pulse/HRworks]${dryRun ? ' (Probelauf)' : ''} ${report.from} bis ${report.to}: ` + + `${report.created} neu, ${report.updated ?? report.planned_update} geaendert, ` + + `${report.removed ?? report.planned_delete} entfernt, ${report.unchanged} unveraendert, ` + + `${report.hours} Stunden.` + ); + for (const person of report.unmatched_persons) { + console.warn(`[Pulse/HRworks] Kein Pulse-Konto fuer ${person.person_id} (${person.name}, ${person.email || 'ohne Mail'}).`); + } + for (const task of report.missing_tasks) { + console.warn(`[Pulse/HRworks] Task fehlt im Projekt: ${task}`); + } + for (const problem of report.failed) { + console.warn(`[Pulse/HRworks] ${problem.ref}: ${problem.error}`); + } + process.exit(0); +} catch (err) { + console.error('[Pulse/HRworks] Abgleich fehlgeschlagen:', err.message); + process.exit(1); +} finally { + await pool.end().catch(() => undefined); +} diff --git a/server/src/lib/hrworks.js b/server/src/lib/hrworks.js new file mode 100644 index 0000000..d690bc7 --- /dev/null +++ b/server/src/lib/hrworks.js @@ -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', +]; diff --git a/server/src/lib/hrworksSync.js b/server/src/lib/hrworksSync.js new file mode 100644 index 0000000..2e22841 --- /dev/null +++ b/server/src/lib/hrworksSync.js @@ -0,0 +1,277 @@ +import { pool, withTransaction } from '../db.js'; +import { getSettings, saveSetting } from './settings.js'; +import { addDays, today } from './dates.js'; +import { Hrworks, ABSENCE_STATES, SICK_STATES } from './hrworks.js'; + +/* + * Abwesenheiten aus HRworks nach Pulse. + * + * Vorgehen: Fuer jede Person und jeden Tag im Fenster liefert HRworks das Soll + * und die verfuegbaren Stunden. Die Differenz sind die Abwesenheitsstunden; + * `relatedEvents` sagt, was der Auslöser war. Daraus entsteht je Tag genau + * eine Buchung mit dem Schluessel "@" bzw. + * "holiday@@" - so ist ein zweiter Lauf harmlos, und was in + * HRworks storniert oder verschoben wurde, verschwindet hier wieder. + * + * Nur bis heute: genehmigter Urlaub liegt oft Monate voraus, gebucht wird er + * am jeweiligen Tag (Absprache). + * + * Es wird ausschliesslich gelesen; Pulse schreibt nichts nach HRworks. + */ + +/** Ein Aufruf von /persons/available-working-hours deckt hoechstens 31 Tage. */ +const CHUNK_DAYS = 31; + +/** Vorrang: ein Feiertag zaehlt als Feiertag, auch wenn Urlaub eingetragen ist. */ +export function ursache(day) { + if (day.holiday) return { art: 'holiday', ref: null }; + if (day.sick_leaves.length) return { art: 'sick', ref: day.sick_leaves[0] }; + if (day.absences.length) return { art: 'absence', ref: day.absences[0] }; + return null; +} + +function normalize(text) { + return String(text || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]/g, ''); +} + +/** HRworks-Person -> Pulse-Benutzer: Mailadresse, sonst Name. */ +export function matchUsers(persons, users) { + const byMail = new Map(users.filter((u) => u.email).map((u) => [u.email.toLowerCase(), u])); + const byName = new Map(); + for (const user of users) { + const key = normalize(user.name); + byName.set(key, byName.has(key) ? null : user); // doppelte Namen: kein Treffer + } + const matched = new Map(); + const unmatched = []; + for (const person of persons) { + const hit = (person.email && byMail.get(person.email)) || byName.get(normalize(person.name)) || null; + if (hit) matched.set(person.person_id, hit); + else unmatched.push({ person_id: person.person_id, name: person.name, email: person.email }); + } + return { matched, unmatched }; +} + +/** Tage in Abschnitte von hoechstens CHUNK_DAYS zerlegen. */ +export function chunks(from, to) { + const out = []; + let start = from; + while (start <= to) { + let end = addDays(start, CHUNK_DAYS - 1); + if (end > to) end = to; + out.push([start, end]); + start = addDays(end, 1); + } + return out; +} + +/** + * Fuehrt den Abgleich durch. + * + * @param {object} options + * @param {boolean} options.dryRun nichts schreiben, nur berichten + * @param {string} [options.from] Beginn des Fensters (Standard: heute minus window_days) + * @param {string} [options.to] Ende (Standard: heute) + */ +export async function syncHrworks({ dryRun = true, from, to } = {}) { + const settings = await getSettings(); + const config = settings.hrworks; + const bis = to || today(); + const von = from || addDays(bis, -Number(config.window_days || 60)); + + const bericht = { + dry_run: dryRun, + from: von, + to: bis, + created: 0, + unchanged: 0, + removed: 0, + failed: [], + unmatched_persons: [], + missing_tasks: [], + hours: 0, + // Einzelheiten fuer die Vorschau: je Person und Task Stunden und Tage. + per_person: {}, + started_at: new Date().toISOString(), + }; + + const [[project]] = await pool.query('SELECT * FROM pulse_projects WHERE code = ? LIMIT 1', [config.project_code]); + if (!project) throw new Error(`Projekt ${config.project_code} nicht gefunden - bitte in den Einstellungen richtigstellen.`); + if (!project.is_active) throw new Error(`Projekt ${config.project_code} ist archiviert.`); + + const [taskRows] = await pool.query('SELECT id, name, is_active FROM pulse_tasks WHERE project_id = ?', [project.id]); + const tasks = new Map(taskRows.map((t) => [normalize(t.name), t])); + + const hr = new Hrworks(config); + const persons = await hr.persons(); + + const [users] = await pool.query( + "SELECT id, name, email FROM pulse_users WHERE is_active = 1 AND role <> 'admin'" + ); + const { matched, unmatched } = matchUsers(persons, users); + bericht.unmatched_persons = unmatched; + + // Vor dem Projektstart wird nicht gebucht - sonst stehen Zeiten vor der + // Laufzeit, die Pulse selbst nie zugelassen haette. + const projektStart = String(project.start_date); + + // Wieviel steht an einem Tag schon (ohne unsere eigenen Abwesenheiten)? + // Mehr als 24 Stunden am Tag laesst Pulse nirgends zu. + const [belegt] = await pool.query( + `SELECT user_id, entry_date, SUM(minutes) AS minutes FROM pulse_time_entries + WHERE entry_date BETWEEN ? AND ? AND hrworks_ref IS NULL + GROUP BY user_id, entry_date`, + [von, bis] + ); + const tagesLast = new Map(belegt.map((r) => [`${r.user_id}@${String(r.entry_date)}`, Number(r.minutes)])); + + // Alle Abwesenheiten und Krankmeldungen im Fenster - fuer Art und Status. + const [absences, sickLeaves] = await Promise.all([hr.absences(von, bis), hr.sickLeaves(von, bis)]); + const meta = new Map(); + for (const a of absences) { + meta.set(a.number, { art: 'absence', type: String(a.type), name: a.name, status: a.statusIdentifier }); + } + for (const s of sickLeaves) { + meta.set(s.number, { art: 'sick', type: String(s.type), name: s.name, status: s.statusIdentifier }); + } + + // Tage einsammeln, in Abschnitten von 31 Tagen. + const personIds = [...matched.keys()]; + const soll = new Map(); // ref -> Buchung + for (const [start, end] of chunks(von, bis)) { + const days = await hr.days(start, end, personIds); + for (const day of days) { + const user = matched.get(day.person); + if (!user) continue; + if (day.date < projektStart) continue; + const stunden = Math.round((day.regular_hours - day.available_hours) * 100) / 100; + if (stunden <= 0) continue; + + const quelle = ursache(day); + if (!quelle) continue; + + let taskName; + let ref; + if (quelle.art === 'holiday') { + if (!config.holiday_task) continue; + taskName = config.holiday_task; + ref = `holiday@${day.date}@${day.person}`; + } else { + const info = meta.get(quelle.ref); + if (!info) continue; // Abwesenheit liegt ausserhalb des abgefragten Fensters + const erlaubt = info.art === 'sick' ? SICK_STATES : ABSENCE_STATES; + if (!erlaubt.includes(info.status)) continue; + taskName = config.type_map?.[`${info.art}:${info.type}`] || info.name; + ref = `${quelle.ref}@${day.date}`; + } + + const task = tasks.get(normalize(taskName)); + if (!task) { + if (!bericht.missing_tasks.includes(taskName)) bericht.missing_tasks.push(taskName); + continue; + } + + // Halbe Tage auf einen festen Wert setzen (Absprache: 4 Stunden). + const halbe = Number(config.half_day_hours || 0); + const minutes = Math.round((halbe > 0 && stunden < day.regular_hours ? halbe : stunden) * 60); + const schon = tagesLast.get(`${user.id}@${day.date}`) || 0; + if (schon + minutes > 24 * 60) { + bericht.failed.push({ + ref, + error: `An diesem Tag stehen schon ${Math.round((schon / 60) * 100) / 100} h - mit ${minutes / 60} h waeren es mehr als 24.`, + }); + continue; + } + soll.set(ref, { user_id: user.id, task_id: task.id, date: day.date, minutes, ref }); + bericht.hours += minutes / 60; + const eintrag = (bericht.per_person[user.name] ||= {}); + const wert = (eintrag[task.name] ||= { hours: 0, days: 0 }); + wert.hours = Math.round((wert.hours + minutes / 60) * 100) / 100; + wert.days += 1; + } + } + + // Was steht schon in Pulse? + const [vorhanden] = await pool.query( + 'SELECT id, hrworks_ref, user_id, task_id, entry_date, minutes FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL AND entry_date BETWEEN ? AND ?', + [von, bis] + ); + const istMap = new Map(vorhanden.map((e) => [e.hrworks_ref, e])); + + const anzulegen = []; + const zuAendern = []; + for (const [ref, wunsch] of soll) { + const ist = istMap.get(ref); + if (!ist) anzulegen.push(wunsch); + else if (ist.minutes !== wunsch.minutes || ist.task_id !== wunsch.task_id || ist.user_id !== wunsch.user_id) { + zuAendern.push({ id: ist.id, ...wunsch }); + } else bericht.unchanged++; + } + // Alles, was in HRworks nicht mehr existiert (storniert, verschoben, Status weg). + const zuLoeschen = vorhanden.filter((e) => !soll.has(e.hrworks_ref)).map((e) => e.id); + + bericht.hours = Math.round(bericht.hours * 100) / 100; + bericht.planned_create = anzulegen.length; + bericht.planned_update = zuAendern.length; + bericht.planned_delete = zuLoeschen.length; + + if (dryRun) { + bericht.finished_at = new Date().toISOString(); + return bericht; + } + + await withTransaction(async (conn) => { + if (zuLoeschen.length) { + await conn.query(`DELETE FROM pulse_time_entries WHERE id IN (${zuLoeschen.map(() => '?').join(',')})`, zuLoeschen); + bericht.removed = zuLoeschen.length; + } + for (const e of zuAendern) { + await conn.query('UPDATE pulse_time_entries SET user_id = ?, task_id = ?, entry_date = ?, minutes = ? WHERE id = ?', [ + e.user_id, + e.task_id, + e.date, + e.minutes, + e.id, + ]); + } + // Jeder Mitarbeiter gehoert dem Projekt an, sonst nimmt Pulse die Buchung + // nicht an - auch neue Kollegen (Absprache). + await conn.query( + `INSERT IGNORE INTO pulse_project_members (project_id, user_id) + SELECT ?, id FROM pulse_users WHERE is_active = 1 AND role <> 'admin'`, + [project.id] + ); + for (const e of anzulegen) { + try { + await conn.query( + `INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable, hrworks_ref) + VALUES (?, ?, ?, NULL, ?, ?, NULL, 0, ?)`, + [e.user_id, project.id, e.task_id, e.date, e.minutes, e.ref] + ); + bericht.created++; + } catch (err) { + bericht.failed.push({ ref: e.ref, error: err.code === 'ER_DUP_ENTRY' ? 'schon vorhanden' : err.message }); + } + } + }); + + bericht.updated = zuAendern.length; + bericht.finished_at = new Date().toISOString(); + await saveSetting('hrworks', { + ...config, + last_run: bericht.finished_at, + last_result: { + created: bericht.created, + updated: bericht.updated, + removed: bericht.removed, + unchanged: bericht.unchanged, + hours: bericht.hours, + failed: bericht.failed.length, + }, + }); + return bericht; +} diff --git a/server/src/lib/settings.js b/server/src/lib/settings.js index 94ed9ef..45e7982 100644 --- a/server/src/lib/settings.js +++ b/server/src/lib/settings.js @@ -30,6 +30,47 @@ export const DEFAULTS = { signature_left: 'Datum, Unterschrift Berater', signature_right: 'Datum, Unterschrift Kunde', }, + /* + * Abwesenheiten aus HRworks. Urlaub und Krankheit werden dort gefuehrt; Pulse + * holt sie taeglich und buchtsie auf ein nicht abrechenbares Projekt. + */ + hrworks: { + enabled: false, + access_key: '', + secret_access_key: '', + // Projekt, auf das gebucht wird (Projektcode, nicht die id - der Code + // bleibt beim Umziehen der Datenbank gleich). + project_code: 'PERINCO-2024-001', + // Wie viele Tage rueckwaerts jeder Lauf prueft. + window_days: 60, + /* + * Abwesenheitsart -> Taskname in Pulse. Schluessel ist "absence:" bzw. + * "sick:" aus HRworks. Fehlt ein Eintrag, gilt der HRworks-Name des + * Typs - dann muss in Pulse ein Task mit genau diesem Namen stehen. + */ + type_map: { + 'absence:1': 'Urlaub', + 'absence:3': 'Elternzeit', + 'absence:5': 'Mutterschutz', + 'absence:6': 'Geburt', + 'absence:7': 'Hochzeit', + 'absence:8': 'Sabbatical', + 'absence:9': 'Freistellung', + 'absence:10': 'Bildungsurlaub', + 'sick:KMA': 'Krankheit', + 'sick:KOA': 'Krankheit', + 'sick:K28': 'Krankheit', + 'sick:KOL': 'Krankheit', + 'sick:KWW': 'Krankheit', + 'sick:KEK': 'Kind krank', + }, + // Task fuer Feiertage; leer = Feiertage nicht buchen. + holiday_task: 'Feiertag', + // Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen). + half_day_hours: 4, + last_run: null, + last_result: null, + }, sso: { enabled: false, button_label: 'Mit Microsoft anmelden', @@ -62,6 +103,7 @@ export async function getSettings() { } } if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret); + if (result.hrworks.secret_access_key) result.hrworks.secret_access_key = decrypt(result.hrworks.secret_access_key); return result; } @@ -69,6 +111,7 @@ export async function saveSetting(key, value) { if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`); let stored = value; if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) }; + if (key === 'hrworks' && value.secret_access_key) stored = { ...value, secret_access_key: encrypt(value.secret_access_key) }; await pool.query( 'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)', [key, JSON.stringify(stored)] diff --git a/server/src/routes/hrworks.js b/server/src/routes/hrworks.js new file mode 100644 index 0000000..65349dd --- /dev/null +++ b/server/src/routes/hrworks.js @@ -0,0 +1,118 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { getSettings } from '../lib/settings.js'; +import { Hrworks } from '../lib/hrworks.js'; +import { syncHrworks } from '../lib/hrworksSync.js'; + +/* + * Abwesenheiten aus HRworks - Bedienung und Kontrolle. + * + * Nur Administratoren. Es wird ausschliesslich aus HRworks gelesen; geschrieben + * wird in Pulse, und zwar nur auf das eingestellte Abwesenheitsprojekt. + */ + +const router = express.Router(); + +/** Was ist eingerichtet, was hat der letzte Lauf gebracht? */ +router.get('/status', async (req, res, next) => { + try { + const { hrworks } = await getSettings(); + const [[project]] = await pool.query('SELECT id, code, name, type, start_date FROM pulse_projects WHERE code = ? LIMIT 1', [ + hrworks.project_code, + ]); + const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL'); + const [[range]] = await pool.query( + 'SELECT MIN(entry_date) AS von, MAX(entry_date) AS bis, COALESCE(SUM(minutes), 0) AS minutes FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL' + ); + res.json({ + enabled: hrworks.enabled, + configured: Boolean(hrworks.access_key && hrworks.secret_access_key), + project: project || null, + project_code: hrworks.project_code, + window_days: hrworks.window_days, + holiday_task: hrworks.holiday_task, + half_day_hours: hrworks.half_day_hours, + type_map: hrworks.type_map, + last_run: hrworks.last_run, + last_result: hrworks.last_result, + entries: Number(n), + hours: Math.round((Number(range.minutes) / 60) * 100) / 100, + first_date: range.von, + last_date: range.bis, + }); + } catch (err) { + next(err); + } +}); + +/** Zugangsdaten pruefen und zeigen, was HRworks liefert. */ +router.post('/test', async (req, res, next) => { + try { + const { hrworks } = await getSettings(); + const hr = new Hrworks(hrworks); + await hr.authenticate(); + const [persons, absenceTypes, sickTypes] = await Promise.all([ + hr.persons(), + hr.absenceTypes(), + hr.sickLeaveTypes(), + ]); + const [users] = await pool.query("SELECT email, name FROM pulse_users WHERE is_active = 1 AND role <> 'admin'"); + const mails = new Set(users.map((u) => (u.email || '').toLowerCase())); + res.json({ + ok: true, + persons: persons.length, + persons_with_pulse_account: persons.filter((p) => p.email && mails.has(p.email)).length, + absence_types: absenceTypes.map((t) => ({ key: t.key, name: t.name, active: t.active })), + sick_leave_types: sickTypes.map((t) => ({ key: t.key, name: t.name, active: t.active })), + }); + } catch (err) { + if (err.status) return res.status(400).json({ error: err.message }); + next(err); + } +}); + +/** + * Abgleich anstossen. Ohne `apply: true` wird nichts geschrieben, sondern nur + * berichtet, was passieren wuerde. + */ +router.post('/sync', async (req, res, next) => { + try { + const bericht = await syncHrworks({ + dryRun: req.body?.apply !== true, + from: req.body?.from, + to: req.body?.to, + }); + res.json(bericht); + } catch (err) { + if (err.status) return res.status(400).json({ error: err.message }); + next(err); + } +}); + +/** Was hat der Abgleich angelegt? Fuer Kontrolle und Rueckbau. */ +router.get('/entries', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT e.hrworks_ref, e.entry_date, e.minutes, u.name AS user_name, t.name AS task_name + FROM pulse_time_entries e + JOIN pulse_users u ON u.id = e.user_id + LEFT JOIN pulse_tasks t ON t.id = e.task_id + WHERE e.hrworks_ref IS NOT NULL ORDER BY e.entry_date, u.name` + ); + res.json({ count: rows.length, entries: rows }); + } catch (err) { + next(err); + } +}); + +/** Rueckbau: entfernt genau die Buchungen aus HRworks, nichts anderes. */ +router.delete('/entries', async (req, res, next) => { + try { + const [result] = await pool.query('DELETE FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL'); + res.json({ deleted: result.affectedRows }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index 6454e8a..cf38adb 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -13,8 +13,13 @@ import { const router = express.Router(); const TYPES = ['billable', 'non_billable', 'fixed', 'abruf']; -/** Abruf rechnet nach Aufwand ab: kein Budgettopf, Satz am Projekt. */ -const NEEDS_POT = (type) => type !== 'abruf'; +/* + * Ein Budgettopf traegt Volumen, Laufzeit und Satz - das hat nur Sinn, wo + * abgerechnet wird. "Abruf" rechnet nach Aufwand ohne Topf, und nicht + * abrechenbare Projekte (intern, Abwesenheiten) haben gar kein Budget: dort + * waere ein Topf nur ein Ablaufdatum, das irgendwann Buchungen blockiert. + */ +const NEEDS_POT = (type) => type === 'billable' || type === 'fixed'; const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null); const num = (v) => (v === '' || v == null ? null : Number(v)); @@ -370,12 +375,16 @@ router.delete('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => try { const projectId = Number(req.params.id); const potId = Number(req.params.potId); + const project = await findProject(projectId); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE pot_id = ?', [potId]); if (Number(n) > 0) { return res.status(409).json({ error: `Auf dem Topf sind ${n} Buchungen - er kann nicht gelöscht werden.` }); } const [[{ pots }]] = await pool.query('SELECT COUNT(*) AS pots FROM pulse_budget_pots WHERE project_id = ?', [projectId]); - if (Number(pots) <= 1) return res.status(400).json({ error: 'Ein Projekt braucht mindestens einen Budgettopf.' }); + if (Number(pots) <= 1 && NEEDS_POT(project.type)) { + return res.status(400).json({ error: 'Abrechenbare Projekte brauchen mindestens einen Budgettopf.' }); + } await pool.query('DELETE FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [potId, projectId]); await respondProject(req, res, projectId); } catch (err) { diff --git a/server/src/routes/settings.js b/server/src/routes/settings.js index 239e2e4..cb56680 100644 --- a/server/src/routes/settings.js +++ b/server/src/routes/settings.js @@ -27,7 +27,17 @@ router.get('/public', async (req, res, next) => { router.get('/', requireRole('admin'), async (req, res, next) => { try { const s = await getSettings(); - res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } }); + // Geheimnisse werden nie ausgeliefert, nur die Information, dass sie stehen. + res.json({ + ...s, + sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) }, + hrworks: { + ...s.hrworks, + secret_access_key: '', + secret_set: Boolean(s.hrworks.secret_access_key), + configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key), + }, + }); } catch (err) { next(err); } @@ -83,6 +93,38 @@ router.put('/', requireRole('admin'), async (req, res, next) => { await saveSetting('export', next_); } + if (body.hrworks) { + const next_ = { ...current.hrworks }; + for (const key of ['access_key', 'project_code', 'holiday_task']) { + if (body.hrworks[key] !== undefined) next_[key] = text(body.hrworks[key], 200).trim(); + } + if (body.hrworks.enabled !== undefined) next_.enabled = Boolean(body.hrworks.enabled); + if (body.hrworks.window_days !== undefined) { + const days = Number(body.hrworks.window_days); + if (days >= 1 && days <= 365) next_.window_days = Math.round(days); + else errors.push('HRworks: Fenster zwischen 1 und 365 Tagen.'); + } + if (body.hrworks.half_day_hours !== undefined) { + const hours = Number(body.hrworks.half_day_hours); + if (hours >= 0 && hours <= 12) next_.half_day_hours = hours; + else errors.push('HRworks: Stunden für einen halben Tag zwischen 0 und 12.'); + } + if (body.hrworks.type_map && typeof body.hrworks.type_map === 'object') { + const map = {}; + for (const [key, value] of Object.entries(body.hrworks.type_map)) { + if (/^(absence|sick):[A-Za-z0-9_-]{1,20}$/.test(key)) map[key] = text(value, 160).trim(); + } + next_.type_map = map; + } + // Leer = unveraendert; der geheime Teil wird nie ausgeliefert. + if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim(); + if (body.hrworks.clear_secret) next_.secret_access_key = ''; + if (next_.enabled && !(next_.access_key && next_.secret_access_key)) { + errors.push('HRworks kann erst aktiviert werden, wenn Access Key und Secret gesetzt sind.'); + } + if (!errors.length) await saveSetting('hrworks', next_); + } + if (body.sso) { const next_ = { ...current.sso }; for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) { diff --git a/server/src/schema.sql b/server/src/schema.sql index 7cb9f07..a25f968 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -137,10 +137,14 @@ CREATE TABLE IF NOT EXISTS pulse_time_entries ( -- der Schluessel verhindert Dubletten bei einem zweiten Import und erlaubt -- einen gezielten Rueckbau, ohne handgetippte Zeiten anzufassen. harvest_id BIGINT UNSIGNED NULL, + -- Herkunft aus HRworks: Abwesenheitsnummer plus Tag, z. B. "30_25@2026-09-08". + -- Ein Tag einer Abwesenheit ist genau eine Buchung. + hrworks_ref VARCHAR(80) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uq_pulse_entries_harvest (harvest_id), + UNIQUE KEY uq_pulse_entries_hrworks (hrworks_ref), KEY idx_pulse_entries_user_date (user_id, entry_date), KEY idx_pulse_entries_project_date (project_id, entry_date), KEY idx_pulse_entries_pot (pot_id), diff --git a/server/src/server.js b/server/src/server.js index 40aff42..0507f78 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -14,6 +14,7 @@ import exportRoutes from './routes/exports.js'; import settingsRoutes from './routes/settings.js'; import directoryRoutes from './routes/directory.js'; import importRoutes from './routes/imports.js'; +import hrworksRoutes from './routes/hrworks.js'; const app = express(); const PORT = Number(process.env.PORT || 5010); @@ -52,6 +53,8 @@ app.use('/api/settings', requireAuth, settingsRoutes); app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes); // Uebernahme aus Fremdsystemen - nur Administratoren. app.use('/api/imports', requireAuth, requireRole('admin'), importRoutes); +// Abwesenheiten aus HRworks - nur Administratoren. +app.use('/api/hrworks', requireAuth, requireRole('admin'), hrworksRoutes); app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' })); diff --git a/server/test/hrworks.test.mjs b/server/test/hrworks.test.mjs new file mode 100644 index 0000000..75f86a2 --- /dev/null +++ b/server/test/hrworks.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chunks, matchUsers, ursache } from '../src/lib/hrworksSync.js'; +import { ABSENCE_STATES, SICK_STATES } from '../src/lib/hrworks.js'; + +test('ein Feiertag gewinnt gegen Urlaub am selben Tag', () => { + assert.deepEqual(ursache({ holiday: true, absences: ['30_25'], sick_leaves: [] }), { art: 'holiday', ref: null }); + assert.deepEqual(ursache({ holiday: false, absences: ['30_25'], sick_leaves: [] }), { art: 'absence', ref: '30_25' }); + // Krankheit vor Urlaub: wer im Urlaub krank wird, ist krank. + assert.deepEqual(ursache({ holiday: false, absences: ['30_25'], sick_leaves: ['31_9'] }), { art: 'sick', ref: '31_9' }); + assert.equal(ursache({ holiday: false, absences: [], sick_leaves: [] }), null); +}); + +test('Zuordnung: Mailadresse zuerst, dann Name', () => { + const users = [ + { id: 1, name: 'Kai Schwickert', email: 'kai.schwickert@icon-experts.de' }, + { id: 2, name: 'Nico Necknig', email: 'nico.necknig@perinco.de' }, + { id: 3, name: 'Renè Kleinau', email: 'rene.kleinau@perinco.de' }, + ]; + const persons = [ + // andere Domain in HRworks - greift ueber den Namen + { person_id: 'KaiS', name: 'Kai Schwickert', email: 'kai.schwickert@perinco.de' }, + // private Adresse in HRworks - greift ueber den Namen + { person_id: 'NicoN', name: 'Nico Necknig', email: 'nico.necknig@web.de' }, + // Akzent unterschiedlich geschrieben + { person_id: 'ReneK', name: 'Rene Kleinau', email: 'andere@perinco.de' }, + { person_id: 'ServiceA', name: 'Service Account', email: 'sa.hrworks@perinco.de' }, + ]; + const { matched, unmatched } = matchUsers(persons, users); + assert.equal(matched.get('KaiS').id, 1); + assert.equal(matched.get('NicoN').id, 2); + assert.equal(matched.get('ReneK').id, 3); + assert.deepEqual(unmatched.map((u) => u.person_id), ['ServiceA']); +}); + +test('doppelte Namen ergeben keinen Treffer', () => { + const users = [ + { id: 5, name: 'Luisa Sauerwald', email: 'lusia.sauerwald@perinco.de' }, + { id: 23, name: 'Luisa Sauerwald', email: 'luisa.sauerwald@perinco.de' }, + ]; + // Mail passt: eindeutig. + let r = matchUsers([{ person_id: 'LuisaS', name: 'Luisa Sauerwald', email: 'luisa.sauerwald@perinco.de' }], users); + assert.equal(r.matched.get('LuisaS').id, 23); + // Mail passt nicht: der Name ist zweimal da, also lieber melden als raten. + r = matchUsers([{ person_id: 'LuisaS', name: 'Luisa Sauerwald', email: 'x@y.de' }], users); + assert.equal(r.matched.size, 0); + assert.equal(r.unmatched.length, 1); +}); + +test('Zeitraum wird in Abschnitte von hoechstens 31 Tagen zerlegt', () => { + assert.deepEqual(chunks('2026-09-01', '2026-09-05'), [['2026-09-01', '2026-09-05']]); + const lang = chunks('2026-07-23', '2026-09-21'); + assert.equal(lang.length, 2); + assert.equal(lang[0][0], '2026-07-23'); + assert.equal(lang[lang.length - 1][1], '2026-09-21'); + for (const [von, bis] of lang) { + const tage = (new Date(bis) - new Date(von)) / 86400000 + 1; + assert.ok(tage <= 31, `${von}-${bis} hat ${tage} Tage`); + } +}); + +test('nur genehmigte Abwesenheiten, bei Krankheit auch offene Bescheinigungen', () => { + assert.deepEqual(ABSENCE_STATES, ['validatedOk']); + assert.ok(SICK_STATES.includes('certificatePending')); + assert.ok(SICK_STATES.includes('reported')); + assert.ok(!SICK_STATES.includes('cancelled')); + assert.ok(!ABSENCE_STATES.includes('requested')); +}); diff --git a/src/components/settings/HrworksSettings.tsx b/src/components/settings/HrworksSettings.tsx new file mode 100644 index 0000000..3c87f31 --- /dev/null +++ b/src/components/settings/HrworksSettings.tsx @@ -0,0 +1,256 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Card, Field, Spinner, Toggle, inputClass } from '../ui/controls'; +import { Icon } from '../ui/Icon'; +import { hrworksApi, settingsApi, type AdminSettings, type HrworksReport, type HrworksStatus } from '../../lib/api'; +import { formatDate, formatHours } from '../../lib/format'; +import { useToast } from '../Toast'; + +/* + * Abwesenheiten aus HRworks. + * + * Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks; Pulse holt sie + * einmal taeglich und bucht sie auf ein nicht abrechenbares Projekt. Hier + * stehen die Zugangsdaten, die Zuordnung der Arten auf Tasks und der Knopf fuer + * einen Probelauf. + */ +export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => { + const { addToast } = useToast(); + const [form, setForm] = useState({ ...settings.hrworks, secret_access_key: '' }); + const [status, setStatus] = useState(null); + const [report, setReport] = useState(null); + const [busy, setBusy] = useState<'save' | 'test' | 'dry' | 'apply' | null>(null); + + const set = (patch: Partial) => setForm({ ...form, ...patch }); + useEffect(() => setForm({ ...settings.hrworks, secret_access_key: '' }), [settings.hrworks]); + useEffect(() => { + hrworksApi.status().then(setStatus).catch(() => undefined); + }, [settings.hrworks]); + + const run = async (was: 'save' | 'test' | 'dry' | 'apply') => { + setBusy(was); + try { + if (was === 'save') { + await settingsApi.save({ hrworks: { ...form, secret_access_key: form.secret_access_key || undefined } }); + addToast('Gespeichert.', 'success'); + onSaved(); + } else if (was === 'test') { + const r = await hrworksApi.test(); + addToast( + `Verbindung steht: ${r.persons} Personen in HRworks, ${r.persons_with_pulse_account} mit Pulse-Konto.`, + 'success' + ); + } else { + const r = await hrworksApi.sync(was === 'apply'); + setReport(r); + addToast( + was === 'apply' + ? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.` + : `Probelauf: ${r.planned_create} neu, ${r.planned_update} geändert, ${r.planned_delete} zu entfernen.`, + 'success' + ); + hrworksApi.status().then(setStatus).catch(() => undefined); + } + } catch (err) { + addToast(err instanceof Error ? err.message : 'Fehlgeschlagen.', 'error'); + } finally { + setBusy(null); + } + }; + + const eintraege = Object.entries(form.type_map ?? {}); + + return ( +
+ +
+

Abwesenheiten aus HRworks

+
+ + +
+
+ +
+ + + {settings.hrworks.enabled + ? 'Der Abgleich läuft täglich um 5:30 Uhr und holt die letzten Tage nach.' + : settings.hrworks.configured + ? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.' + : 'Noch keine Zugangsdaten. Schlüsselpaar in HRworks unter Grundlagen → Integrationen → HRworks-API erzeugen.'} + {status && status.entries > 0 && ( + <> + {' '} + Übernommen sind {status.entries} Buchungen mit {formatHours(status.hours * 60)} Stunden + {status.first_date && status.last_date ? ` (${formatDate(status.first_date)} – ${formatDate(status.last_date)})` : ''}. + + )} + +
+ +
+ + set({ access_key: e.target.value.trim() })} /> + + + set({ secret_access_key: e.target.value.trim() })} + /> + + + set({ project_code: e.target.value.trim() })} /> + + + set({ window_days: Number(e.target.value) })} /> + + + set({ holiday_task: e.target.value })} /> + + + set({ half_day_hours: Number(e.target.value) })} /> + +
+ +
+ set({ enabled: v })} + label="Täglichen Abgleich aktivieren" + hint="Ohne Haken läuft nichts – auch der nächtliche Lauf nicht." + /> +
+ + {status?.project && ( +

+ Zielprojekt: {status.project.code} – {status.project.name} ({status.project.type}), ab{' '} + {formatDate(status.project.start_date)}. Alle aktiven Mitarbeiter werden beim Abgleich zugewiesen. +

+ )} +
+ + +

Zuordnung der Arten

+

+ Links der Schlüssel aus HRworks (absence: oder{' '} + sick:), rechts der Taskname in Pulse. Fehlt ein Eintrag, sucht Pulse + einen Task mit dem HRworks-Namen der Art – findet es keinen, meldet der Probelauf ihn als fehlend. +

+
+ {eintraege.map(([key, value]) => ( +
+ {key} + set({ type_map: { ...form.type_map, [key]: e.target.value } })} + /> +
+ ))} +
+
+ + +
+

Abgleich jetzt

+
+ + +
+
+ {busy === 'dry' || busy === 'apply' ? ( + + ) : report ? ( + + ) : ( +

+ Der Probelauf schreibt nichts. Er zeigt, welche Buchungen entstehen, geändert oder entfernt würden. +

+ )} + {status?.last_run && ( +

+ Letzter Lauf: {new Date(status.last_run).toLocaleString('de-DE')} + {status.last_result + ? ` – ${status.last_result.created} neu, ${status.last_result.updated} geändert, ${status.last_result.removed} entfernt, ${status.last_result.unchanged} unverändert.` + : ''} +

+ )} +
+
+ ); +}; + +const Bericht: React.FC<{ report: HrworksReport }> = ({ report }) => ( +
+

+ {formatDate(report.from)} – {formatDate(report.to)}:{' '} + {report.dry_run + ? `${report.planned_create} neu, ${report.planned_update} geändert, ${report.planned_delete} zu entfernen, ${report.unchanged} unverändert` + : `${report.created} neu, ${report.updated ?? 0} geändert, ${report.removed ?? 0} entfernt, ${report.unchanged} unverändert`}{' '} + · {formatHours(report.hours * 60)} Stunden +

+ {report.missing_tasks.length > 0 && ( +

+ Diese Tasks fehlen im Projekt: {report.missing_tasks.join(', ')} +

+ )} + {report.unmatched_persons.length > 0 && ( +

+ Ohne Pulse-Konto: {report.unmatched_persons.map((p) => `${p.name || p.person_id}`).join(', ')} +

+ )} + {report.failed.length > 0 && ( +
    + {report.failed.slice(0, 8).map((f) => ( +
  • + {f.ref}: {f.error} +
  • + ))} +
+ )} + {Object.keys(report.per_person).length > 0 && ( +
+ + + + + + + + + + + {Object.entries(report.per_person).flatMap(([person, arten]) => + Object.entries(arten).map(([art, wert], i) => ( + + + + + + + )) + )} + +
MitarbeiterArtTageStunden
{i === 0 ? person : ''}{art}{wert.days}{formatHours(wert.hours * 60)}
+
+ )} +
+); diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index d4ebdaf..488f956 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -3,17 +3,19 @@ import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../u import { Icon, type IconName } from '../ui/Icon'; import { Team } from '../team/Team'; import { SsoDirectory } from './SsoDirectory'; +import { HrworksSettings } from './HrworksSettings'; import { useAuth } from '../../lib/auth'; import { download, settingsApi, type AdminSettings } from '../../lib/api'; import { useLoader } from '../../lib/hooks'; import { useRouter } from '../../lib/router'; import { useToast } from '../Toast'; -type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'benutzer'; +type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'benutzer'; const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [ { key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true }, { key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true }, { key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true }, + { key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true }, // Management sieht in den Einstellungen nur diesen Reiter. { key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false }, { key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true }, @@ -57,6 +59,7 @@ export const Settings: React.FC = () => { {tab === 'allgemein' && setData(s)} />} {tab === 'export' && setData(s)} />} {tab === 'sso' && } + {tab === 'hrworks' && } )} diff --git a/src/lib/api.ts b/src/lib/api.ts index 2318dbb..bb3d6de 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -162,13 +162,78 @@ export interface SsoSettings { redirect_uri: string; local_login: boolean; } +export interface HrworksSettings { + enabled: boolean; + access_key: string; + secret_access_key: string; + secret_set?: boolean; + configured?: boolean; + project_code: string; + window_days: number; + type_map: Record; + holiday_task: string; + half_day_hours: number; + last_run: string | null; + last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null; +} + +export interface HrworksStatus { + enabled: boolean; + configured: boolean; + project: { id: number; code: string; name: string; type: string; start_date: string } | null; + project_code: string; + window_days: number; + holiday_task: string; + half_day_hours: number; + type_map: Record; + last_run: string | null; + last_result: HrworksSettings['last_result']; + entries: number; + hours: number; + first_date: string | null; + last_date: string | null; +} + +export interface HrworksReport { + dry_run: boolean; + from: string; + to: string; + created: number; + updated?: number; + removed?: number; + unchanged: number; + planned_create: number; + planned_update: number; + planned_delete: number; + hours: number; + failed: { ref: string; error: string }[]; + unmatched_persons: { person_id: string; name: string; email: string | null }[]; + missing_tasks: string[]; + per_person: Record>; +} + +export const hrworksApi = { + status: () => get('/hrworks/status'), + test: () => + send<{ ok: true; persons: number; persons_with_pulse_account: number; absence_types: { key: string; name: string }[] }>( + 'POST', + '/hrworks/test', + {} + ), + sync: (apply = false) => send('POST', '/hrworks/sync', { apply }), + entries: () => get<{ count: number; entries: unknown[] }>('/hrworks/entries'), + removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'), +}; + export interface AdminSettings extends PublicSettings { export: ExportSettings; sso: SsoSettings; + hrworks: HrworksSettings; } -export type AdminSettingsInput = Omit & { +export type AdminSettingsInput = Omit & { export: Partial; sso: Partial & { clear_secret?: boolean }; + hrworks: Partial & { clear_secret?: boolean }; }; export interface AdUser {