Files
AgrarmonitorConnector/src/AgrarmonitorConnector.ts
T

755 lines
24 KiB
TypeScript

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<void> | 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> {
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<void> {
if (this.loginInProgress) {
return this.loginInProgress;
}
this.loginInProgress = this.performLogin().finally(() => {
this.loginInProgress = null;
});
return this.loginInProgress;
}
async clearSession(): Promise<void> {
this.cookieJar = new CookieJar();
await this.options.cookieStore.clear();
this.http = this.createHttpClient();
this.apiHttp = this.createApiHttpClient();
}
async saveSession(): Promise<void> {
await this.options.cookieStore.save(this.cookieJar);
}
async getCookieCount(url = this.baseUrl): Promise<number> {
return this.cookieJar.getCookiesSync(url).length;
}
async checkFreigeschaltet(): Promise<AgrarmonitorFreischaltungStatus> {
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<AgrarmonitorRegistrierungStatus> {
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<AgrarmonitorDeviceRegistrationResult> {
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<AgrarmonitorApiCustomer[]> {
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<EingangsrechnungLivesearchResult[]> {
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<HTMLTableRowElement>('table#dateien tbody tr'));
const results: EingangsrechnungLivesearchResult[] = [];
for (const row of rows) {
const cells = Array.from(row.querySelectorAll<HTMLTableCellElement>('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<HTMLAnchorElement>('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<boolean> {
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<boolean> {
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<Rechnungsdaten> {
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<boolean> {
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<void> {
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<boolean> {
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<AgrarmonitorApiCustomer> {
const response = await this.apiRequest<unknown>(`/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<TData>(
url: string,
config: AxiosRequestConfig & { apiToken?: string } = {}
): Promise<AxiosResponse<TData>> {
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<TData>(url, axiosConfig);
}
private async performLogin(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<AxiosResponse> {
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<string, string | number> {
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<HTMLElement>('#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<HTMLInputElement>(`input[name="${name}"]`)?.value.trim() ?? '';
}
private selectedNumberValue(document: Document, name: string): number {
return this.parseNumber(
document.querySelector<HTMLOptionElement>(`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<HTMLInputElement>(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)}...`;
}
}