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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c0eb00faee
commit
b6ba7860bf
@@ -1,6 +1,6 @@
|
|||||||
import { pool, withTransaction } from '../db.js';
|
import { pool, withTransaction } from '../db.js';
|
||||||
import { getSettings, saveSetting } from './settings.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 { Hrworks, ABSENCE_STATES } from './hrworks.js';
|
||||||
import { chunks, matchUsers } from './hrworksSync.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. */
|
/** Wie weit reichen lange Urlaube vor das Fenster zurueck? Fuer die Artabfrage. */
|
||||||
const LOOKBACK_DAYS = 90;
|
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
|
* Was steht an einem Tag im Kalender? Feiertag vor Urlaub; Wochenenden und
|
||||||
* freie Tage (Soll 0) tragen keinen Urlaub.
|
* freie Tage (Soll 0) tragen keinen Urlaub.
|
||||||
*/
|
*/
|
||||||
export function kalenderEintrag(day, meta, types, typeMap = {}) {
|
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;
|
if (!(day.regular_hours > 0)) return null;
|
||||||
for (const id of day.absences) {
|
for (const id of day.absences) {
|
||||||
const info = meta.get(id);
|
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 [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 { matched, unmatched } = matchUsers(persons, users);
|
||||||
|
|
||||||
const absences = await hr.absences(addDays(von, -LOOKBACK_DAYS), bis);
|
// /absences erlaubt hoechstens ein Jahr je Aufruf.
|
||||||
const meta = new Map(absences.map((a) => [a.number, { type: String(a.type), name: a.name, status: a.statusIdentifier }]));
|
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
|
// Je Person und Tag ein Eintrag - auch wenn zwei HRworks-Konten auf
|
||||||
// denselben Pulse-Benutzer zeigen.
|
// denselben Pulse-Benutzer zeigen.
|
||||||
|
|||||||
@@ -114,3 +114,65 @@ export function periodLabel(granularity, from) {
|
|||||||
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
|
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
|
||||||
return y;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ export function matchUsers(persons, users) {
|
|||||||
return { matched, unmatched };
|
return { matched, unmatched };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tage in Abschnitte von hoechstens CHUNK_DAYS zerlegen. */
|
/** Tage in Abschnitte von hoechstens `size` Tagen zerlegen. */
|
||||||
export function chunks(from, to) {
|
export function chunks(from, to, size = CHUNK_DAYS) {
|
||||||
const out = [];
|
const out = [];
|
||||||
let start = from;
|
let start = from;
|
||||||
while (start <= to) {
|
while (start <= to) {
|
||||||
let end = addDays(start, CHUNK_DAYS - 1);
|
let end = addDays(start, size - 1);
|
||||||
if (end > to) end = to;
|
if (end > to) end = to;
|
||||||
out.push([start, end]);
|
out.push([start, end]);
|
||||||
start = addDays(end, 1);
|
start = addDays(end, 1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { kalenderEintrag } from '../src/lib/calendarSync.js';
|
import { kalenderEintrag } from '../src/lib/calendarSync.js';
|
||||||
import { holidayName } from '../src/lib/hrworks.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 tag = (extra = {}) => ({ holiday: false, holiday_name: null, absences: [], sick_leaves: [], regular_hours: 8, available_hours: 0, ...extra });
|
||||||
const meta = new Map([
|
const meta = new Map([
|
||||||
@@ -29,7 +30,8 @@ test('Kalender: Feiertag vor Urlaub, Wochenende ohne Urlaub, halbe Tage', () =>
|
|||||||
label: 'Tag der Deutschen Einheit',
|
label: 'Tag der Deutschen Einheit',
|
||||||
half: false,
|
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'], regular_hours: 0 }), meta, types), null);
|
||||||
assert.equal(kalenderEintrag(tag({ absences: ['30_1'], available_hours: 4 }), meta, types).half, true);
|
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([{}]), null);
|
||||||
assert.equal(holidayName(undefined), 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,3 +66,9 @@ test('nur genehmigte Abwesenheiten, bei Krankheit auch offene Bescheinigungen',
|
|||||||
assert.ok(!SICK_STATES.includes('cancelled'));
|
assert.ok(!SICK_STATES.includes('cancelled'));
|
||||||
assert.ok(!ABSENCE_STATES.includes('requested'));
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user