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
+118
View File
@@ -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;
+12 -3
View File
@@ -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) {
+43 -1
View File
@@ -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']) {