From 8c5a81ed27f7fda66001bff2143478f14e85642d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 25 May 2026 12:11:44 +0200 Subject: [PATCH] feat: implement ProcessVerarbeiteteDocuments (Upload-Check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported ProcessVerarbeiteteDocuments() from C# ProcessUploads.cs: - Checks docs tagged "hochgeladen" → eingangsrechnungVorhanden() - On match: livesearch, update title/type/created/correspondent/tags, set custom fields (externeBelegnummer, AgrarmonitorLink), addNote - Tag "hochgeladen" → "fertig" swap; owner via Client.AgrarmonitorBetriebId - 401/403 guard: clearClient() + break (same pattern as runPolling) - Cron: AGRARMONITOR_UPLOAD_CHECK_CRON (default: 0 * * * * *) - New settings: agrarmonitor_tag_hochgeladen, agrarmonitor_link_field - Endpoint: POST /api/agrarmonitor/process-uploads - Frontend: polling-config extended with tagHochgeladen + linkField select, new card "Dokumenten-Verarbeitung" with run button + result display Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + docker-compose.yml | 1 + .../agrarmonitor-polling.service.ts | 246 +++++++++++++++++- .../agrarmonitor/agrarmonitor.controller.ts | 11 +- paperless-frontend/src/api/settings.ts | 4 + paperless-frontend/src/pages/SettingsPage.tsx | 60 ++++- 6 files changed, 312 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 254c991..504d47e 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,4 @@ AGRARMONITOR_API_TOKEN= AGRARMONITOR_COOKIE_PATH=./data/agrarmonitor-cookies.json AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren +AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren diff --git a/docker-compose.yml b/docker-compose.yml index 2a94afe..0635a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: - AGRARMONITOR_COOKIE_PATH=${AGRARMONITOR_COOKIE_PATH:-./data/agrarmonitor-cookies.json} - AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-} - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} + - AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-} volumes: - /mnt/scans:/mnt/scans - /mnt/paperlessmanager:/mnt/data diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 1859bbb..0c826eb 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -9,7 +9,9 @@ import { Client } from '../database/entities/client.entity'; const INTERN_BELEGNUMMER_FIELD_ID = 7; const EINGANGSDATUM_FIELD_ID = 9; +const EXTERN_BELEGNUMMER_FIELD_ID = 3; const DOCS_PAGE_SIZE = 500; +const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de'; export interface PollingResult { processed: number; @@ -22,6 +24,7 @@ export interface PollingResult { export class AgrarmonitorPollingService implements OnModuleInit { private readonly logger = new Logger(AgrarmonitorPollingService.name); private pollingRunning = false; + private uploadCheckRunning = false; constructor( private readonly agrarmonitorService: AgrarmonitorService, @@ -33,6 +36,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { async onModuleInit() { await this.upsertSetting('agrarmonitor_tag_fertig', '4'); await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); + await this.upsertSetting('agrarmonitor_tag_hochgeladen', ''); + await this.upsertSetting('agrarmonitor_link_field', ''); } @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') @@ -41,21 +46,40 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); } - async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string }> { - const [fertig, verbucht] = await Promise.all([ + @Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *') + async scheduledUploadCheck() { + if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return; + this.processVerarbeiteteDocuments().catch((err) => this.logger.error('Cron-Upload-Check-Fehler:', err)); + } + + async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }> { + const [fertig, verbucht, hochgeladen, linkField] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), ]); return { tagFertig: fertig?.Wert ?? '4', tagVerbucht: verbucht?.Wert ?? '9', + tagHochgeladen: hochgeladen?.Wert ?? '', + linkField: linkField?.Wert ?? '', }; } - async updatePollingConfig(tagFertig: string, tagVerbucht: string): Promise<{ tagFertig: string; tagVerbucht: string }> { - await this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }); - await this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }); - return { tagFertig, tagVerbucht }; + async updatePollingConfig( + tagFertig: string, + tagVerbucht: string, + tagHochgeladen: string, + linkField: string, + ): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }> { + await Promise.all([ + this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }), + this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }), + this.settingRepo.update({ Tag: 'agrarmonitor_tag_hochgeladen' }, { Wert: tagHochgeladen }), + this.settingRepo.update({ Tag: 'agrarmonitor_link_field' }, { Wert: linkField }), + ]); + return { tagFertig, tagVerbucht, tagHochgeladen, linkField }; } async runPolling(): Promise { @@ -210,9 +234,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { const customer = customers.find((c) => Number(c.id) === amDoc.kundenId); if (customer) { const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; - const searchName = `(${lieferantennummer})`; const displayName = this.buildCustomerName(customer, lieferantennummer); - let corr = await this.paperlessService.getCorrespondentByName(searchName); + let corr = await this.paperlessService.getCorrespondentByName(displayName); if (!corr) { corr = await this.paperlessService.addCorrespondent({ name: displayName, @@ -264,6 +287,213 @@ export class AgrarmonitorPollingService implements OnModuleInit { return result; } + async processVerarbeiteteDocuments(): Promise { + if (this.uploadCheckRunning) { + this.logger.warn('Upload-Check läuft bereits, überspringe'); + return { processed: 0, updated: 0, skipped: 0, errors: ['Upload-Check bereits aktiv'] }; + } + this.uploadCheckRunning = true; + + const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] }; + this.logger.log('Starte Upload-Check'); + + try { + const [hochgeladenSetting, fertigSetting, linkFieldSetting] = await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), + ]); + + const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10); + const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10); + const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10); + + if (isNaN(tagHochgeladenId)) { + this.logger.warn('Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen'); + return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] }; + } + + let amClient: Awaited>; + try { + amClient = await this.agrarmonitorService.getClient(); + } catch (err: unknown) { + const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`; + this.logger.error(msg); + return { ...result, errors: [msg] }; + } + + const docsResponse = await this.paperlessService.getDocuments({ + page: 1, + page_size: DOCS_PAGE_SIZE, + truncate_content: true, + tags__id__all: tagHochgeladenId, + }); + const docs: any[] = docsResponse?.results ?? []; + if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) { + this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`); + } + this.logger.log(`${docs.length} Dokumente laut Paperless im Dateieingang`); + + for (const doc of docs) { + result.processed++; + + const interneBelegnummer = + ((doc.custom_fields as any[]) ?? []).find( + (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, + )?.value as string ?? ''; + + if (!interneBelegnummer) { + this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`); + result.skipped++; + await this.delay(500); + continue; + } + + let vorhanden: boolean; + try { + vorhanden = await amClient.eingangsrechnungVorhanden(interneBelegnummer); + } catch (err: unknown) { + const status = (err as any)?.response?.status; + if (status === 401 || status === 403) { + this.agrarmonitorService.clearClient(); + const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`; + this.logger.warn(msg); + result.errors.push(msg); + break; + } + const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`; + this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + result.errors.push(msg); + await this.delay(500); + continue; + } + + if (!vorhanden) { + result.skipped++; + await this.delay(500); + continue; + } + + this.logger.log(`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`); + + let amResults: Awaited>; + try { + amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer); + } catch (err: unknown) { + const status = (err as any)?.response?.status; + if (status === 401 || status === 403) { + this.agrarmonitorService.clearClient(); + const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`; + this.logger.warn(msg); + result.errors.push(msg); + break; + } + const msg = `${interneBelegnummer}: Livesearch-Fehler`; + this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + result.errors.push(msg); + await this.delay(500); + continue; + } + + if (amResults.length > 1) { + this.logger.log(`Dokument ${interneBelegnummer} ist doppelt vorhanden`); + result.skipped++; + await this.delay(500); + continue; + } + + const amDoc = amResults[0]; + + try { + // Kundendaten abrufen + const customer = await amClient.getCustomerById(amDoc.kundenId); + const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; + if (!lieferantennummer) { + this.logger.log(`Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`); + result.skipped++; + await this.delay(500); + continue; + } + + // Korrespondent ermitteln oder anlegen + const displayName = this.buildCustomerName(customer, lieferantennummer); + let corr = await this.paperlessService.getCorrespondentByName(displayName); + if (!corr) { + corr = await this.paperlessService.addCorrespondent({ + name: displayName, + match: '', + matching_algorithm: 0, + is_insensitive: true, + owner: null, + }); + } + + // Owner aus Client-Tabelle + let ownerId: number | undefined; + const matchedClient = await this.clientRepo.findOneBy({ AgrarmonitorBetriebId: amDoc.betriebId }); + if (matchedClient) ownerId = matchedClient.PaperlessUserId; + + // Tags: hochgeladen entfernen, fertig hinzufügen + const currentTags: number[] = (doc.tags as number[]) ?? []; + const newTags = [...new Set(currentTags.filter((t) => t !== tagHochgeladenId).concat([tagFertigId]))]; + + // Custom fields aufbauen: bestehende behalten, extern + link setzen + const existingFields: any[] = ((doc.custom_fields as any[]) ?? []).map((f: any) => ({ ...f })); + this.setCustomField(existingFields, EXTERN_BELEGNUMMER_FIELD_ID, amDoc.belegNummer); + if (!isNaN(linkFieldId)) { + this.setCustomField( + existingFields, + linkFieldId, + `${AGRARMONITOR_BASE_URL}/rechnungen/detail/${amDoc.eingangId}`, + ); + } + + const updateData: Record = { + title: (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer, + document_type: amDoc.dokumentTyp === 0 ? 1 : 2, + tags: newTags, + custom_fields: existingFields, + }; + if (amDoc.belegDatum) updateData.created = amDoc.belegDatum.toISOString().slice(0, 10); + if (corr) updateData.correspondent = corr.id as number; + if (ownerId !== undefined) updateData.owner = ownerId; + + await this.paperlessService.updateDocument(doc.id as number, updateData); + await this.paperlessService.addNote( + doc.id as number, + `Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`, + ); + this.logger.log(`Beleg ${interneBelegnummer} auf AMfertig gesetzt`); + result.updated++; + } catch (err: unknown) { + const msg = `${interneBelegnummer}: Update-Fehler`; + this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + result.errors.push(msg); + } + + await this.delay(500); + } + + this.logger.log( + `Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` + + `${result.skipped} übersprungen, ${result.errors.length} Fehler`, + ); + } finally { + this.uploadCheckRunning = false; + } + + return result; + } + + private setCustomField(fields: any[], fieldId: number, value: any): void { + const existing = fields.find((f) => f.field === fieldId); + if (existing) { + existing.value = value; + } else { + fields.push({ field: fieldId, value }); + } + } + private buildCustomerName(customer: Record, nummer: string): string { const firma = (customer['firma'] as string) ?? ''; const nachname = (customer['nachname'] as string) ?? ''; diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index 76b9aed..c5c5192 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -32,8 +32,8 @@ export class AgrarmonitorController { @Put('polling-config') @RequirePermissions(Permission.MANAGE_SETTINGS) - async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string }) { - return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht); + async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }) { + return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField); } @Post('run-polling') @@ -42,4 +42,11 @@ export class AgrarmonitorController { async runPolling() { return this.pollingService.runPolling(); } + + @Post('process-uploads') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async processUploads() { + return this.pollingService.processVerarbeiteteDocuments(); + } } diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index 8fabd38..6819ef6 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -199,6 +199,8 @@ export interface AgrarmonitorStatusData { export interface AgrarmonitorPollingConfig { tagFertig: string; tagVerbucht: string; + tagHochgeladen: string; + linkField: string; } export interface AgrarmonitorPollingResult { @@ -221,4 +223,6 @@ export const agrarmonitorApi = { api.put('/api/agrarmonitor/polling-config', config).then((r) => r.data), runPolling: () => api.post('/api/agrarmonitor/run-polling').then((r) => r.data), + processUploads: () => + api.post('/api/agrarmonitor/process-uploads').then((r) => r.data), }; diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index c1081f9..aff7136 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -2291,9 +2291,12 @@ function AgrarmonitorTab() { const [pollingConfigLoading, setPollingConfigLoading] = useState(false); const [pollingSaving, setPollingSaving] = useState(false); const [pollingRunning, setPollingRunning] = useState(false); + const [uploadCheckRunning, setUploadCheckRunning] = useState(false); const [status, setStatus] = useState(null); const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); const [pollingResult, setPollingResult] = useState(null); + const [uploadCheckResult, setUploadCheckResult] = useState(null); + const [customFields, setCustomFields] = useState([]); const handleLoadStatus = async () => { setLoading(true); @@ -2341,7 +2344,10 @@ function AgrarmonitorTab() { } }, [pollingForm]); - useEffect(() => { handleLoadPollingConfig(); }, [handleLoadPollingConfig]); + useEffect(() => { + handleLoadPollingConfig(); + paperlessApi.getCustomFields().then(setCustomFields).catch(() => {}); + }, [handleLoadPollingConfig]); const handleSavePollingConfig = async () => { const values = await pollingForm.validateFields() as AgrarmonitorPollingConfig; @@ -2369,6 +2375,19 @@ function AgrarmonitorTab() { } }; + const handleProcessUploads = async () => { + setUploadCheckRunning(true); + setUploadCheckResult(null); + try { + const result = await agrarmonitorApi.processUploads(); + setUploadCheckResult(result); + } catch { + message.error('Upload-Check fehlgeschlagen'); + } finally { + setUploadCheckRunning(false); + } + }; + const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => { if (value === null) return ; return value @@ -2462,6 +2481,16 @@ function AgrarmonitorTab() { > + + + + + + @@ -2492,6 +2521,35 @@ function AgrarmonitorTab() { )} + + + + Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig", + sobald sie im Agrarmonitor-Buchungssystem erscheinen. + + + + {uploadCheckResult && ( +
+ {uploadCheckResult.processed} geprüft + {uploadCheckResult.updated} aktualisiert + {uploadCheckResult.skipped} übersprungen + {uploadCheckResult.errors.length > 0 && ( + {uploadCheckResult.errors.length} Fehler + )} + {uploadCheckResult.errors.length > 0 && ( +
    + {uploadCheckResult.errors.map((e, i) => ( +
  • {e}
  • + ))} +
+ )} +
+ )} +
+
);