Arbeitszeit: Zeitbuchungen anlegen, aendern und loeschen

createZeitbuchung, updateZeitbuchung und deleteZeitbuchung buchen
Taetigkeitsbloecke und Pausen ueber module/arbeitszeit/api/. Die erste
Buchung eines Tages legt per nachtragen.php die Schicht an, weitere
Buchungen kommen in die vorhandene Schicht, die bei Bedarf verlaengert
wird. Die Kostenstelle wird an die Notiz gehaengt. Unbekannte
Taetigkeiten werden vorher abgelehnt, weil Agrarmonitor sie ohne Fehler
speichert. Ablehnungen kommen als ZeitbuchungError mit Grund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-10-14 11:42:57 +02:00
1 parent 6db6c5b278
commit d703e46125
21 files changed
+1920 -16

No files matched your search

+439
View File
@@ -8,10 +8,20 @@ const axios_1 = __importDefault(require("axios"));
const axios_cookiejar_support_1 = require("axios-cookiejar-support");
const jsdom_1 = require("jsdom");
const tough_cookie_1 = require("tough-cookie");
const ZeitbuchungError_1 = require("./ZeitbuchungError");
class AgrarmonitorConnector {
options;
http;
static maxLivesearchSeiten = 500;
static berlinFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
static s3DateienBaseUrl = 'https://s3-eu-central-1.amazonaws.com/dateien.agrarmonitor.de/07';
baseUrl;
apiBaseUrl;
@@ -474,6 +484,435 @@ class AgrarmonitorConnector {
}
return Array.from(taetigkeiten.values());
}
/**
* Bucht einen Taetigkeitsblock oder eine Pause. Gibt es fuer Mitarbeiter und
* Arbeitstag noch keine Schicht, legt `nachtragen.php` Schicht und Verteilung
* an (Pausen brauchen eine vorhandene Schicht). Sonst kommt die Buchung in die
* passende Schicht, die bei Bedarf verlaengert wird.
*/
async createZeitbuchung(daten) {
const buchung = this.zeitbuchungAufbereiten(daten);
const schichten = await this.ladeSchichten(buchung.mitarbeiterId, buchung.datum);
if (schichten.length === 0) {
if (buchung.art === 'pause') {
throw new ZeitbuchungError_1.ZeitbuchungError(`Pause am ${buchung.datum} kann nicht gebucht werden: keine Schicht vorhanden`, 'keine Schicht vorhanden');
}
const id = await this.taetigkeitNachtragen(buchung);
return { ref: this.zeitbuchungRef(buchung, id) };
}
const schicht = this.passendeSchicht(schichten, buchung);
const schichtVorher = { ...schicht };
await this.schichtErweitern(schicht, buchung);
try {
const id = buchung.art === 'pause'
? await this.pauseAnlegen(buchung, schicht.id)
: await this.verteilungAnlegen(buchung, schicht.id);
return { ref: this.zeitbuchungRef(buchung, id) };
}
catch (error) {
if (schicht.start !== schichtVorher.start || schicht.ende !== schichtVorher.ende) {
await this.schichtSetzen(schichtVorher).catch(restoreError => {
this.logger?.warn?.('Schicht konnte nicht zurueckgesetzt werden', {
schichtId: schichtVorher.id,
fehler: restoreError instanceof Error ? restoreError.message : String(restoreError),
});
});
}
throw error;
}
}
async updateZeitbuchung(ref, daten) {
const zielRef = this.parseZeitbuchungRef(ref);
const buchung = this.zeitbuchungAufbereiten(daten);
if (buchung.art !== zielRef.art) {
throw new ZeitbuchungError_1.ZeitbuchungError('Art der Buchung (Taetigkeit/Pause) kann nicht geaendert werden', 'art geaendert');
}
if (buchung.mitarbeiterId !== zielRef.mitarbeiterId || buchung.datum !== zielRef.datum) {
throw new ZeitbuchungError_1.ZeitbuchungError('Mitarbeiter oder Arbeitstag einer Buchung kann nicht geaendert werden', 'mitarbeiter oder datum geaendert');
}
const tagesansicht = await this.ladeTagesansicht(zielRef.mitarbeiterId, zielRef.datum);
const zeilenKlasse = zielRef.art === 'pause' ? 'pause_betrieb' : 'betrieb';
const schichtId = this.parseNumber(tagesansicht
.querySelector(`tr.${zeilenKlasse}[id="${zielRef.id}"]`)
?.closest('table.verteilung_table')
?.getAttribute('data-schicht'));
if (!schichtId) {
throw new ZeitbuchungError_1.ZeitbuchungError(`Buchung ${ref} nicht gefunden`, 'nicht gefunden');
}
const schicht = await this.ladeSchicht(schichtId);
if (schicht) {
await this.schichtErweitern(schicht, buchung);
}
if (buchung.art === 'pause') {
await this.pauseSpeichern(buchung, zielRef.id, 0);
return;
}
const formular = await this.ladeVerteilungFormular({
employeeID: zielRef.mitarbeiterId,
date: zielRef.datum,
distributionID: String(zielRef.id),
});
this.taetigkeitPruefen(formular, buchung.taetigkeitId);
const notizen = Array.from(formular.querySelectorAll('#notizen_table tbody tr[data-id]'))
.map(row => ({
id: this.parseNumber(row.getAttribute('data-id')),
text: this.normalisiereText(row.querySelectorAll('td')[2]?.textContent ?? ''),
}))
.filter(notiz => notiz.id > 0);
const notizUnveraendert = (notizen.length === 0 && !buchung.notiz) ||
(notizen.length === 1 && notizen[0].text === this.normalisiereText(buchung.notiz));
await this.verteilungSpeichern(buchung, zielRef.id, {
alteTaetigkeitId: this.selectedValue(formular, '#taetigkeit_id') || '0',
projektId: this.selectedValue(formular, '#projekt_id') || '0',
verfasserId: formular.querySelector('#verfasser_id')?.value ?? '',
notiz: notizUnveraendert ? '' : buchung.notiz,
});
// Alte Notizen erst nach erfolgreichem Speichern entfernen.
if (!notizUnveraendert) {
for (const notiz of notizen) {
await this.zeitbuchungPost('/module/notiz/api/delete.php', { noteID: notiz.id }, 'Notiz loeschen');
}
}
}
async deleteZeitbuchung(ref) {
const zielRef = this.parseZeitbuchungRef(ref);
if (zielRef.art === 'pause') {
await this.zeitbuchungPost('/module/arbeitszeit/api/deletePause.php', { pauseID: zielRef.id, employeeID: zielRef.mitarbeiterId, date: zielRef.datum }, 'Pause loeschen');
return;
}
await this.zeitbuchungPost('/module/arbeitszeit/api/deleteVerteilung.php', { distributionID: zielRef.id, employeeID: zielRef.mitarbeiterId, date: zielRef.datum }, 'Buchung loeschen');
}
zeitbuchungAufbereiten(daten) {
if (!Number.isInteger(daten.mitarbeiterId) || daten.mitarbeiterId <= 0) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Mitarbeiter-ID', 'mitarbeiterId');
}
if (!(daten.start instanceof Date) || Number.isNaN(daten.start.getTime())) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiger Start', 'start');
}
if (!(daten.ende instanceof Date) || Number.isNaN(daten.ende.getTime())) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiges Ende', 'ende');
}
if (daten.datum !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(daten.datum)) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiger Arbeitstag, erwartet YYYY-MM-DD', 'datum');
}
const start = this.zeitpunkt(daten.start);
const ende = this.zeitpunkt(daten.ende);
if (ende.vergleich <= start.vergleich) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ende muss mindestens eine Minute nach dem Start liegen', 'dauer');
}
const basis = {
mitarbeiterId: daten.mitarbeiterId,
datum: daten.datum ?? start.datum,
start,
ende,
};
if (daten.art === 'pause') {
return { ...basis, art: 'pause', taetigkeitId: 0, maschinenId: 0, notiz: '' };
}
if (!Number.isInteger(daten.taetigkeitId) || daten.taetigkeitId <= 0) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Taetigkeits-ID', 'taetigkeitId');
}
const maschinenId = daten.maschinenId ?? 0;
if (!Number.isInteger(maschinenId) || maschinenId < 0) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Maschinen-ID', 'maschinenId');
}
const kostenstelle = daten.kostenstelle?.trim();
const notiz = [daten.notiz?.trim(), kostenstelle ? `Kostenstelle: ${kostenstelle}` : '']
.filter(Boolean)
.join('\n');
return { ...basis, art: 'taetigkeit', taetigkeitId: daten.taetigkeitId, maschinenId, notiz };
}
/** Rundet auf die volle Minute - Agrarmonitor speichert keine Sekunden. */
zeitpunkt(date) {
const gerundet = new Date(Math.round(date.getTime() / 60000) * 60000);
const teile = Object.fromEntries(AgrarmonitorConnector.berlinFormat.formatToParts(gerundet).map(teil => [teil.type, teil.value]));
const datum = `${teile.year}-${teile.month}-${teile.day}`;
const uhrzeit = `${teile.hour}:${teile.minute}`;
return {
vergleich: `${datum} ${uhrzeit}`,
formular: this.vergleichZuFormular(`${datum} ${uhrzeit}`),
datum,
uhrzeit,
};
}
/** YYYY-MM-DD HH:MM -> dd.mm.yy (HH:MM) */
vergleichZuFormular(wert) {
const [datum, uhrzeit] = wert.split(' ');
const [jahr, monat, tag] = datum.split('-');
return `${tag}.${monat}.${jahr.slice(2)} (${uhrzeit})`;
}
zeitbuchungRef(buchung, id) {
return `${buchung.art === 'pause' ? 'pause' : 'verteilung'}:${buchung.mitarbeiterId}:${buchung.datum}:${id}`;
}
parseZeitbuchungRef(ref) {
const match = /^(verteilung|pause):(\d+):(\d{4}-\d{2}-\d{2}):(\d+)$/.exec(ref);
if (!match) {
throw new ZeitbuchungError_1.ZeitbuchungError(`Ungueltige Zeitbuchungs-Referenz: ${ref}`, 'ref');
}
return {
art: match[1] === 'pause' ? 'pause' : 'taetigkeit',
mitarbeiterId: Number(match[2]),
datum: match[3],
id: Number(match[4]),
};
}
async ladeTagesansicht(mitarbeiterId, datum) {
const response = await this.http.get(`/arbeitszeit/detail/${mitarbeiterId}/${datum}`);
await this.saveSession();
return this.parseHtmlDocument(response.data);
}
async ladeSchichten(mitarbeiterId, datum) {
const tagesansicht = await this.ladeTagesansicht(mitarbeiterId, datum);
const ids = new Set(Array.from(tagesansicht.querySelectorAll('table.verteilung_table[data-schicht]'))
.map(table => this.parseNumber(table.getAttribute('data-schicht')))
.filter(id => id > 0));
const schichten = [];
for (const id of ids) {
const schicht = await this.ladeSchicht(id);
if (schicht) {
schichten.push(schicht);
}
}
return schichten;
}
async ladeSchicht(schichtId) {
const response = await this.http.get('/module/arbeitszeit/api/getSchicht.php', {
params: { schicht_id: schichtId },
});
await this.saveSession();
const data = (typeof response.data === 'string' ? JSON.parse(response.data || 'null') : response.data);
if (!data?.timestamp_start || Number(data.ist_deleted) === 1) {
return null;
}
const start = data.timestamp_start.slice(0, 16);
const ende = data.timestamp_ende && !data.timestamp_ende.startsWith('0000') ? data.timestamp_ende.slice(0, 16) : start;
return { id: schichtId, start, ende };
}
/** Schicht, die sich mit der Buchung ueberschneidet, sonst die letzte davor, sonst die erste. */
passendeSchicht(schichten, buchung) {
return (schichten.find(schicht => schicht.start <= buchung.ende.vergleich && buchung.start.vergleich <= schicht.ende) ??
schichten.filter(schicht => schicht.start <= buchung.start.vergleich).at(-1) ??
schichten[0]);
}
/** Neue Verteilungen verlaengern die Schicht in Agrarmonitor nicht von selbst. */
async schichtErweitern(schicht, buchung) {
const start = buchung.start.vergleich < schicht.start ? buchung.start.vergleich : schicht.start;
const ende = buchung.ende.vergleich > schicht.ende ? buchung.ende.vergleich : schicht.ende;
if (start === schicht.start && ende === schicht.ende) {
return;
}
await this.schichtSetzen({ id: schicht.id, start, ende });
schicht.start = start;
schicht.ende = ende;
}
async schichtSetzen(schicht) {
await this.zeitbuchungPost('/module/arbeitszeit/api/updateSchicht.php', {
shiftID: schicht.id,
timestampStart: this.vergleichZuFormular(schicht.start),
timestampEnd: this.vergleichZuFormular(schicht.ende),
}, 'Schicht anpassen');
}
/**
* Erste Buchung des Tages: nachtragen.php legt Schicht und Verteilung an,
* liefert aber keine ID. Die Verteilung wird danach in der Tagesansicht gesucht.
*/
async taetigkeitNachtragen(buchung) {
const nachtragenFormular = await this.http.get('/module/arbeitszeit/api/nachtragen-form.php');
await this.saveSession();
this.taetigkeitPruefen(this.parseHtmlDocument(nachtragenFormular.data), buchung.taetigkeitId);
const amArbeitstag = buchung.start.datum === buchung.datum && buchung.ende.datum === buchung.datum;
const form = {
taetigkeit_id: String(buchung.taetigkeitId),
projekt_id: '0',
arbeitszeit_start: buchung.start.uhrzeit,
// Nachtragen kennt nur Uhrzeiten; ueber Mitternacht wird unten korrigiert.
arbeitszeit_ende: amArbeitstag ? buchung.ende.uhrzeit : '23:59',
arbeitzeit_sollstunden: '',
pause_start: '',
pause_ende: '',
maschine_id: String(buchung.maschinenId),
notiz: buchung.notiz,
uebernehmen: '1',
mitarbeitertyp: '1',
mitarbeiter_id: String(buchung.mitarbeiterId),
kategorie_id: '0',
};
const body = new URLSearchParams();
for (const [name, value] of Object.entries(form)) {
body.append(`form[${name}]`, value);
}
body.append('tage[]', buchung.datum);
body.append('anzahl_datum', '1');
body.append('planungAusMitarbeiterId', '0');
const response = await this.http.post('/module/arbeitszeit/api/nachtragen.php', body, {
...this.formPostConfig('/arbeitszeit'),
validateStatus: status => status >= 200 && status < 500,
});
await this.saveSession();
this.zeitbuchungAntwortPruefen(response, 'Nachtragen');
const tagesansicht = await this.ladeTagesansicht(buchung.mitarbeiterId, buchung.datum);
const zeilen = Array.from(tagesansicht.querySelectorAll('table.verteilung_table tr.betrieb[id]'));
if (zeilen.length !== 1) {
throw new ZeitbuchungError_1.ZeitbuchungError(`Nachgetragene Buchung am ${buchung.datum} nicht eindeutig gefunden (${zeilen.length} Buchungen)`, 'nicht eindeutig');
}
const id = this.parseNumber(zeilen[0].id);
if (!amArbeitstag) {
const formular = await this.ladeVerteilungFormular({
employeeID: buchung.mitarbeiterId,
date: buchung.datum,
distributionID: String(id),
});
await this.verteilungSpeichern(buchung, id, {
alteTaetigkeitId: String(buchung.taetigkeitId),
projektId: '0',
verfasserId: formular.querySelector('#verfasser_id')?.value ?? '',
notiz: '',
});
const schicht = await this.ladeSchicht(this.parseNumber(zeilen[0].closest('table.verteilung_table')?.getAttribute('data-schicht')));
if (schicht) {
await this.schichtErweitern(schicht, buchung);
}
}
return id;
}
/**
* editVerteilungForm.php mit shiftID legt sofort eine leere Verteilung an
* (das Agrarmonitor-UI loescht sie bei "Abbrechen"). Schlaegt das Speichern
* fehl, wird dieser Platzhalter wieder entfernt.
*/
async verteilungAnlegen(buchung, schichtId) {
const formular = await this.ladeVerteilungFormular({
employeeID: buchung.mitarbeiterId,
date: buchung.datum,
shiftID: schichtId,
});
const id = this.parseNumber(formular.querySelector('#verteilung_form')?.getAttribute('data-id'));
if (!id) {
throw new ZeitbuchungError_1.ZeitbuchungError('Agrarmonitor hat keine neue Buchung angelegt', 'keine verteilung_id');
}
try {
this.taetigkeitPruefen(formular, buchung.taetigkeitId);
await this.verteilungSpeichern(buchung, id, {
alteTaetigkeitId: '0',
projektId: '0',
verfasserId: formular.querySelector('#verfasser_id')?.value ?? '',
notiz: buchung.notiz,
});
}
catch (error) {
await this.zeitbuchungPost('/module/arbeitszeit/api/deleteVerteilung.php', { distributionID: id, employeeID: buchung.mitarbeiterId, date: buchung.datum }, 'Platzhalter loeschen').catch(() => undefined);
throw error;
}
return id;
}
async ladeVerteilungFormular(body) {
const html = await this.zeitbuchungPost('/module/arbeitszeit/api/editVerteilungForm.php', body, 'Buchungsformular laden');
const formular = this.parseHtmlDocument(html);
if (!formular.querySelector('#verteilung_form')) {
throw new ZeitbuchungError_1.ZeitbuchungError('Buchungsformular nicht gefunden', 'kein verteilung_form');
}
return formular;
}
async verteilungSpeichern(buchung, id, formular) {
await this.zeitbuchungPost('/module/arbeitszeit/api/updateVerteilung.php', {
mitarbeiterId: buchung.mitarbeiterId,
verteilungId: String(id),
verteilungForm: {
timestamp_start: buchung.start.formular,
timestamp_ende: buchung.ende.formular,
alte_taetigkeit_id: formular.alteTaetigkeitId,
taetigkeit_id: String(buchung.taetigkeitId),
ganzertag: '1',
maschine_id: String(buchung.maschinenId),
projekt_id: formular.projektId,
},
// Nicht leere notizfeld werden als neue Notiz an die Verteilung gehaengt.
notizForm: {
notizfeld: formular.notiz,
verfasser_id: formular.verfasserId,
},
}, 'Buchung speichern');
}
/** Wie bei Verteilungen legt editPauseForm.php mit shiftID sofort eine Pause an. */
async pauseAnlegen(buchung, schichtId) {
const html = await this.zeitbuchungPost('/module/arbeitszeit/api/editPauseForm.php', { employeeID: buchung.mitarbeiterId, date: buchung.datum, shiftID: schichtId }, 'Pausenformular laden');
const id = this.parseNumber(this.parseHtmlDocument(html).querySelector('#pause_form')?.getAttribute('data-id'));
if (!id) {
throw new ZeitbuchungError_1.ZeitbuchungError('Agrarmonitor hat keine neue Pause angelegt', 'keine pause_id');
}
try {
await this.pauseSpeichern(buchung, id, 1);
}
catch (error) {
await this.zeitbuchungPost('/module/arbeitszeit/api/deletePause.php', { pauseID: id, employeeID: buchung.mitarbeiterId, date: buchung.datum }, 'Platzhalter loeschen').catch(() => undefined);
throw error;
}
return id;
}
async pauseSpeichern(buchung, id, neu) {
await this.zeitbuchungPost('/module/arbeitszeit/api/updatePause.php', {
mitarbeiterId: buchung.mitarbeiterId,
pause_id: String(id),
pauseForm: {
pause_start: buchung.start.formular,
pause_ende: buchung.ende.formular,
},
neu,
}, 'Pause speichern');
}
/**
* JSON-POST an die Arbeitszeit-API. 4xx (z. B. 422 mit Grund im Body) wird
* als ZeitbuchungError geworfen; Netzwerk- und 5xx-Fehler bleiben Axios-Fehler.
*/
async zeitbuchungPost(url, body, aktion) {
const response = await this.http.post(url, body, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, */*',
'X-Requested-With': 'XMLHttpRequest',
Referer: `${this.baseUrl}/arbeitszeit`,
Origin: this.baseUrl,
},
validateStatus: status => status >= 200 && status < 500,
});
await this.saveSession();
this.zeitbuchungAntwortPruefen(response, aktion);
return response.data;
}
zeitbuchungAntwortPruefen(response, aktion) {
if (response.status >= 200 && response.status < 300) {
return;
}
const grund = this.zeitbuchungGrund(response.data);
throw new ZeitbuchungError_1.ZeitbuchungError(`${aktion} fehlgeschlagen (HTTP ${response.status}): ${grund}`, grund, response.status);
}
/** 422-Antworten sehen so aus: {"verteilungForm":"ganzertag must be present"} */
zeitbuchungGrund(data) {
if (data && typeof data === 'object') {
const texte = Object.values(data)
.flatMap(wert => (wert && typeof wert === 'object' ? Object.values(wert) : [wert]))
.filter((wert) => typeof wert === 'string' && wert.trim() !== '');
return texte.length ? texte.join('; ') : JSON.stringify(data).slice(0, 500);
}
return String(data ?? '').trim().slice(0, 500) || 'keine Antwort';
}
/**
* Agrarmonitor speichert auch unbekannte oder inaktive Taetigkeits-IDs (dann
* ohne Taetigkeit). Deshalb gegen die Auswahl im Buchungsformular pruefen.
*/
taetigkeitPruefen(formular, taetigkeitId) {
const buchbar = Array.from(formular.querySelectorAll('select#taetigkeit_id option')).map(option => option.value);
if (!buchbar.includes(String(taetigkeitId))) {
throw new ZeitbuchungError_1.ZeitbuchungError(`Taetigkeit ${taetigkeitId} ist in Agrarmonitor nicht buchbar (unbekannt oder inaktiv)`, 'taetigkeit nicht buchbar');
}
}
selectedValue(document, selector) {
const select = document.querySelector(selector);
return select?.querySelector('option[selected]')?.value ?? select?.value ?? '';
}
normalisiereText(text) {
return text.replace(/\s+/g, ' ').trim();
}
/**
* Laedt eine Livesearch-Liste Seite fuer Seite. Die Seitenzahl steht in
* `.pagination[data-pages]`; zusaetzlich bricht die Schleife ab, sobald eine