diff --git a/.env.example b/.env.example index 3bdae21..db2364c 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,7 @@ 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 +AGRARMONITOR_POLLING_DEBUG= # true = jeden Schritt des Polling-Laufs pro Dokument loggen # --- Täglicher Digest --- # Basis-URL der App für klickbare Links in Digest-E-Mails (z.B. https://paperless.example.com) diff --git a/docker-compose.yml b/docker-compose.yml index 1d37bea..b641dee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,7 @@ services: - AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-} - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} - AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-} + - AGRARMONITOR_POLLING_DEBUG=${AGRARMONITOR_POLLING_DEBUG:-} - APP_URL=${APP_URL:-} - DAILY_DIGEST_CRON=${DAILY_DIGEST_CRON:-} volumes: diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 9db3963..0ceebbf 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -5064,7 +5064,7 @@ }, "node_modules/agrarmonitor-connector": { "version": "0.1.0", - "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#e6ce1d254bf21522b73a9323bb8f8cd9fadd7eef", + "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#9bda65eec0ec45c98ecce506fe68e36991c0ae61", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 3fa615b..2361e13 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -8,6 +8,8 @@ import { PaperlessService } from '../paperless/paperless.service'; import { Setting } from '../database/entities/setting.entity'; import { Client } from '../database/entities/client.entity'; import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { resolveTitelTemplate } from '../paperless/titel-template.util'; const INTERN_BELEGNUMMER_FIELD_ID = 7; const EINGANGSDATUM_FIELD_ID = 9; @@ -47,6 +49,10 @@ export interface SyncCorrespondentsResult { export class AgrarmonitorPollingService implements OnModuleInit { private readonly logger = new Logger(AgrarmonitorPollingService.name); private pollingRunning = false; + /** Schritt-Logging für runPolling(), aktivierbar über AGRARMONITOR_POLLING_DEBUG=true */ + private readonly debug = ['1', 'true', 'yes'].includes( + (process.env['AGRARMONITOR_POLLING_DEBUG'] ?? '').toLowerCase(), + ); private uploadCheckRunning = false; constructor( @@ -57,6 +63,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { @InjectRepository(Client) private readonly clientRepo: Repository, @InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository, + @InjectRepository(DocumentType) + private readonly docTypeRepo: Repository, ) {} async onModuleInit() { @@ -210,6 +218,10 @@ export class AgrarmonitorPollingService implements OnModuleInit { const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10); const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10); + this.trace( + `Konfiguration: Tag fertig=${tagFertigId}, Tag verbucht=${tagVerbuchtId}`, + ); + if (isNaN(tagFertigId) || isNaN(tagVerbuchtId)) { const msg = 'Tag-IDs ungültig (keine Zahlen)'; this.logger.error(msg); @@ -236,9 +248,13 @@ export class AgrarmonitorPollingService implements OnModuleInit { return { ...result, errors: [msg] }; } - for (const customer of customers.filter( + const aktiveLieferanten = customers.filter( (c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1, - )) { + ); + this.trace( + `${customers.length} Kunden geladen, davon ${aktiveLieferanten.length} aktive Lieferanten — starte Korrespondenten-Sync`, + ); + for (const customer of aktiveLieferanten) { try { await this.getOrCreateCorrespondent(customer, Number(customer.id)); } catch (err: unknown) { @@ -261,9 +277,14 @@ export class AgrarmonitorPollingService implements OnModuleInit { ); } this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); + this.trace( + `Paperless meldet ${docsResponse?.count ?? 0} Dokumente mit Tag ${tagFertigId}`, + ); + let nichtGebucht = 0; for (const doc of docs) { result.processed++; + const nr = `[${result.processed}/${docs.length}] Dokument ${doc.id as number}`; const interneBelegnummer = (((doc.custom_fields as any[]) ?? []).find( @@ -279,6 +300,10 @@ export class AgrarmonitorPollingService implements OnModuleInit { continue; } + this.trace( + `${nr} „${doc.title as string}“: interne Belegnummer ${interneBelegnummer}, Tags [${((doc.tags as number[]) ?? []).join(', ')}] — starte Livesearch`, + ); + let amResults: Awaited< ReturnType >; @@ -295,14 +320,17 @@ export class AgrarmonitorPollingService implements OnModuleInit { break; } const msg = `${interneBelegnummer}: Livesearch-Fehler`; - this.logger.error( - `${msg}: ${fehlerText(err)}`, - ); + this.logger.error(`${msg}: ${fehlerText(err)}`); result.errors.push(msg); await this.delay(500); continue; } + this.trace( + `${nr}: Livesearch lieferte ${amResults.length} Treffer` + + amResults.map((r) => `\n ${this.describeAmDoc(r)}`).join(''), + ); + if (amResults.length === 0) { this.logger.log( `${interneBelegnummer} nicht in Agrarmonitor gefunden`, @@ -323,6 +351,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { const amDoc = amResults[0]; if (!amDoc.interneBelegNummer && interneBelegnummer) { + this.trace( + `${nr}: Lieferscheinnummer in AM leer — setze ${interneBelegnummer}`, + ); try { await amClient.setLieferscheinNummer( amDoc.eingangId, @@ -339,6 +370,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find( (cf: any) => cf.field === EINGANGSDATUM_FIELD_ID, ); + this.trace( + `${nr}: Eingangsdatum in AM leer — Paperless-Feld ${EINGANGSDATUM_FIELD_ID} = ${JSON.stringify(eingangsdatumField?.value ?? null)}`, + ); if (eingangsdatumField?.value) { const eingangsdatum = new Date(eingangsdatumField.value as string); if (!isNaN(eingangsdatum.getTime())) { @@ -350,7 +384,17 @@ export class AgrarmonitorPollingService implements OnModuleInit { } } + if (!amDoc.buchungsDatum) { + nichtGebucht++; + this.trace( + `${nr}: kein Buchungsdatum in AM — noch nicht gebucht, keine Änderung`, + ); + } + if (amDoc.buchungsDatum) { + this.trace( + `${nr}: gebucht am ${this.fmtDate(amDoc.buchungsDatum)} — aktualisiere Paperless`, + ); try { let correspondentId: number | undefined; const customer = customers.find( @@ -384,6 +428,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { updateData.correspondent = correspondentId; if (ownerId !== undefined) updateData.owner = ownerId; + this.trace( + `${nr}: Kunde ${amDoc.kundenId} → Korrespondent ${correspondentId ?? '–'}, Betrieb ${amDoc.betriebId} → Owner ${ownerId ?? '–'}, Update ${JSON.stringify(updateData)}`, + ); await this.paperlessService.updateDocument( doc.id as number, updateData, @@ -392,9 +439,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { result.updated++; } catch (err: unknown) { const msg = `${interneBelegnummer}: Update-Fehler`; - this.logger.error( - `${msg}: ${fehlerText(err)}`, - ); + this.logger.error(`${msg}: ${fehlerText(err)}`); result.errors.push(msg); } } @@ -404,7 +449,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.logger.log( `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` + - `${result.skipped} übersprungen, ${result.errors.length} Fehler`, + `${nichtGebucht} noch nicht gebucht, ${result.skipped} übersprungen, ${result.errors.length} Fehler`, ); } finally { this.pollingRunning = false; @@ -525,9 +570,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { break; } const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`; - this.logger.error( - `${msg}: ${fehlerText(err)}`, - ); + this.logger.error(`${msg}: ${fehlerText(err)}`); result.errors.push(msg); await this.delay(500); continue; @@ -552,9 +595,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { } // Bei Fehler vorsichtig: nicht verschieben const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`; - this.logger.error( - `${msg}: ${fehlerText(err)}`, - ); + this.logger.error(`${msg}: ${fehlerText(err)}`); result.errors.push(msg); await this.delay(500); continue; @@ -645,9 +686,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { break; } const msg = `${interneBelegnummer}: Livesearch-Fehler`; - this.logger.error( - `${msg}: ${fehlerText(err)}`, - ); + this.logger.error(`${msg}: ${fehlerText(err)}`); result.errors.push(msg); await this.delay(500); continue; @@ -718,10 +757,23 @@ export class AgrarmonitorPollingService implements OnModuleInit { ); } + // Titel über das Titel-Template des Dokumenttyps bilden; + // ohne (auflösbares) Template bleibt der bisherige Standardtitel. + const documentTypeId = amDoc.dokumentTyp === 0 ? 1 : 2; + const docTypeConfig = await this.docTypeRepo.findOneBy({ + DocumentTypeId: documentTypeId, + }); + const title = + resolveTitelTemplate( + docTypeConfig?.TitelTemplate, + existingFields, + amDoc.belegDatum ?? new Date(doc.created as string), + ) ?? + (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer; + const updateData: Record = { - title: - (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer, - document_type: amDoc.dokumentTyp === 0 ? 1 : 2, + title, + document_type: documentTypeId, tags: newTags, custom_fields: existingFields, }; @@ -742,9 +794,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { result.updated++; } catch (err: unknown) { const msg = `${interneBelegnummer}: Update-Fehler`; - this.logger.error( - `${msg}: ${fehlerText(err)}`, - ); + this.logger.error(`${msg}: ${fehlerText(err)}`); result.errors.push(msg); } @@ -1018,6 +1068,34 @@ export class AgrarmonitorPollingService implements OnModuleInit { return `${name} (${nummer})`; } + private trace(message: string): void { + if (this.debug) this.logger.log(`[Debug] ${message}`); + } + + private fmtDate(d: Date | null | undefined): string { + if (!d) return '–'; + return isNaN(d.getTime()) ? 'ungültig' : d.toISOString().slice(0, 10); + } + + private describeAmDoc(r: { + eingangId: number; + belegNummer: string; + interneBelegNummer: string; + belegDatum: Date | null; + eingangsDatum: Date | null; + buchungsDatum: Date | null; + kundenId: number; + betriebId: number; + dokumentTyp: number; + }): string { + return ( + `eingangId=${r.eingangId} beleg=${r.belegNummer || '–'} intern=${r.interneBelegNummer || '–'} ` + + `typ=${r.dokumentTyp} belegDatum=${this.fmtDate(r.belegDatum)} ` + + `eingangsDatum=${this.fmtDate(r.eingangsDatum)} buchungsDatum=${this.fmtDate(r.buchungsDatum)} ` + + `kunde=${r.kundenId} betrieb=${r.betriebId}` + ); + } + private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts index 52eed31..a5e0d73 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts @@ -8,10 +8,16 @@ import { PaperlessModule } from '../paperless/paperless.module'; import { Setting } from '../database/entities/setting.entity'; import { Client } from '../database/entities/client.entity'; import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity'; +import { DocumentType } from '../database/entities/document-type.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]), + TypeOrmModule.forFeature([ + Setting, + Client, + CorrespondentSetting, + DocumentType, + ]), PaperlessModule, ], providers: [ diff --git a/paperless-backend/src/paperless/paperless-processor.service.ts b/paperless-backend/src/paperless/paperless-processor.service.ts index 0426529..4c6a54b 100644 --- a/paperless-backend/src/paperless/paperless-processor.service.ts +++ b/paperless-backend/src/paperless/paperless-processor.service.ts @@ -5,6 +5,7 @@ import { Repository } from 'typeorm'; import { DocumentField } from '../database/entities/document-field.entity'; import { DocumentType } from '../database/entities/document-type.entity'; import { PaperlessService } from './paperless.service'; +import { resolveTitelTemplate } from './titel-template.util'; import { PostprocessingService } from '../postprocessing/postprocessing.service'; const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager" @@ -220,45 +221,12 @@ export class PaperlessProcessorService { }; if (docTypeConfig.TitelTemplate) { - let title = docTypeConfig.TitelTemplate; - - // Prüfen ob alle im Template referenzierten Custom Fields ausgefüllt sind - const placeholderRegex = /\{\{CUSTOM\[(\d+)\]\}\}/g; - let allFilled = true; - let match: RegExpExecArray | null; - - while ((match = placeholderRegex.exec(title)) !== null) { - const fieldId = parseInt(match[1], 10); - const cf = newCustomFields.find((f) => f.field === fieldId); - if (!cf || cf.value == null || cf.value === '') { - allFilled = false; - break; - } - } - - if (allFilled) { - for (const cf of newCustomFields) { - const placeholder = `{{CUSTOM[${cf.field}]}}`; - if (title.includes(placeholder)) { - title = title.replaceAll( - placeholder, - cf.value != null ? String(cf.value) : '', - ); - } - } - - if (title.includes('{{DATE}}')) { - const created = doc.created ? new Date(doc.created) : new Date(); - const dateStr = `${String(created.getDate()).padStart(2, '0')}.${String(created.getMonth() + 1).padStart(2, '0')}.${created.getFullYear()}`; - title = title.replaceAll('{{DATE}}', dateStr); - } - - if (title.includes('{{ZEITSTEMPEL}}')) { - const now = new Date(); - const ts = `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`; - title = title.replaceAll('{{ZEITSTEMPEL}}', ts); - } - + const title = resolveTitelTemplate( + docTypeConfig.TitelTemplate, + newCustomFields, + doc.created ? new Date(doc.created) : new Date(), + ); + if (title !== null) { updatePayload.title = title; } else { this.logger.log( diff --git a/paperless-backend/src/paperless/titel-template.util.spec.ts b/paperless-backend/src/paperless/titel-template.util.spec.ts new file mode 100644 index 0000000..8b8b926 --- /dev/null +++ b/paperless-backend/src/paperless/titel-template.util.spec.ts @@ -0,0 +1,48 @@ +import { resolveTitelTemplate } from './titel-template.util'; + +describe('resolveTitelTemplate', () => { + const created = new Date(2026, 9, 5); // 05.10.2026 + + it('ersetzt Custom-Field-Platzhalter und {{DATE}}', () => { + expect( + resolveTitelTemplate( + 'ERG {{CUSTOM[3]}} vom {{DATE}}', + [{ field: 3, value: 'RE-42' }], + created, + ), + ).toBe('ERG RE-42 vom 05.10.2026'); + }); + + it('ersetzt mehrfach vorkommende Platzhalter', () => { + expect( + resolveTitelTemplate( + '{{CUSTOM[3]}}/{{CUSTOM[3]}}', + [{ field: 3, value: 'X' }], + created, + ), + ).toBe('X/X'); + }); + + it('liefert null, wenn ein referenziertes Feld leer ist oder fehlt', () => { + expect( + resolveTitelTemplate('{{CUSTOM[3]}}', [{ field: 3, value: '' }], created), + ).toBeNull(); + expect(resolveTitelTemplate('{{CUSTOM[7]}}', [], created)).toBeNull(); + }); + + it('liefert null bei leerem Template', () => { + expect(resolveTitelTemplate('', [], created)).toBeNull(); + expect(resolveTitelTemplate(null, [], created)).toBeNull(); + }); + + it('ersetzt {{ZEITSTEMPEL}} mit der übergebenen Uhrzeit', () => { + expect( + resolveTitelTemplate( + 'T {{ZEITSTEMPEL}}', + [], + created, + new Date(2026, 0, 2, 3, 4, 5), + ), + ).toBe('T 02.01.2026 03:04:05'); + }); +}); diff --git a/paperless-backend/src/paperless/titel-template.util.ts b/paperless-backend/src/paperless/titel-template.util.ts new file mode 100644 index 0000000..c57c4c6 --- /dev/null +++ b/paperless-backend/src/paperless/titel-template.util.ts @@ -0,0 +1,41 @@ +const pad = (n: number) => String(n).padStart(2, '0'); + +const formatDatum = (d: Date) => + `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`; + +/** + * Löst ein Titel-Template eines Dokumenttyps auf. + * Platzhalter: {{CUSTOM[]}}, {{DATE}} (Belegdatum), {{ZEITSTEMPEL}} (jetzt). + * Liefert null, wenn kein Template gesetzt ist oder ein referenziertes + * Custom Field leer ist — der Aufrufer behält dann den bisherigen Titel. + */ +export function resolveTitelTemplate( + template: string | null | undefined, + customFields: Array<{ field: number; value: unknown }>, + created: Date, + now: Date = new Date(), +): string | null { + if (!template) return null; + + const placeholderRegex = /\{\{CUSTOM\[(\d+)\]\}\}/g; + let match: RegExpExecArray | null; + while ((match = placeholderRegex.exec(template)) !== null) { + const fieldId = parseInt(match[1], 10); + const cf = customFields.find((f) => f.field === fieldId); + if (!cf || cf.value == null || cf.value === '') return null; + } + + let title = template; + for (const cf of customFields) { + title = title.replaceAll( + `{{CUSTOM[${cf.field}]}}`, + cf.value != null ? String(cf.value as string | number | boolean) : '', + ); + } + title = title.replaceAll('{{DATE}}', formatDatum(created)); + title = title.replaceAll( + '{{ZEITSTEMPEL}}', + `${formatDatum(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, + ); + return title; +}