Add invoice and customer API methods
This commit is contained in:
+374
-12
@@ -12,7 +12,9 @@ import type {
|
||||
AgrarmonitorFreischaltungStatus,
|
||||
AgrarmonitorLoginStrategy,
|
||||
AgrarmonitorRegistrierungStatus,
|
||||
EingangsrechnungLivesearchResult,
|
||||
Logger,
|
||||
Rechnungsdaten,
|
||||
} from './types';
|
||||
|
||||
type RetryableAxiosRequestConfig = AxiosRequestConfig & {
|
||||
@@ -22,6 +24,8 @@ type RetryableAxiosRequestConfig = AxiosRequestConfig & {
|
||||
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;
|
||||
@@ -30,11 +34,12 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
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 = options.apiBaseUrl ?? 'https://api.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;
|
||||
@@ -45,6 +50,7 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
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();
|
||||
@@ -73,6 +79,7 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
this.cookieJar = new CookieJar();
|
||||
await this.options.cookieStore.clear();
|
||||
this.http = this.createHttpClient();
|
||||
this.apiHttp = this.createApiHttpClient();
|
||||
}
|
||||
|
||||
async saveSession(): Promise<void> {
|
||||
@@ -172,22 +179,14 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
}
|
||||
|
||||
async fetchCustomers(options: AgrarmonitorFetchCustomersOptions = {}): Promise<AgrarmonitorApiCustomer[]> {
|
||||
const apiToken = options.apiToken ?? this.options.apiToken;
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error('Agrarmonitor API-Token nicht konfiguriert');
|
||||
}
|
||||
|
||||
const response = await this.http.get(`${this.apiBaseUrl}/v1/kunden`, {
|
||||
const response = await this.apiRequest<{ data?: unknown }>('/kunden', {
|
||||
params: {
|
||||
per_page: options.perPage ?? 99999,
|
||||
api_token: apiToken,
|
||||
},
|
||||
apiToken: options.apiToken,
|
||||
});
|
||||
|
||||
await this.saveSession();
|
||||
|
||||
const responseData = response.data as { data?: unknown };
|
||||
const responseData = response.data;
|
||||
|
||||
if (!responseData || !Array.isArray(responseData.data)) {
|
||||
throw new Error('Ungueltige Agrarmonitor API-Antwort');
|
||||
@@ -196,6 +195,163 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
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({
|
||||
@@ -236,6 +392,33 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
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');
|
||||
@@ -351,6 +534,185 @@ export class AgrarmonitorConnector implements AgrarmonitorConnectorResult {
|
||||
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 ?? '';
|
||||
|
||||
Reference in New Issue
Block a user