"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() ?? ''; // Buchungsstatus steht in #bookedStatus. Fallback für das alte Layout: der Teil der // Statuszeile mit "gebucht" (nicht einfach der letzte, dahinter folgt "Zahlung erfassen"). const bookingText = document.querySelector('#bookedStatus')?.textContent?.trim() ?? (receivedStatus?.parentElement?.textContent ?? '') .split('-') .map(part => part.trim()) .find(part => /gebucht/i.test(part)) ?? ''; 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