Files
AgrarmonitorConnector/dist/AgrarmonitorConnector.js
T
bjoernpoettkerandClaude Opus 5.5 659fdb5293 Arbeitszeit: Arbeitstag beginnen, beenden, aendern und loeschen
beginneArbeitstag legt per saveSchicht.php eine laufende Schicht fuer
beliebige Mitarbeiter an (der Menuepunkt "Arbeitstag beginnen" wirkt nur
fuer den angemeldeten Benutzer). Damit kann auch eine Pause die erste
Buchung des Tages sein. Zeitbuchungen lassen laufende Schichten offen
und ziehen nur den Beginn bei Bedarf vor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:54:58 +02:00

1591 lines
71 KiB
JavaScript

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AgrarmonitorConnector = void 0;
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;
timeoutMs;
autoLogin;
autoRetry;
loginStrategy;
logger;
cookieJar;
apiHttp;
loginInProgress = null;
constructor(options) {
this.options = options;
this.baseUrl = options.baseUrl ?? 'https://admin7.agrarmonitor.de';
this.apiBaseUrl = this.normalizeApiBaseUrl(options.apiBaseUrl ?? 'https://api.agrarmonitor.de/v1');
this.timeoutMs = options.timeoutMs ?? 15000;
this.autoLogin = options.autoLogin ?? true;
this.autoRetry = options.autoRetry ?? true;
this.loginStrategy = options.loginStrategy ?? 'redirect';
this.logger = options.logger;
}
async init() {
this.cookieJar = await this.options.cookieStore.load();
this.http = this.createHttpClient();
this.apiHttp = this.createApiHttpClient();
if (this.autoLogin) {
const valid = await this.isSessionValid();
if (!valid) {
await this.login();
}
}
return this;
}
async login() {
if (this.loginInProgress) {
return this.loginInProgress;
}
this.loginInProgress = this.performLogin().finally(() => {
this.loginInProgress = null;
});
return this.loginInProgress;
}
async clearSession() {
this.cookieJar = new tough_cookie_1.CookieJar();
await this.options.cookieStore.clear();
this.http = this.createHttpClient();
this.apiHttp = this.createApiHttpClient();
}
async saveSession() {
await this.options.cookieStore.save(this.cookieJar);
}
async getCookieCount(url = this.baseUrl) {
return this.cookieJar.getCookiesSync(url).length;
}
async checkFreigeschaltet() {
const response = await this.http.get('/', {
maxRedirects: 0,
validateStatus: status => status >= 200 && status < 400,
});
await this.saveSession();
const redirectLocation = this.getHeader(response, 'location');
const redirected = response.status >= 300 && response.status < 400 && this.isFreischaltungUrl(redirectLocation);
return {
freigeschaltet: !redirected,
status: response.status,
redirected,
redirectLocation,
timestamp: new Date().toISOString(),
cookies: await this.getCookieCount(),
};
}
async checkRegistriert() {
const response = await this.http.get('/', {
maxRedirects: 5,
validateStatus: status => status >= 200 && status < 500,
});
await this.saveSession();
const pageContent = typeof response.data === 'string' ? response.data : '';
const hasRegistrationText = pageContent.includes('Neues Gerät registrieren');
return {
registriert: !hasRegistrationText,
status: response.status,
hasRegistrationText,
timestamp: new Date().toISOString(),
cookies: await this.getCookieCount(),
};
}
async registerDevice(registration) {
const agrarmonitorId = registration.agrarmonitorId.trim();
const pcName = registration.pcName.trim();
if (!agrarmonitorId || !pcName) {
throw new Error('AgrarmonitorID und PC-Name sind erforderlich');
}
const freischaltungResponse = await this.http.get('/freischaltung/');
const responseContent = typeof freischaltungResponse.data === 'string' ? freischaltungResponse.data : '';
const nonce = this.extractNonce(responseContent, '#nonce, input[name="nonce"]');
const registerResponse = await this.http.post('/freischaltung/api/register.php', {
firma: agrarmonitorId,
name: pcName,
nonce,
}, {
headers: {
'Content-Type': 'application/json',
},
validateStatus: status => status >= 200 && status < 500,
});
await this.saveSession();
const success = registerResponse.status >= 200 && registerResponse.status < 300;
return {
success,
status: registerResponse.status,
message: success ? 'Registrierung erfolgreich' : 'Registrierung fehlgeschlagen',
data: {
agrarmonitorId,
pcName,
nonce: this.maskNonce(nonce),
},
timestamp: new Date().toISOString(),
cookies: await this.getCookieCount(),
};
}
async fetchCustomers(options = {}) {
const response = await this.apiRequest('/kunden', {
params: {
per_page: options.perPage ?? 99999,
},
apiToken: options.apiToken,
});
const responseData = response.data;
if (!responseData || !Array.isArray(responseData.data)) {
throw new Error('Ungueltige Agrarmonitor API-Antwort');
}
return responseData.data;
}
async getKunden2(options = {}) {
const customers = await this.fetchAllCustomers(options);
return customers.map(customer => this.mapKunde2(customer));
}
async eingangsrechnungenLivesearch(suchstring) {
const response = await this.http.get('/module/dateien/livesearch.php', {
params: this.createDateienLivesearchParams(suchstring),
});
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#dateien tbody tr'));
const results = [];
for (const row of rows) {
const cells = Array.from(row.querySelectorAll('td'));
const typText = cells[3]?.textContent?.trim() ?? '';
if (!typText.startsWith('Eingangsrechnungen')) {
continue;
}
const dokumentId = this.parseNumber(row.getAttribute('data-file_id'));
const dataFile = row.getAttribute('data-file') ?? '';
const dokumentName = cells[2]?.querySelector('b > a')?.textContent?.trim() ?? '';
const dateiName = cells[2]?.querySelector('span')?.textContent?.trim() ?? '';
const belegLink = cells[3]?.querySelector('a');
const belegTextParts = (belegLink?.textContent ?? '').split(',').map(part => part.trim()).filter(Boolean);
const belegNummer = belegTextParts[0] ?? '';
const belegDatum = this.parseGermanShortDateFromText(belegTextParts.at(-1) ?? '');
const eingangId = this.parseNumber(this.lastPathSegment(belegLink?.getAttribute('href') ?? ''));
const { interneBelegNummer, kundenId, betriebId, dokumentTyp } = await this.getEingangsrechnungEditMeta(eingangId);
const { eingangsDatum, buchungsDatum } = await this.getEingangsrechnungDetailMeta(eingangId);
results.push({
dokumentId,
vorschauUrl: `${AgrarmonitorConnector.s3DateienBaseUrl}/v_${this.fileBasename(dataFile)}.png`,
dokumentUrl: `${AgrarmonitorConnector.s3DateienBaseUrl}/${dataFile}`,
dokumentName,
dateiName,
belegNummer,
interneBelegNummer,
belegDatum,
buchungsDatum,
eingangsDatum,
eingangId,
kundenId,
betriebId,
dokumentTyp,
});
}
return results;
}
async eingangsrechnungVorhanden(suchstring) {
const response = await this.http.get('/module/dateien/livesearch.php', {
params: this.createDateienLivesearchParams(suchstring),
});
await this.saveSession();
return this.hasTableRows(response.data, 'table#dateien tbody tr');
}
async eingangsrechnungImDateieingangVorhanden(suchstring) {
const response = await this.http.get('/module/dateien/eingang/livesearch.php', {
params: {
suchstring,
seite: 1,
},
});
await this.saveSession();
return this.hasTableRows(response.data, 'table#dateien_eingang tbody tr');
}
async getRechnungsdaten(rechnungId) {
return this.rechnungsdatenAusFormular(await this.loadEingangsrechnungEditForm(rechnungId));
}
rechnungsdatenAusFormular(document) {
return {
lieferschein: this.inputValue(document, 'lieferscheinnummer'),
rechnung: this.inputValue(document, 'rechnungsnummer'),
datum: this.requireDate(this.parseGermanShortDate(this.inputValue(document, 'rechnungsdatum')), 'rechnungsdatum'),
kundenId: this.selectedNumberValue(document, 'rgempf'),
adresstext: this.inputValue(document, 'addressName'),
};
}
async setRechnungsdaten(rechnungId, daten) {
// update.php erwartet das komplette Formular. Deshalb das aktuelle Formular laden
// und nur die geaenderten Felder ueberschreiben, damit nichts geleert wird.
const document = await this.loadEingangsrechnungEditForm(rechnungId);
const formData = {
...this.serializeForm(document, 'form#rechnung-detail'),
lieferscheinnummer: daten.lieferschein,
rechnungsnummer: daten.rechnung,
rechnungsdatum: this.formatGermanShortDate(daten.datum),
rgempf: String(daten.kundenId),
addressName: daten.adresstext,
};
const response = await this.http.post('/module/eingangsrechnungen/api/update.php', { rechnungID: rechnungId, formData }, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, */*',
'X-Requested-With': 'XMLHttpRequest',
Referer: `${this.baseUrl}/eingangsrechnungen/detail/${rechnungId}`,
Origin: this.baseUrl,
},
// 4xx nicht als Axios-Fehler werfen: Agrarmonitor schickt den Grund (z. B. bei 422) im Body.
validateStatus: status => status >= 200 && status < 500,
});
await this.saveSession();
// Erfolg: leeres Array. Fehler: Objekt mit msg.
const msg = response.data?.msg;
const ok = response.status >= 200 && response.status < 300;
if ((typeof msg === 'string' && msg.trim()) || !ok) {
const grund = typeof msg === 'string' && msg.trim()
? msg.trim()
: (typeof response.data === 'string' ? response.data : JSON.stringify(response.data)).slice(0, 500);
throw new Error(`Rechnung ${rechnungId} konnte nicht gespeichert werden (HTTP ${response.status}): ${grund}`);
}
return true;
}
async setLieferscheinNummer(rechnungId, nummer) {
const rechnungsdaten = await this.getRechnungsdaten(rechnungId);
const success = await this.setRechnungsdaten(rechnungId, {
...rechnungsdaten,
lieferschein: nummer,
});
if (!success) {
throw new Error('Lieferscheinnummer konnte nicht gespeichert werden');
}
}
async setEingangsdatum(rechnungId, datum) {
const response = await this.http.post('/module/eingangsrechnungen/api/updateReceived.php', new URLSearchParams({
datum: this.formatGermanShortDate(datum),
receiptID: String(rechnungId),
}), this.formPostConfig(`/eingangsrechnungen/detail/${rechnungId}`));
await this.saveSession();
return response.status >= 200 && response.status < 300;
}
async getCustomerById(id) {
const response = await this.apiRequest(`/kunden/${id}`);
this.logDebug('Agrarmonitor customer API raw response', response.data);
if (this.isWrappedApiCustomer(response.data)) {
return response.data.data;
}
if (this.isApiCustomer(response.data)) {
return response.data;
}
throw new Error('Ungueltige Agrarmonitor Kunden-API-Antwort');
}
async getMaschinenKategorien() {
const response = await this.http.get('/maschinen/kategorien');
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#kategorien tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.id),
name: cells[0]?.textContent?.trim() ?? '',
zugmaschine: this.parseJaNein(cells[2]?.textContent ?? ''),
mobilSichtbar: Boolean(cells[4]?.querySelector('input[type="checkbox"]')?.checked),
};
});
}
async getMaschinen(gruppenId, suchstring = '') {
const response = await this.http.get('/module/maschinen/livesearch.php', {
params: {
suchstring,
maschinen_aktiv: 1,
maschinengruppe: gruppenId,
seite: 1,
},
});
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table.maschinen_table tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
nummer: cells[0]?.textContent?.trim() ?? '',
name: cells[1]?.textContent?.trim() ?? '',
kennzeichen: cells[2]?.textContent?.trim() ?? '',
aktiv: Boolean(cells[4]?.querySelector('.fa-check, .text-green')),
};
});
}
async getAlleMaschinen() {
const documents = await this.loadLivesearchSeiten('/module/maschinen/livesearch.php', { suchstring: '', maschinen_aktiv: 1, maschinengruppe: 0 }, 'table.maschinen_table tbody tr[data-id]');
const maschinen = new Map();
for (const document of documents) {
for (const table of Array.from(document.querySelectorAll('table.maschinen_table'))) {
const gruppenId = this.parseNumber(table.id.replace('maschinen_table_', ''));
const gruppe = this.vorherigeUeberschrift(table)?.textContent?.trim() ?? '';
for (const row of Array.from(table.querySelectorAll('tbody tr[data-id]'))) {
const id = this.parseNumber(row.getAttribute('data-id'));
const vorhanden = maschinen.get(id);
if (vorhanden) {
if (!vorhanden.gruppenIds.includes(gruppenId)) {
vorhanden.gruppenIds.push(gruppenId);
}
continue;
}
const cells = Array.from(row.querySelectorAll('td'));
maschinen.set(id, {
id,
nummer: this.livesearchText(cells[0]),
name: this.livesearchText(cells[1]),
kennzeichen: this.livesearchText(cells[2]),
aktiv: Boolean(cells[4]?.querySelector('.fa-check, .text-green')),
gruppenId,
gruppe,
gruppenIds: [gruppenId],
});
}
}
}
return Array.from(maschinen.values());
}
async getFirmen() {
const response = await this.http.get('/einstellungen');
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#firmen_table tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.getAttribute('data-firma_id') ?? row.id),
bezeichnung: cells[0]?.textContent?.trim() ?? '',
};
});
}
async getArtikelEinheiten() {
const response = await this.http.get('/artikel/einheiten');
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#einheiten tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
bezeichnung: cells[0]?.textContent?.trim() ?? '',
kurz: cells[1]?.textContent?.trim() ?? '',
};
});
}
async getArtikelKategorien() {
const response = await this.http.get('/artikel/kategorien');
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#kategorien tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.id),
name: cells[1]?.textContent?.trim() ?? '',
};
});
}
async getArtikel(artikelGruppe, suchstring = '') {
const response = await this.http.get('/module/artikel/livesearch.php', {
params: {
suchstring,
artikel_aktiv: 0,
warengruppe: artikelGruppe,
lagerplatz: 0,
bestand: 0,
itemsperpage: 100000,
seite: 1,
},
});
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#artikel_table tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
nummer: cells[0]?.textContent?.trim() ?? '',
bezeichnung: cells[1]?.textContent?.trim() ?? '',
};
});
}
async getMitarbeiter(suchstring = '', mitarbeitergruppe = 0) {
const response = await this.http.get('/module/mitarbeiter/livesearch.php', {
params: {
suchstring,
mitarbeiter_aktiv: 1,
mitarbeitergruppe,
itemsperpage: 100000,
seite: 1,
},
});
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const rows = Array.from(document.querySelectorAll('table#mitarbeiter_table tbody tr, table.mitarbeiter_table tbody tr'));
return rows.map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return {
id: this.parseNumber(row.getAttribute('data-id') ?? row.id),
nummer: cells[0]?.textContent?.trim() ?? '',
nachname: cells[2]?.textContent?.trim() ?? '',
vorname: cells[3]?.textContent?.trim() ?? '',
aktiv: Boolean(row.querySelector('td.toggle .fa-check, td.toggle .text-green')),
};
});
}
async getTaetigkeiten() {
const documents = await this.loadLivesearchSeiten('/module/arbeitszeit/taetigkeiten-livesearch.php', { taetigkeiten_aktiv: 1 }, 'table.taetigkeit_table tbody tr[data-id]');
const taetigkeiten = new Map();
for (const document of documents) {
for (const table of Array.from(document.querySelectorAll('table.taetigkeit_table'))) {
const bereichElement = this.vorherigeUeberschrift(table)?.querySelector('.bereich');
for (const row of Array.from(table.querySelectorAll('tbody tr[data-id]'))) {
const id = this.parseNumber(row.getAttribute('data-id'));
const cells = Array.from(row.querySelectorAll('td'));
if (!id || taetigkeiten.has(id)) {
continue;
}
taetigkeiten.set(id, {
id,
uuid: row.getAttribute('data-activityuuid') ?? '',
nummer: cells[0]?.textContent?.trim() ?? '',
name: cells[1]?.textContent?.trim() ?? '',
aktiv: Boolean(row.querySelector('td.toggle .fa-check, td.toggle .text-green')),
bereichId: this.parseNumber(bereichElement?.getAttribute('data-id')),
bereich: bereichElement?.textContent?.trim() ?? '',
maschineZuordnen: this.parseJaNein(cells[2]?.textContent ?? ''),
abwesenheit: this.parseJaNein(cells[3]?.textContent ?? ''),
notizErzwingen: this.parseJaNein(cells[5]?.textContent ?? ''),
});
}
}
}
return Array.from(taetigkeiten.values());
}
/**
* Beginnt den Arbeitstag: legt eine laufende Schicht (Anwesenheit ohne Ende)
* an, wie es das Agrarmonitor-Menue "Arbeitstag beginnen" tut - aber fuer
* beliebige Mitarbeiter (saveSchicht.php statt bueroAnundAbmelden.php, das nur
* fuer den angemeldeten Benutzer wirkt). Laeuft am Tag schon eine Schicht,
* wird deren Referenz geliefert, damit Wiederholungen nichts doppelt anlegen.
*/
async beginneArbeitstag(mitarbeiterId, start, options = {}) {
const beginn = this.arbeitstagZeitpunkt(mitarbeiterId, start, 'start');
const datum = this.arbeitstagDatum(options.datum, beginn);
const laufend = (await this.ladeSchichten(mitarbeiterId, datum)).find(schicht => schicht.ende === null);
if (laufend) {
this.logDebug('Arbeitstag laeuft bereits', { mitarbeiterId, datum, schichtId: laufend.id });
return { ref: this.arbeitstagRef(mitarbeiterId, datum, laufend.id) };
}
const antwort = await this.zeitbuchungPost('/module/auftraege/api/saveSchicht.php', {
vorgang_id: '-1',
mitarbeiter_id: mitarbeiterId,
formdata: { timestamp_start: beginn.formular, timestamp_ende: '' },
}, 'Arbeitstag beginnen');
const id = this.parseNumber(antwort);
if (!id) {
throw new ZeitbuchungError_1.ZeitbuchungError('Agrarmonitor hat keine Schicht angelegt', this.zeitbuchungGrund(antwort));
}
return { ref: this.arbeitstagRef(mitarbeiterId, datum, id) };
}
async beendeArbeitstag(ref, ende) {
const zielRef = this.parseArbeitstagRef(ref);
const schicht = await this.ladeArbeitstag(zielRef, ref);
await this.arbeitstagSetzen(schicht, schicht.start, this.arbeitstagZeitpunkt(zielRef.mitarbeiterId, ende, 'ende').vergleich);
}
/** Korrigiert Beginn und Ende; `ende: null` laesst den Arbeitstag weiterlaufen. */
async updateArbeitstag(ref, zeiten) {
const zielRef = this.parseArbeitstagRef(ref);
const schicht = await this.ladeArbeitstag(zielRef, ref);
const start = this.arbeitstagZeitpunkt(zielRef.mitarbeiterId, zeiten.start, 'start').vergleich;
const ende = zeiten.ende === null ? null : this.arbeitstagZeitpunkt(zielRef.mitarbeiterId, zeiten.ende, 'ende').vergleich;
await this.arbeitstagSetzen(schicht, start, ende);
}
/** Loescht die Schicht samt aller Taetigkeiten und Pausen darin. */
async deleteArbeitstag(ref) {
const zielRef = this.parseArbeitstagRef(ref);
await this.zeitbuchungPost('/module/arbeitszeit/api/deleteSchicht.php', { shiftID: zielRef.id, employeeID: zielRef.mitarbeiterId, date: zielRef.datum }, 'Arbeitstag loeschen');
}
arbeitstagZeitpunkt(mitarbeiterId, date, feld) {
if (!Number.isInteger(mitarbeiterId) || mitarbeiterId <= 0) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltige Mitarbeiter-ID', 'mitarbeiterId');
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new ZeitbuchungError_1.ZeitbuchungError(feld === 'start' ? 'Ungueltiger Start' : 'Ungueltiges Ende', feld);
}
return this.zeitpunkt(date);
}
arbeitstagDatum(datum, start) {
if (datum !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(datum)) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ungueltiger Arbeitstag, erwartet YYYY-MM-DD', 'datum');
}
return datum ?? start.datum;
}
arbeitstagRef(mitarbeiterId, datum, schichtId) {
return `schicht:${mitarbeiterId}:${datum}:${schichtId}`;
}
parseArbeitstagRef(ref) {
const match = /^schicht:(\d+):(\d{4}-\d{2}-\d{2}):(\d+)$/.exec(ref);
if (!match) {
throw new ZeitbuchungError_1.ZeitbuchungError(`Ungueltige Arbeitstag-Referenz: ${ref}`, 'ref');
}
return { mitarbeiterId: Number(match[1]), datum: match[2], id: Number(match[3]) };
}
async ladeArbeitstag(zielRef, ref) {
const schicht = await this.ladeSchicht(zielRef.id);
if (!schicht) {
throw new ZeitbuchungError_1.ZeitbuchungError(`Arbeitstag ${ref} nicht gefunden`, 'nicht gefunden');
}
return schicht;
}
async arbeitstagSetzen(schicht, start, ende) {
if (ende !== null && ende <= start) {
throw new ZeitbuchungError_1.ZeitbuchungError('Ende muss mindestens eine Minute nach dem Beginn liegen', 'dauer');
}
await this.schichtSetzen({ id: schicht.id, start, ende });
}
/**
* 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) : null;
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 && (schicht.ende === null || 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.
* Eine laufende Schicht bleibt offen - nur ihr Start wird bei Bedarf vorgezogen.
*/
async schichtErweitern(schicht, buchung) {
const start = buchung.start.vergleich < schicht.start ? buchung.start.vergleich : schicht.start;
const ende = schicht.ende === null || buchung.ende.vergleich <= schicht.ende ? schicht.ende : buchung.ende.vergleich;
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),
// Leeres Ende laesst die Schicht laufen.
timestampEnd: schicht.ende === null ? '' : 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
* Seite keine neuen Zeilen bringt (Agrarmonitor ignoriert `seite` teils).
*/
async loadLivesearchSeiten(url, params, rowSelector) {
const documents = [];
const bekannteZeilen = new Set();
for (let seite = 1; seite <= AgrarmonitorConnector.maxLivesearchSeiten; seite += 1) {
const response = await this.http.get(url, { params: { ...params, seite } });
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const neueZeilen = Array.from(document.querySelectorAll(rowSelector))
.map(row => `${row.closest('table')?.id ?? ''}:${row.getAttribute('data-id') ?? row.id}`)
.filter(key => !bekannteZeilen.has(key));
if (neueZeilen.length === 0) {
break;
}
neueZeilen.forEach(key => bekannteZeilen.add(key));
documents.push(document);
const seiten = this.parseNumber(document.querySelector('.pagination')?.getAttribute('data-pages'));
if (seite >= seiten) {
break;
}
}
return documents;
}
vorherigeUeberschrift(element) {
let sibling = element.previousElementSibling;
while (sibling && !/^H[1-6]$/.test(sibling.tagName)) {
sibling = sibling.previousElementSibling;
}
return sibling;
}
/** Agrarmonitor zeigt leere Werte in Livesearch-Tabellen als "-" an. */
livesearchText(cell) {
const text = cell?.textContent?.trim() ?? '';
return text === '-' ? '' : text;
}
createHttpClient() {
const client = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
baseURL: this.baseUrl,
jar: this.cookieJar,
withCredentials: true,
timeout: this.timeoutMs,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
},
maxRedirects: 5,
validateStatus: status => status >= 200 && status < 400,
}));
client.interceptors.response.use(async (response) => {
await this.options.cookieStore.save(this.cookieJar);
const config = response.config;
if (this.autoRetry && !config._agrarmonitorLoginRequest && this.isLoginRequiredResponse(response)) {
return this.retryAfterLogin(config);
}
return response;
}, async (error) => {
const response = error.response;
const config = error.config;
if (this.autoRetry && response && config && !config._agrarmonitorLoginRequest && this.isLoginRequiredResponse(response)) {
return this.retryAfterLogin(config);
}
throw error;
});
return client;
}
createApiHttpClient(apiToken = this.options.apiToken) {
return axios_1.default.create({
baseURL: this.apiBaseUrl,
timeout: this.timeoutMs,
headers: {
Accept: 'application/json',
...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}),
},
validateStatus: status => status >= 200 && status < 500,
});
}
async apiRequest(url, config = {}) {
const apiToken = config.apiToken ?? this.options.apiToken;
if (!apiToken) {
throw new Error('Agrarmonitor API-Token nicht konfiguriert');
}
const { apiToken: _apiToken, ...axiosConfig } = config;
const client = apiToken === this.options.apiToken ? this.apiHttp : this.createApiHttpClient(apiToken);
return client.get(url, axiosConfig);
}
async fetchAllCustomers(options) {
const customers = [];
let page = 1;
let lastPage = 1;
do {
const response = await this.apiRequest('/kunden', {
params: {
per_page: options.perPage ?? 99999,
page,
},
apiToken: options.apiToken,
});
if (!response.data || !Array.isArray(response.data.data)) {
throw new Error('Ungueltige Agrarmonitor API-Antwort');
}
customers.push(...response.data.data);
lastPage = this.parsePositiveNumber(response.data.meta?.last_page, page);
page++;
} while (page <= lastPage);
return customers;
}
mapKunde2(customer) {
return {
ID: this.parseNumber(customer.id),
ist_Kunde: this.toBoolean(customer.ist_kunde),
Kundennummer: this.toStringValue(customer.kundennummer),
ist_Lieferant: this.toBoolean(customer.ist_lieferant),
Lieferantennummer: this.toStringValue(customer.lieferantennummer),
Firma: this.toStringValue(customer.firma),
Anrede: this.toStringValue(customer.anrede),
Vorname: this.toStringValue(customer.vorname),
Nachname: this.toStringValue(customer.nachname),
Strasse: this.toStringValue(customer.strasse),
Plz: this.toStringValue(customer.plz),
Ort: this.toStringValue(customer.ort),
Land: this.toStringValue(customer.land_iso_3166),
ist_aktiv: this.toBoolean(customer.ist_aktiv),
};
}
async performLogin() {
if (!this.options.username || !this.options.password) {
throw new Error('Agrarmonitor-Credentials nicht konfiguriert');
}
this.logger?.info?.('Fuehre Agrarmonitor-Login durch');
this.logLoginDetail('Login gestartet', {
phase: 'login-start',
baseUrl: this.baseUrl,
username: this.options.username,
loginStrategy: this.loginStrategy,
autoRetry: this.autoRetry,
cookies: this.formatCookies(this.snapshotCookies()),
});
if (this.loginStrategy === 'auto') {
this.logger?.warn?.('loginStrategy "auto" ist veraltet; verwende Redirect-Login');
}
await this.performRedirectLogin();
if (!(await this.isSessionValid({ skipAutoRetry: true }))) {
throw new Error('Agrarmonitor-Login durchgefuehrt, Session weiterhin ungueltig - Geraet freigeschaltet? Credentials korrekt?');
}
await this.options.cookieStore.save(this.cookieJar);
this.logger?.info?.('Agrarmonitor-Login erfolgreich');
this.logLoginDetail('Login abgeschlossen', {
phase: 'login-done',
cookies: this.formatCookies(this.snapshotCookies()),
});
}
async performRedirectLogin() {
const loginPageResponse = await this.loggedRequest('Loginseite', 'GET', '/', () => this.http.get('/', this.loginRequestConfig()));
const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
if (!this.isLoginPageText(loginPageText)) {
this.logLoginDetail('Keine Loginseite erhalten, Session ist bereits gueltig', {
phase: 'login-skip',
url: '/',
});
return;
}
const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]');
this.logLoginDetail('Nonce aus Loginseite gelesen', {
phase: 'nonce',
url: '/',
nonce: this.maskNonce(nonce),
});
const loginData = {
username: this.options.username,
passwort: this.options.password,
nonce,
ssoAction: "",
ssoReturn: "",
};
const loginPostConfig = {
headers: {
'Content-Type': 'application/json',
},
_agrarmonitorLoginRequest: true,
};
const response = await this.loggedRequest('Login-POST', 'POST', '/login/api/login.php', () => this.http.post('/login/api/login.php', loginData, loginPostConfig), {
contentType: 'application/json',
body: { ...loginData, passwort: '***', nonce: this.maskNonce(nonce) },
});
const responseText = typeof response.data === 'string' ? response.data : '';
if (this.isLoginPageText(responseText)) {
throw new Error('Agrarmonitor-Redirect-Login fehlgeschlagen');
}
}
async isSessionValid(options = {}) {
try {
const response = await this.http.get('/', options.skipAutoRetry ? this.loginRequestConfig() : undefined);
const grund = this.loginRequiredReason(response);
this.logLoginDetail(`Session-Pruefung: ${grund ? 'ungueltig' : 'gueltig'}`, {
phase: 'session-check',
url: '/',
status: response.status,
gueltig: grund === null,
grund,
cookies: this.formatCookies(this.snapshotCookies()),
});
return grund === null;
}
catch (error) {
this.logLoginDetail('Session-Pruefung fehlgeschlagen', {
phase: 'session-check',
url: '/',
gueltig: false,
grund: 'request-fehler',
fehler: error instanceof Error ? error.message : String(error),
});
return false;
}
}
loginRequestConfig() {
return {
_agrarmonitorLoginRequest: true,
};
}
isLoginRequiredResponse(response) {
return this.loginRequiredReason(response) !== null;
}
/**
* Liefert das Kriterium, an dem eine abgelaufene Session erkannt wurde,
* oder null fuer eine gueltige Session.
*/
loginRequiredReason(response) {
const responseText = typeof response.data === 'string' ? response.data : '';
if (response.status === 401 || response.status === 403) {
return `status-${response.status}`;
}
if (this.isLoginPageText(responseText)) {
return 'login-markup';
}
return null;
}
async retryAfterLogin(config) {
if (config._agrarmonitorRetry) {
throw new Error('Agrarmonitor-Request nach erneutem Login weiterhin nicht autorisiert');
}
config._agrarmonitorRetry = true;
const method = (config.method ?? 'get').toUpperCase();
const url = config.url ?? '/';
this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt');
this.logLoginDetail(`Session abgelaufen, wiederhole ${method} ${url} nach erneutem Login`, {
phase: 'session-expired',
method,
url,
cookies: this.formatCookies(this.snapshotCookies()),
});
await this.login();
return this.loggedRequest('Wiederholung nach Login', method, url, () => this.http.request(config), {}, 'retry');
}
createDateienLivesearchParams(suchstring) {
return {
suchstring,
stammdatum_typ: -1,
mobil: -1,
sensibel: -1,
firma: 0,
itemsperpage: 100000,
seite: 1,
};
}
async getEingangsrechnungEditMeta(rechnungId) {
const document = await this.loadEingangsrechnungEditForm(rechnungId);
return {
interneBelegNummer: this.inputValue(document, 'lieferscheinnummer'),
kundenId: this.selectedNumberValue(document, 'rgempf'),
betriebId: this.selectedNumberValue(document, 'firma_id'),
dokumentTyp: this.selectedNumberValue(document, 'typ'),
};
}
async loadEingangsrechnungEditForm(rechnungId) {
const response = await this.http.get('/module/eingangsrechnungen/api/editForm.php', {
params: {
invoiceID: rechnungId,
},
});
await this.saveSession();
return this.parseHtmlDocument(response.data);
}
/** Flaches Objekt aus allen Formularfeldern, wie es das Agrarmonitor-Frontend an update.php schickt. */
serializeForm(document, formSelector) {
const form = document.querySelector(formSelector);
if (!form) {
throw new Error(`Formular ${formSelector} nicht gefunden`);
}
const values = {};
for (const field of Array.from(form.querySelectorAll('input, select, textarea'))) {
// Disabled-Felder bewusst mitnehmen: Bei gebuchten Rechnungen liefert editForm.php
// alle Felder disabled, update.php verlangt sie aber (sonst HTTP 422).
if (!field.name) {
continue;
}
if (field instanceof document.defaultView.HTMLInputElement) {
if (['button', 'submit', 'reset', 'image', 'file'].includes(field.type)) {
continue;
}
if (field.type === 'checkbox') {
values[field.name] = field.checked ? '1' : '0';
continue;
}
if (field.type === 'radio') {
if (field.checked) {
values[field.name] = field.value;
}
continue;
}
}
values[field.name] = field.value;
}
return values;
}
async getEingangsrechnungDetailMeta(rechnungId) {
const response = await this.http.get(`/eingangsrechnungen/detail/${rechnungId}`);
await this.saveSession();
const document = this.parseHtmlDocument(response.data);
const receivedStatus = document.querySelector('#receivedStatus');
const receivedText = receivedStatus?.textContent?.trim() ?? '';
const parentParts = (receivedStatus?.parentElement?.textContent ?? '').split('-');
const bookingText = parentParts.at(-1)?.trim() ?? '';
return {
eingangsDatum: !receivedText || receivedText === 'Nicht empfangen'
? null
: this.parseGermanShortDateFromText(receivedText.slice(13).trim()),
buchungsDatum: !bookingText || bookingText === 'Nicht gebucht'
? null
: this.parseGermanShortDateFromText(bookingText.slice(11).trim()),
};
}
formPostConfig(refererPath) {
return {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: `${this.baseUrl}${refererPath}`,
Origin: this.baseUrl,
},
validateStatus: status => status >= 200 && status < 400,
};
}
parseHtmlDocument(data) {
return new jsdom_1.JSDOM(typeof data === 'string' ? data : String(data ?? '')).window.document;
}
hasTableRows(data, selector) {
return this.parseHtmlDocument(data).querySelectorAll(selector).length > 0;
}
inputValue(document, name) {
return document.querySelector(`input[name="${name}"]`)?.value.trim() ?? '';
}
selectedNumberValue(document, name) {
return this.parseNumber(document.querySelector(`select[name="${name}"] option:checked`)?.value);
}
parseNumber(value) {
const numberValue = Number(String(value ?? '').trim());
return Number.isFinite(numberValue) ? numberValue : 0;
}
parsePositiveNumber(value, fallback) {
const numberValue = this.parseNumber(value);
return numberValue > 0 ? numberValue : fallback;
}
toStringValue(value) {
return value === null || typeof value === 'undefined' ? '' : String(value);
}
toBoolean(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'ja';
}
parseJaNein(value) {
return value.trim().toLowerCase() === 'ja';
}
parseGermanShortDate(value) {
const match = value.trim().match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
if (!match) {
return null;
}
const [, day, month, year] = match;
const parsed = new Date(Number(`20${year}`), Number(month) - 1, Number(day));
if (parsed.getFullYear() !== Number(`20${year}`) ||
parsed.getMonth() !== Number(month) - 1 ||
parsed.getDate() !== Number(day)) {
return null;
}
return parsed;
}
requireDate(value, fieldName) {
if (!value) {
throw new Error(`Ungueltiges Datumsformat fuer ${fieldName}`);
}
return value;
}
formatGermanShortDate(date) {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear()).slice(-2);
return `${day}.${month}.${year}`;
}
lastPathSegment(value) {
return value.split('?')[0]?.split('/').filter(Boolean).at(-1) ?? '';
}
fileBasename(fileName) {
const lastDotIndex = fileName.lastIndexOf('.');
return lastDotIndex === -1 ? fileName : fileName.slice(0, lastDotIndex);
}
normalizeApiBaseUrl(value) {
const withoutTrailingSlash = value.replace(/\/+$/, '');
return withoutTrailingSlash.endsWith('/v1') ? withoutTrailingSlash : `${withoutTrailingSlash}/v1`;
}
isWrappedApiCustomer(value) {
return (typeof value === 'object' &&
value !== null &&
'data' in value &&
this.isApiCustomer(value.data));
}
isApiCustomer(value) {
return (typeof value === 'object' &&
value !== null &&
'id' in value &&
(typeof value.id === 'string' || typeof value.id === 'number'));
}
parseGermanShortDateFromText(value) {
const match = value.match(/(\d{2}\.\d{2}\.\d{2})/);
return match ? this.parseGermanShortDate(match[1]) : null;
}
logDebug(message, meta) {
if (this.logger?.debug) {
this.logger.debug(message, meta);
return;
}
this.logger?.info?.(message, meta);
}
getResponseUrl(response) {
const request = response.request;
return request?.res?.responseUrl ?? '';
}
getHeader(response, header) {
const value = response.headers[header.toLowerCase()];
if (Array.isArray(value)) {
return value[0] ?? null;
}
return typeof value === 'string' ? value : null;
}
isFreischaltungUrl(value) {
return Boolean(value?.includes('freischaltung'));
}
isLoginPageText(responseText) {
return responseText.includes('Anmeldung - AGRARMONITOR');
}
extractNonce(html, selector) {
const dom = new jsdom_1.JSDOM(html);
const element = dom.window.document.querySelector(selector);
const nonce = element?.getAttribute('value') ?? element?.value ?? '';
if (!nonce) {
throw new Error('Nonce-Element nicht gefunden oder leer');
}
return nonce;
}
maskNonce(nonce) {
return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`;
}
get debugLogging() {
return typeof this.logger?.debug === 'function';
}
/**
* Detail-Logging des Login-Flows. Anders als logDebug faellt das bewusst
* NICHT auf info zurueck - ohne debug-Logger bleiben die Details still.
*/
logLoginDetail(message, meta) {
this.logger?.debug?.(message, meta);
}
maskValue(value) {
if (!value) {
return '(leer)';
}
return value.length <= 8 ? `\u2026(len=${value.length})` : `${value.slice(0, 4)}\u2026(len=${value.length})`;
}
/**
* Liest die Cookies des Jars fuer eine URL aus. Liefert eine leere Liste,
* solange kein debug-Logger haengt - dann wird gar nicht erst serialisiert.
*/
snapshotCookies(url = this.baseUrl) {
if (!this.debugLogging) {
return [];
}
try {
return this.cookieJar.getCookiesSync(url).map(cookie => ({
name: cookie.key,
value: cookie.value,
domain: cookie.domain ?? null,
path: cookie.path ?? null,
expires: cookie.expires instanceof Date ? cookie.expires.toISOString() : 'Session',
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
}));
}
catch {
return [];
}
}
/**
* Maskiert die Werte im rohen Set-Cookie-Header - sonst stuende die neue
* Session-ID im Klartext im Log, obwohl der Jar-Snapshot sie maskiert.
*/
formatSetCookieHeader(header) {
if (!Array.isArray(header)) {
return null;
}
return header.map(entry => {
const [pair, ...attributes] = String(entry).split(';');
const separator = pair.indexOf('=');
if (separator === -1) {
return [pair, ...attributes].join(';');
}
const name = pair.slice(0, separator);
const value = pair.slice(separator + 1);
return [`${name}=${this.maskValue(value)}`, ...attributes].join(';');
});
}
/** Wie snapshotCookies, aber mit maskierten Werten - nur das geht ins Log. */
formatCookies(cookies) {
return cookies.map(cookie => ({ ...cookie, value: this.maskValue(cookie.value) }));
}
diffCookies(before, after) {
const beforeByName = new Map(before.map(cookie => [cookie.name, cookie.value]));
const afterByName = new Map(after.map(cookie => [cookie.name, cookie.value]));
return {
added: [...afterByName.keys()].filter(name => !beforeByName.has(name)),
changed: [...afterByName.entries()]
.filter(([name, value]) => beforeByName.has(name) && beforeByName.get(name) !== value)
.map(([name]) => name),
removed: [...beforeByName.keys()].filter(name => !afterByName.has(name)),
};
}
/**
* Klammert einen Request mit je einem Cookie-Snapshot davor und danach.
* Der Snapshot davor wird vor dem Absenden geloggt, damit er auch dann
* im Log steht, wenn der Request haengt oder wirft.
*/
async loggedRequest(label, method, url, exec, details = {}, phase = 'request') {
const before = this.snapshotCookies();
this.logLoginDetail(`${label}: ${method} ${url}`, {
phase,
method,
url,
cookies: this.formatCookies(before),
...details,
});
try {
const response = await exec();
const after = this.snapshotCookies();
const responseText = typeof response.data === 'string' ? response.data : '';
this.logLoginDetail(`${label}: ${method} ${url} -> ${response.status}`, {
phase: 'response',
method,
url,
status: response.status,
responseUrl: this.getResponseUrl(response) || null,
contentType: this.getHeader(response, 'content-type'),
setCookie: this.formatSetCookieHeader(response.headers['set-cookie']),
istLoginSeite: this.isLoginPageText(responseText),
cookies: this.formatCookies(after),
cookieChanges: this.diffCookies(before, after),
});
return response;
}
catch (error) {
const after = this.snapshotCookies();
this.logLoginDetail(`${label}: ${method} ${url} -> Fehler`, {
phase: 'error',
method,
url,
fehler: error instanceof Error ? error.message : String(error),
cookies: this.formatCookies(after),
cookieChanges: this.diffCookies(before, after),
});
throw error;
}
}
}
exports.AgrarmonitorConnector = AgrarmonitorConnector;
//# sourceMappingURL=AgrarmonitorConnector.js.map