import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'; import { wrapper } from 'axios-cookiejar-support'; import { JSDOM } from 'jsdom'; import { CookieJar } from 'tough-cookie'; import type { AgrarmonitorApiCustomer, AgrarmonitorConnectorOptions, AgrarmonitorConnectorResult, AgrarmonitorDeviceRegistrationOptions, AgrarmonitorDeviceRegistrationResult, AgrarmonitorFetchCustomersOptions, AgrarmonitorFreischaltungStatus, AgrarmonitorLoginStrategy, AgrarmonitorRegistrierungStatus, EingangsrechnungLivesearchResult, Logger, Rechnungsdaten, } from './types'; type RetryableAxiosRequestConfig = AxiosRequestConfig & { _agrarmonitorRetry?: boolean; }; export class AgrarmonitorConnector implements AgrarmonitorConnectorResult { public http!: AxiosInstance; private static readonly s3DateienBaseUrl = 'https://s3-eu-central-1.amazonaws.com/dateien.agrarmonitor.de/07'; private readonly baseUrl: string; private readonly apiBaseUrl: string; private readonly timeoutMs: number; private readonly autoLogin: boolean; private readonly autoRetry: boolean; private readonly loginStrategy: AgrarmonitorLoginStrategy; private readonly logger?: Logger; private cookieJar!: CookieJar; private apiHttp!: AxiosInstance; private loginInProgress: Promise | null = null; constructor(private readonly options: AgrarmonitorConnectorOptions) { 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 ?? 'auto'; this.logger = options.logger; } async init(): Promise { 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(): Promise { if (this.loginInProgress) { return this.loginInProgress; } this.loginInProgress = this.performLogin().finally(() => { this.loginInProgress = null; }); return this.loginInProgress; } async clearSession(): Promise { this.cookieJar = new CookieJar(); await this.options.cookieStore.clear(); this.http = this.createHttpClient(); this.apiHttp = this.createApiHttpClient(); } async saveSession(): Promise { await this.options.cookieStore.save(this.cookieJar); } async getCookieCount(url = this.baseUrl): Promise { return this.cookieJar.getCookiesSync(url).length; } async checkFreigeschaltet(): Promise { 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(): Promise { 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: AgrarmonitorDeviceRegistrationOptions ): Promise { 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: AgrarmonitorFetchCustomersOptions = {}): Promise { const response = await this.apiRequest<{ data?: unknown }>('/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 as AgrarmonitorApiCustomer[]; } async eingangsrechnungenLivesearch(suchstring: string): Promise { 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: EingangsrechnungLivesearchResult[] = []; 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: string): Promise { 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: string): Promise { 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: number): Promise { const response = await this.http.get('/module/eingangsrechnungen/api/eingangsrechnungen.php', { params: { id: 'edit', rechnungId, }, }); await this.saveSession(); const document = this.parseHtmlDocument(response.data); 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: number, daten: Rechnungsdaten): Promise { const response = await this.http.post( `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=update&rechnungId=${encodeURIComponent(rechnungId)}`, new URLSearchParams({ lieferscheinnummer: daten.lieferschein, rechnungsnummer: daten.rechnung, rechnungsdatum: this.formatGermanShortDate(daten.datum), rgempf: String(daten.kundenId), adresstext: daten.adresstext, }), this.formPostConfig(`/eingangsrechnungen/detail/${rechnungId}`) ); await this.saveSession(); return response.status >= 200 && response.status < 300; } async setLieferscheinNummer(rechnungId: number, nummer: string): Promise { 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: number, datum: Date): Promise { 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: number): Promise { 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'); } private createHttpClient(): AxiosInstance { const client = wrapper( axios.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); if (this.autoRetry && this.isLoginRequiredResponse(response)) { return this.retryAfterLogin(response.config); } return response; }, async error => { const response = error.response as AxiosResponse | undefined; if (this.autoRetry && response && this.isLoginRequiredResponse(response)) { return this.retryAfterLogin(error.config); } throw error; } ); return client; } private createApiHttpClient(apiToken = this.options.apiToken): AxiosInstance { return axios.create({ baseURL: this.apiBaseUrl, timeout: this.timeoutMs, headers: { Accept: 'application/json', ...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}), }, validateStatus: status => status >= 200 && status < 500, }); } private async apiRequest( url: string, config: AxiosRequestConfig & { apiToken?: string } = {} ): Promise> { 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); } private async performLogin(): Promise { if (!this.options.username || !this.options.password) { throw new Error('Agrarmonitor-Credentials nicht konfiguriert'); } this.logger?.info?.('Fuehre Agrarmonitor-Login durch'); if (this.loginStrategy === 'auth') { await this.performAuthLogin(); } else if (this.loginStrategy === 'legacy') { await this.performLegacyLogin(); } else { await this.performAutoLogin(); } await this.options.cookieStore.save(this.cookieJar); this.logger?.info?.('Agrarmonitor-Login erfolgreich'); } private async performAutoLogin(): Promise { try { await this.performAuthLogin(); } catch (authError) { this.logger?.warn?.('Agrarmonitor-Login via /auth/login fehlgeschlagen, versuche Legacy-Login', authError); await this.performLegacyLogin(); } } private async performAuthLogin(): Promise { await this.http.get('/auth/login'); const loginData = new URLSearchParams({ email: this.options.username, password: this.options.password, remember: 'on', }); const response = await this.http.post('/auth/login', loginData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const responseText = typeof response.data === 'string' ? response.data : ''; if (responseText.includes('Anmeldung fehlgeschlagen')) { throw new Error('Agrarmonitor-Login fehlgeschlagen'); } } private async performLegacyLogin(): Promise { const loginPageResponse = await this.http.get('/'); const loginPageText = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : ''; if (!this.isLoginPageText(loginPageText)) { return; } const nonce = this.extractNonce(loginPageText, 'input[name="nonce"]'); const loginData = new URLSearchParams({ username: this.options.username, passwort: this.options.password, nonce, }); const response = await this.http.post('/redirect.php?id=benutzerverwaltung&action=login', loginData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const responseText = typeof response.data === 'string' ? response.data : ''; if (this.isLoginPageText(responseText)) { throw new Error('Agrarmonitor-Legacy-Login fehlgeschlagen'); } } private async isSessionValid(): Promise { try { const response = await this.http.get('/'); return !this.isLoginRequiredResponse(response); } catch { return false; } } private isLoginRequiredResponse(response: AxiosResponse): boolean { const responseUrl = this.getResponseUrl(response); const responseText = typeof response.data === 'string' ? response.data : ''; return ( response.status === 401 || response.status === 403 || responseUrl.includes('/auth/login') || responseText.includes('/auth/login') || this.isLoginPageText(responseText) || responseText.includes('Anmeldung') || responseText.includes('Einloggen') ); } private async retryAfterLogin(config: RetryableAxiosRequestConfig): Promise { if (config._agrarmonitorRetry) { throw new Error('Agrarmonitor-Request nach erneutem Login weiterhin nicht autorisiert'); } config._agrarmonitorRetry = true; this.logger?.info?.('Agrarmonitor-Session abgelaufen, erneuter Login wird ausgefuehrt'); await this.login(); return this.http.request(config); } private createDateienLivesearchParams(suchstring: string): Record { return { suchstring, stammdatum_typ: -1, mobil: -1, sensibel: -1, firma: 0, itemsperpage: 100000, seite: 1, }; } private async getEingangsrechnungEditMeta(rechnungId: number): Promise<{ interneBelegNummer: string; kundenId: number; betriebId: number; dokumentTyp: number; }> { const response = await this.http.get('/module/eingangsrechnungen/api/eingangsrechnungen.php', { params: { id: 'edit', rechnungId, }, }); await this.saveSession(); const document = this.parseHtmlDocument(response.data); return { interneBelegNummer: this.inputValue(document, 'lieferscheinnummer'), kundenId: this.selectedNumberValue(document, 'rgempf'), betriebId: this.selectedNumberValue(document, 'firma_id'), dokumentTyp: this.selectedNumberValue(document, 'typ'), }; } private async getEingangsrechnungDetailMeta(rechnungId: number): Promise<{ eingangsDatum: Date | null; buchungsDatum: Date | null; }> { 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()), }; } private formPostConfig(refererPath: string): AxiosRequestConfig { return { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Referer: `${this.baseUrl}${refererPath}`, Origin: this.baseUrl, }, validateStatus: status => status >= 200 && status < 400, }; } private parseHtmlDocument(data: unknown): Document { return new JSDOM(typeof data === 'string' ? data : String(data ?? '')).window.document; } private hasTableRows(data: unknown, selector: string): boolean { return this.parseHtmlDocument(data).querySelectorAll(selector).length > 0; } private inputValue(document: Document, name: string): string { return document.querySelector(`input[name="${name}"]`)?.value.trim() ?? ''; } private selectedNumberValue(document: Document, name: string): number { return this.parseNumber( document.querySelector(`select[name="${name}"] option:checked`)?.value ); } private parseNumber(value: unknown): number { const numberValue = Number(String(value ?? '').trim()); return Number.isFinite(numberValue) ? numberValue : 0; } private parseGermanShortDate(value: string): Date | null { 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; } private requireDate(value: Date | null, fieldName: string): Date { if (!value) { throw new Error(`Ungueltiges Datumsformat fuer ${fieldName}`); } return value; } private formatGermanShortDate(date: Date): string { 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}`; } private lastPathSegment(value: string): string { return value.split('?')[0]?.split('/').filter(Boolean).at(-1) ?? ''; } private fileBasename(fileName: string): string { const lastDotIndex = fileName.lastIndexOf('.'); return lastDotIndex === -1 ? fileName : fileName.slice(0, lastDotIndex); } private normalizeApiBaseUrl(value: string): string { const withoutTrailingSlash = value.replace(/\/+$/, ''); return withoutTrailingSlash.endsWith('/v1') ? withoutTrailingSlash : `${withoutTrailingSlash}/v1`; } private isWrappedApiCustomer(value: unknown): value is { data: AgrarmonitorApiCustomer } { return ( typeof value === 'object' && value !== null && 'data' in value && this.isApiCustomer((value as { data?: unknown }).data) ); } private isApiCustomer(value: unknown): value is AgrarmonitorApiCustomer { return ( typeof value === 'object' && value !== null && 'id' in value && (typeof (value as { id?: unknown }).id === 'string' || typeof (value as { id?: unknown }).id === 'number') ); } private parseGermanShortDateFromText(value: string): Date | null { const match = value.match(/(\d{2}\.\d{2}\.\d{2})/); return match ? this.parseGermanShortDate(match[1]) : null; } private logDebug(message: string, meta?: unknown): void { if (this.logger?.debug) { this.logger.debug(message, meta); return; } this.logger?.info?.(message, meta); } private getResponseUrl(response: AxiosResponse): string { const request = response.request as { res?: { responseUrl?: string } } | undefined; return request?.res?.responseUrl ?? ''; } private getHeader(response: AxiosResponse, header: string): string | null { const value = response.headers[header.toLowerCase()]; if (Array.isArray(value)) { return value[0] ?? null; } return typeof value === 'string' ? value : null; } private isFreischaltungUrl(value: string | null): boolean { return Boolean(value?.includes('freischaltung')); } private isLoginPageText(responseText: string): boolean { return responseText.includes('Anmeldung - AGRARMONITOR'); } private extractNonce(html: string, selector: string): string { const dom = new 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; } private maskNonce(nonce: string): string { return nonce.length <= 10 ? nonce : `${nonce.slice(0, 10)}...`; } }