From b6ba7860bf40115dd778a5a35b098c104fdbf0e7 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 25 Sep 2026 11:41:49 +0200 Subject: [PATCH] fix(kalender): Abwesenheiten jahresweise abfragen, Feiertagsnamen - HRworks /absences erlaubt hoechstens ein Jahr je Aufruf; der Kalender reicht weiter und fragt jetzt in Abschnitten von 365 Tagen. - HRworks liefert zu Feiertagen keinen Namen. Der Name kommt jetzt aus dem Datum (feste Feiertage, Ostern-abhaengige, Buss- und Bettag). Co-Authored-By: Claude Opus 5.5 --- server/src/lib/calendarSync.js | 15 +++++--- server/src/lib/dates.js | 62 ++++++++++++++++++++++++++++++++++ server/src/lib/hrworksSync.js | 6 ++-- server/test/calendar.test.mjs | 16 ++++++++- server/test/hrworks.test.mjs | 6 ++++ 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/server/src/lib/calendarSync.js b/server/src/lib/calendarSync.js index f9f4d49..c948575 100644 --- a/server/src/lib/calendarSync.js +++ b/server/src/lib/calendarSync.js @@ -1,6 +1,6 @@ import { pool, withTransaction } from '../db.js'; import { getSettings, saveSetting } from './settings.js'; -import { addDays, today } from './dates.js'; +import { addDays, germanHolidayName, today } from './dates.js'; import { Hrworks, ABSENCE_STATES } from './hrworks.js'; import { chunks, matchUsers } from './hrworksSync.js'; @@ -22,13 +22,15 @@ import { chunks, matchUsers } from './hrworksSync.js'; /** Wie weit reichen lange Urlaube vor das Fenster zurueck? Fuer die Artabfrage. */ const LOOKBACK_DAYS = 90; +/** HRworks: "The maximum date interval for this action is one year." */ +const ABSENCES_MAX_DAYS = 365; /** * Was steht an einem Tag im Kalender? Feiertag vor Urlaub; Wochenenden und * freie Tage (Soll 0) tragen keinen Urlaub. */ export function kalenderEintrag(day, meta, types, typeMap = {}) { - if (day.holiday) return { kind: 'holiday', label: day.holiday_name || 'Feiertag', half: false }; + if (day.holiday) return { kind: 'holiday', label: day.holiday_name || germanHolidayName(day.date) || 'Feiertag', half: false }; if (!(day.regular_hours > 0)) return null; for (const id of day.absences) { const info = meta.get(id); @@ -63,8 +65,13 @@ export async function syncCalendar({ dryRun = true, from, to } = {}) { 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); - const absences = await hr.absences(addDays(von, -LOOKBACK_DAYS), bis); - const meta = new Map(absences.map((a) => [a.number, { type: String(a.type), name: a.name, status: a.statusIdentifier }])); + // /absences erlaubt hoechstens ein Jahr je Aufruf. + const meta = new Map(); + for (const [start, end] of chunks(addDays(von, -LOOKBACK_DAYS), bis, ABSENCES_MAX_DAYS)) { + for (const a of await hr.absences(start, end)) { + meta.set(a.number, { type: String(a.type), name: a.name, status: a.statusIdentifier }); + } + } // Je Person und Tag ein Eintrag - auch wenn zwei HRworks-Konten auf // denselben Pulse-Benutzer zeigen. diff --git a/server/src/lib/dates.js b/server/src/lib/dates.js index ae7def4..9a7f786 100644 --- a/server/src/lib/dates.js +++ b/server/src/lib/dates.js @@ -114,3 +114,65 @@ export function periodLabel(granularity, from) { if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`; return y; } + +/** Ostersonntag (Gauss/Meeus, gregorianisch) als 'YYYY-MM-DD'. */ +export function easterSunday(year) { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * Name eines deutschen Feiertags zum Datum. HRworks sagt nur, DASS ein Tag + * Feiertag ist (welcher, haengt am Standort der Person); der Name kommt von + * hier. Unbekannt: null. + */ +export function germanHolidayName(value) { + const year = Number(value.slice(0, 4)); + const fixed = { + '01-01': 'Neujahr', + '01-06': 'Heilige Drei Könige', + '03-08': 'Frauentag', + '05-01': 'Tag der Arbeit', + '08-08': 'Friedensfest', + '08-15': 'Mariä Himmelfahrt', + '09-20': 'Weltkindertag', + '10-03': 'Tag der Deutschen Einheit', + '10-31': 'Reformationstag', + '11-01': 'Allerheiligen', + '12-24': 'Heiligabend', + '12-25': '1. Weihnachtstag', + '12-26': '2. Weihnachtstag', + '12-31': 'Silvester', + }; + if (fixed[value.slice(5)]) return fixed[value.slice(5)]; + const easter = easterSunday(year); + const offset = daysBetween(easter, value); + const moving = { + '-2': 'Karfreitag', + 0: 'Ostersonntag', + 1: 'Ostermontag', + 39: 'Christi Himmelfahrt', + 49: 'Pfingstsonntag', + 50: 'Pfingstmontag', + 60: 'Fronleichnam', + }; + if (moving[offset]) return moving[offset]; + // Buss- und Bettag: Mittwoch vor dem 23. November. + const nov23 = parseDate(`${year}-11-23`); + const back = ((nov23.getUTCDay() - 3 + 7) % 7) || 7; + if (value === addDays(`${year}-11-23`, -back)) return 'Buß- und Bettag'; + return null; +} diff --git a/server/src/lib/hrworksSync.js b/server/src/lib/hrworksSync.js index 2e22841..7bd9b38 100644 --- a/server/src/lib/hrworksSync.js +++ b/server/src/lib/hrworksSync.js @@ -56,12 +56,12 @@ export function matchUsers(persons, users) { return { matched, unmatched }; } -/** Tage in Abschnitte von hoechstens CHUNK_DAYS zerlegen. */ -export function chunks(from, to) { +/** Tage in Abschnitte von hoechstens `size` Tagen zerlegen. */ +export function chunks(from, to, size = CHUNK_DAYS) { const out = []; let start = from; while (start <= to) { - let end = addDays(start, CHUNK_DAYS - 1); + let end = addDays(start, size - 1); if (end > to) end = to; out.push([start, end]); start = addDays(end, 1); diff --git a/server/test/calendar.test.mjs b/server/test/calendar.test.mjs index 7be760f..508105b 100644 --- a/server/test/calendar.test.mjs +++ b/server/test/calendar.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { kalenderEintrag } from '../src/lib/calendarSync.js'; import { holidayName } from '../src/lib/hrworks.js'; +import { easterSunday, germanHolidayName } from '../src/lib/dates.js'; const tag = (extra = {}) => ({ holiday: false, holiday_name: null, absences: [], sick_leaves: [], regular_hours: 8, available_hours: 0, ...extra }); const meta = new Map([ @@ -29,7 +30,8 @@ test('Kalender: Feiertag vor Urlaub, Wochenende ohne Urlaub, halbe Tage', () => label: 'Tag der Deutschen Einheit', half: false, }); - assert.equal(kalenderEintrag(tag({ holiday: true }), meta, types).label, 'Feiertag'); + assert.equal(kalenderEintrag(tag({ holiday: true, date: '2026-07-14' }), meta, types).label, 'Feiertag'); + assert.equal(kalenderEintrag(tag({ holiday: true, date: '2026-10-03' }), meta, types).label, 'Tag der Deutschen Einheit'); assert.equal(kalenderEintrag(tag({ absences: ['30_1'], regular_hours: 0 }), meta, types), null); assert.equal(kalenderEintrag(tag({ absences: ['30_1'], available_hours: 4 }), meta, types).half, true); }); @@ -47,3 +49,15 @@ test('Feiertagsname aus unterschiedlichen Formen', () => { assert.equal(holidayName([{}]), null); assert.equal(holidayName(undefined), null); }); + +test('Feiertagsnamen aus dem Datum', () => { + assert.equal(easterSunday(2026), '2026-04-05'); + assert.equal(easterSunday(2027), '2027-03-28'); + assert.equal(germanHolidayName('2026-04-03'), 'Karfreitag'); + assert.equal(germanHolidayName('2026-05-14'), 'Christi Himmelfahrt'); + assert.equal(germanHolidayName('2026-05-25'), 'Pfingstmontag'); + assert.equal(germanHolidayName('2026-06-04'), 'Fronleichnam'); + assert.equal(germanHolidayName('2026-11-18'), 'Buß- und Bettag'); + assert.equal(germanHolidayName('2026-12-25'), '1. Weihnachtstag'); + assert.equal(germanHolidayName('2026-07-14'), null); +}); diff --git a/server/test/hrworks.test.mjs b/server/test/hrworks.test.mjs index 75f86a2..d5c418f 100644 --- a/server/test/hrworks.test.mjs +++ b/server/test/hrworks.test.mjs @@ -66,3 +66,9 @@ test('nur genehmigte Abwesenheiten, bei Krankheit auch offene Bescheinigungen', assert.ok(!SICK_STATES.includes('cancelled')); assert.ok(!ABSENCE_STATES.includes('requested')); }); + +test('Abschnitte mit eigener Groesse (Abwesenheiten: hoechstens ein Jahr)', () => { + const teile = chunks('2025-10-01', '2027-11-01', 365); + assert.equal(teile.length, 3); + for (const [von, bis] of teile) assert.ok((new Date(bis) - new Date(von)) / 86400000 + 1 <= 365); +});