# Agrarmonitor Polling Service Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend the Agrarmonitor module with a polling service that checks Paperless documents against Agrarmonitor invoices and updates both systems when a booking date is found. **Architecture:** A new `AgrarmonitorWebService` handles the HTML scraping of admin7.agrarmonitor.de (livesearch, setEingangsdatum, setLieferscheinNummer). A new `AgrarmonitorPollingService` orchestrates the polling loop (cron + manual trigger), reads tag-ID settings from the DB, and updates both Agrarmonitor and Paperless. The `Client` entity gets an `AgrarmonitorBetriebId` column so the BetriebId-to-Owner mapping is configurable instead of hardcoded. **Tech Stack:** NestJS, TypeORM (MySQL, synchronize:true), `node-html-parser`, `@nestjs/schedule` Cron, PaperlessService (already exists), React 19 + Ant Design. --- ## File Map | Action | File | |--------|------| | Modify | `paperless-backend/package.json` | | Modify | `paperless-backend/src/database/entities/client.entity.ts` | | **Create** | `paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts` | | **Create** | `paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts` | | Modify | `paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts` | | Modify | `paperless-backend/src/agrarmonitor/agrarmonitor.module.ts` | | Modify | `paperless-backend/src/settings/settings.controller.ts` | | Modify | `.env.example` | | Modify | `docker-compose.yml` | | Modify | `paperless-frontend/src/api/settings.ts` | | Modify | `paperless-frontend/src/api/inbox.ts` | | Modify | `paperless-frontend/src/pages/SettingsPage.tsx` | --- ### Task 1: Install node-html-parser **Files:** - Modify: `paperless-backend/package.json` - [ ] **Step 1: Install the package** ```bash cd paperless-backend && npm install node-html-parser ``` Expected: `node-html-parser` appears in `dependencies` in `package.json`. - [ ] **Step 2: Verify TypeScript types are available** ```bash cd paperless-backend && node -e "const { parse } = require('node-html-parser'); console.log(typeof parse)" ``` Expected: `function` - [ ] **Step 3: Commit** ```bash git add paperless-backend/package.json paperless-backend/package-lock.json git commit -m "chore: add node-html-parser for Agrarmonitor HTML scraping" ``` --- ### Task 2: Extend Client entity with AgrarmonitorBetriebId **Files:** - Modify: `paperless-backend/src/database/entities/client.entity.ts` - [ ] **Step 1: Add the nullable column** Replace the full file content with: ```typescript import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity('clients') export class Client { @PrimaryGeneratedColumn() Id!: number; @Column({ type: 'varchar', length: 45 }) Name!: string; @Column({ type: 'int' }) PaperlessUserId!: number; @Column({ type: 'int', nullable: true }) AgrarmonitorBetriebId!: number | null; } ``` - [ ] **Step 2: Verify build compiles** ```bash cd paperless-backend && npm run build 2>&1 | tail -5 ``` Expected: no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add paperless-backend/src/database/entities/client.entity.ts git commit -m "feat: add AgrarmonitorBetriebId to Client entity" ``` --- ### Task 3: Create AgrarmonitorWebService (HTML scraping) **Files:** - Create: `paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts` This service wraps the three Agrarmonitor web actions: livesearch, setEingangsdatum, setLieferscheinNummer. It calls the session-authenticated `client.http` Axios instance from `AgrarmonitorService`. - [ ] **Step 1: Create the file** ```typescript // paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { parse } from 'node-html-parser'; import { AgrarmonitorService } from './agrarmonitor.service'; export interface EingangsrechnungEntry { eingangId: number; belegNummer: string; interneBelegNummer: string; kundenId: number; betriebId: number; buchungsDatum: Date | null; eingangsDatum: Date | null; } @Injectable() export class AgrarmonitorWebService { private readonly logger = new Logger(AgrarmonitorWebService.name); private readonly baseUrl: string; constructor( private readonly agrarmonitorService: AgrarmonitorService, private readonly configService: ConfigService, ) { this.baseUrl = this.configService.get( 'AGRARMONITOR_BASE_URL', 'https://admin7.agrarmonitor.de', ); } async eingangsrechnungenLivesearch(suchstring: string): Promise { const client = await this.agrarmonitorService.getClient(); await client.http.get('/'); const searchUrl = `/module/dateien/livesearch.php?suchstring=${encodeURIComponent(suchstring)}` + `&stammdatum_typ=-1&mobil=-1&sensibel=-1&firma=0&itemsperpage=100000&seite=1`; const { data: html } = await client.http.get(searchUrl, { responseType: 'text' }); const root = parse('' + html + ''); const table = root.querySelector('table#dateien'); if (!table) return []; const rows = table.querySelectorAll('tbody tr'); const results: EingangsrechnungEntry[] = []; for (const row of rows) { const tds = row.querySelectorAll('td'); if (tds.length < 4) continue; if (!tds[3].text.trim().startsWith('Eingangsrechnungen')) continue; const linkEl = tds[3].querySelector('a'); if (!linkEl) continue; const href = linkEl.getAttribute('href') ?? ''; const eingangId = parseInt(href.split('/').pop() ?? '0', 10); if (!eingangId) continue; const belegText = linkEl.text; const belegParts = belegText.split(','); const belegNummer = belegParts[0]?.trim() ?? ''; const { data: editHtml } = await client.http.get( `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, { responseType: 'text' }, ); const editRoot = parse(editHtml); const interneBelegNummer = editRoot.querySelector('input[name="lieferscheinnummer"]')?.getAttribute('value') ?? ''; const kundenId = parseInt( editRoot .querySelector('select[name="rgempf"] option[selected]') ?.getAttribute('value') ?? '0', 10, ); const betriebId = parseInt( editRoot .querySelector('select[name="firma_id"] option[selected]') ?.getAttribute('value') ?? '0', 10, ); const { data: detailHtml } = await client.http.get( `/eingangsrechnungen/detail/${eingangId}`, { responseType: 'text' }, ); const detailRoot = parse(detailHtml); const receivedEl = detailRoot.getElementById('receivedStatus'); let eingangsDatum: Date | null = null; let buchungsDatum: Date | null = null; if (receivedEl) { const eingangsText = receivedEl.text.trim(); if (eingangsText !== 'Nicht empfangen' && eingangsText.length > 13) { eingangsDatum = this.parseGermanDate(eingangsText.substring(13)); } const parentText = receivedEl.parentNode?.text ?? ''; const dashIdx = parentText.lastIndexOf('-'); const buchenText = dashIdx >= 0 ? parentText.substring(dashIdx + 1).trim() : ''; if (buchenText !== 'Nicht gebucht' && buchenText.length > 11) { buchungsDatum = this.parseGermanDate(buchenText.substring(11)); } } results.push({ eingangId, belegNummer, interneBelegNummer, kundenId, betriebId, buchungsDatum, eingangsDatum }); } return results; } async setEingangsdatum(eingangId: number, _belegNummer: string, datum: Date): Promise { const client = await this.agrarmonitorService.getClient(); const params = new URLSearchParams(); params.append('datum', this.formatGermanDate(datum)); params.append('receiptID', String(eingangId)); try { const res = await client.http.post( '/module/eingangsrechnungen/api/updateReceived.php', params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, Origin: this.baseUrl, }, }, ); return res.status < 400; } catch (err: any) { this.logger.error(`setEingangsdatum(${eingangId}) Fehler: ${err?.message}`); return false; } } async setLieferscheinNummer(eingangId: number, lieferscheinNummer: string): Promise { const client = await this.agrarmonitorService.getClient(); const { data: editHtml } = await client.http.get( `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, { responseType: 'text' }, ); const editRoot = parse(editHtml); const rechnungsnummer = editRoot.querySelector('input[name="rechnungsnummer"]')?.getAttribute('value') ?? ''; const rechnungsdatum = editRoot.querySelector('input[name="rechnungsdatum"]')?.getAttribute('value') ?? ''; const rgempf = editRoot .querySelector('select[name="rgempf"] option[selected]') ?.getAttribute('value') ?? ''; const addressName = editRoot.querySelector('input[name="addressName"]')?.getAttribute('value') ?? ''; const params = new URLSearchParams(); params.append('lieferscheinnummer', lieferscheinNummer); params.append('rechnungsnummer', rechnungsnummer); params.append('rechnungsdatum', rechnungsdatum); params.append('rgempf', rgempf); params.append('adresstext', addressName); try { const res = await client.http.post( `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=update&rechnungId=${eingangId}`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, Origin: this.baseUrl, }, }, ); return res.status < 400; } catch (err: any) { this.logger.error(`setLieferscheinNummer(${eingangId}) Fehler: ${err?.message}`); return false; } } private parseGermanDate(str: string): Date | null { const parts = str.trim().split('.'); if (parts.length !== 3) return null; const [dd, mm, yy] = parts; const year = parseInt(yy, 10) < 50 ? 2000 + parseInt(yy, 10) : 1900 + parseInt(yy, 10); const d = new Date(year, parseInt(mm, 10) - 1, parseInt(dd, 10)); return isNaN(d.getTime()) ? null : d; } private formatGermanDate(date: Date): string { const dd = String(date.getDate()).padStart(2, '0'); const mm = String(date.getMonth() + 1).padStart(2, '0'); const yy = String(date.getFullYear()).slice(-2); return `${dd}.${mm}.${yy}`; } } ``` - [ ] **Step 2: Verify build compiles** ```bash cd paperless-backend && npm run build 2>&1 | tail -5 ``` Expected: no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts git commit -m "feat: add AgrarmonitorWebService with livesearch and date setters" ``` --- ### Task 4: Create AgrarmonitorPollingService **Files:** - Create: `paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts` This service: - Seeds the two tag-ID settings on startup (`agrarmonitor_tag_fertig` default `'4'`, `agrarmonitor_tag_verbucht` default `'9'`) - Runs on cron when `AGRARMONITOR_POLLING_CRON` env var is set - Exposes `runPolling()` and `getPollingConfig()`/`updatePollingConfig()` for the controller Custom field IDs (hardcoded to match the C# reference, change if your Paperless installation differs): - `interneBelegnummer` = custom field `7` - `Eingangsdatum` = custom field `9` - [ ] **Step 1: Create the file** ```typescript // paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorWebService } from './agrarmonitor-web.service'; import { PaperlessService } from '../paperless/paperless.service'; import { Setting } from '../database/entities/setting.entity'; import { Client } from '../database/entities/client.entity'; const INTERN_BELEGNUMMER_FIELD_ID = 7; const EINGANGSDATUM_FIELD_ID = 9; export interface PollingResult { processed: number; updated: number; skipped: number; errors: string[]; } @Injectable() export class AgrarmonitorPollingService implements OnModuleInit { private readonly logger = new Logger(AgrarmonitorPollingService.name); constructor( private readonly agrarmonitorService: AgrarmonitorService, private readonly webService: AgrarmonitorWebService, private readonly paperlessService: PaperlessService, @InjectRepository(Setting) private readonly settingRepo: Repository, @InjectRepository(Client) private readonly clientRepo: Repository, ) {} async onModuleInit() { await this.upsertSetting('agrarmonitor_tag_fertig', '4'); await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); } @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') async scheduledPolling() { if (!process.env['AGRARMONITOR_POLLING_CRON']) return; this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); } async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string }> { const [fertig, verbucht] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), ]); return { tagFertig: fertig?.Wert ?? '4', tagVerbucht: verbucht?.Wert ?? '9', }; } 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 runPolling(): Promise { const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] }; this.logger.log('Starte Agrarmonitor-Polling'); const [tagFertigSetting, tagVerbuchtSetting] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), ]); const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10); const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10); let amClient: Awaited>; try { amClient = await this.agrarmonitorService.getClient(); } catch (err: any) { const msg = `Connector-Fehler: ${err?.message ?? 'unbekannt'}`; this.logger.error(msg); return { ...result, errors: [msg] }; } let customers: any[]; try { customers = await amClient.fetchCustomers(); } catch (err: any) { const msg = `Kunden-Abruf fehlgeschlagen: ${err?.message ?? 'unbekannt'}`; this.logger.error(msg); return { ...result, errors: [msg] }; } for (const customer of customers.filter( (c: any) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1, )) { const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; const searchName = `(${lieferantennummer})`; const displayName = this.buildCustomerName(customer, lieferantennummer); const existing = await this.paperlessService.getCorrespondentByName(searchName); if (!existing) { await this.paperlessService.addCorrespondent({ name: displayName, match: '', matching_algorithm: 0, is_insensitive: true, owner: null, }); } } const docsResponse = await this.paperlessService.getDocuments({ page: 1, page_size: 9999, truncate_content: true, tags__id__all: tagFertigId, }); const docs: any[] = docsResponse?.results ?? []; this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); 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 amResults: Awaited>; try { amResults = await this.webService.eingangsrechnungenLivesearch(interneBelegnummer); } catch (err: any) { this.logger.error(`Livesearch ${interneBelegnummer}: ${err?.message}`); result.errors.push(`${interneBelegnummer}: Livesearch-Fehler`); await this.delay(500); continue; } if (amResults.length === 0) { this.logger.log(`${interneBelegnummer} nicht in Agrarmonitor gefunden`); result.skipped++; await this.delay(500); continue; } if (amResults.length > 1) { this.logger.error(`${interneBelegnummer} mehrfach in Agrarmonitor gefunden`); result.errors.push(`${interneBelegnummer}: Mehrfach gefunden`); await this.delay(500); continue; } const amDoc = amResults[0]; if (!amDoc.interneBelegNummer && interneBelegnummer) { await this.webService.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer); } if (!amDoc.eingangsDatum) { const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find( (cf: any) => cf.field === EINGANGSDATUM_FIELD_ID, ); if (eingangsdatumField?.value) { const eingangsdatum = new Date(eingangsdatumField.value as string); if (!isNaN(eingangsdatum.getTime())) { await this.webService.setEingangsdatum(amDoc.eingangId, amDoc.belegNummer, eingangsdatum); this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); } } } else if (amDoc.buchungsDatum) { try { let correspondentId: number | undefined; const customer = customers.find((c: any) => 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); if (!corr) { corr = await this.paperlessService.addCorrespondent({ name: displayName, match: '', matching_algorithm: 0, is_insensitive: true, owner: null, }); } if (corr) correspondentId = corr.id as number; } let ownerId: number | undefined; const matchedClient = await this.clientRepo.findOneBy({ AgrarmonitorBetriebId: amDoc.betriebId, }); if (matchedClient) ownerId = matchedClient.PaperlessUserId; const currentTags: number[] = (doc.tags as number[]) ?? []; const newTags = currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]); const updateData: Record = { tags: newTags }; if (correspondentId !== undefined) updateData.correspondent = correspondentId; if (ownerId !== undefined) updateData.owner = ownerId; await this.paperlessService.updateDocument(doc.id as number, updateData); this.logger.log(`Beleg ${interneBelegnummer} gebucht`); result.updated++; } catch (err: any) { this.logger.error(`Update ${interneBelegnummer}: ${err?.message}`); result.errors.push(`${interneBelegnummer}: Update-Fehler`); } } else { result.skipped++; } await this.delay(500); } this.logger.log( `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ${result.skipped} übersprungen, ${result.errors.length} Fehler`, ); return result; } private buildCustomerName(customer: any, nummer: string): string { const firma = (customer.firma as string) ?? ''; const nachname = (customer['nachname'] as string) ?? ''; const vorname = (customer['vorname'] as string) ?? ''; const name = firma || (nachname + (vorname ? ', ' + vorname : '')); return `${name} (${nummer})`; } private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } private async upsertSetting(tag: string, defaultValue: string): Promise { const existing = await this.settingRepo.findOneBy({ Tag: tag }); if (!existing) { await this.settingRepo.save( this.settingRepo.create({ Typ: 1, Wert: defaultValue, Tag: tag }), ); } } } ``` - [ ] **Step 2: Verify build compiles** ```bash cd paperless-backend && npm run build 2>&1 | tail -5 ``` Expected: no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts git commit -m "feat: add AgrarmonitorPollingService with cron and runPolling" ``` --- ### Task 5: Extend AgrarmonitorController with 3 new endpoints **Files:** - Modify: `paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts` Replace the entire file with: - [ ] **Step 1: Update the controller** ```typescript // paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts import { Body, Controller, Get, HttpCode, Post, Put } from '@nestjs/common'; import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @Controller('api/agrarmonitor') export class AgrarmonitorController { constructor( private readonly service: AgrarmonitorService, private readonly pollingService: AgrarmonitorPollingService, ) {} @Get('status') @RequirePermissions(Permission.MANAGE_SETTINGS) async getStatus() { return this.service.getStatus(); } @Post('register') @HttpCode(200) @RequirePermissions(Permission.MANAGE_SETTINGS) async registerDevice(@Body() body: { pcName: string; agrarmonitorId: string }) { return this.service.registerDevice(body.pcName, body.agrarmonitorId); } @Get('polling-config') @RequirePermissions(Permission.MANAGE_SETTINGS) async getPollingConfig() { return this.pollingService.getPollingConfig(); } @Put('polling-config') @RequirePermissions(Permission.MANAGE_SETTINGS) async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string }) { return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht); } @Post('run-polling') @HttpCode(200) @RequirePermissions(Permission.MANAGE_SETTINGS) async runPolling() { return this.pollingService.runPolling(); } } ``` - [ ] **Step 2: Verify build compiles** ```bash cd paperless-backend && npm run build 2>&1 | tail -5 ``` Expected: no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts git commit -m "feat: add polling-config and run-polling endpoints to AgrarmonitorController" ``` --- ### Task 6: Update AgrarmonitorModule **Files:** - Modify: `paperless-backend/src/agrarmonitor/agrarmonitor.module.ts` - [ ] **Step 1: Replace the module file** ```typescript // paperless-backend/src/agrarmonitor/agrarmonitor.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorWebService } from './agrarmonitor-web.service'; import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; import { AgrarmonitorController } from './agrarmonitor.controller'; import { Setting } from '../database/entities/setting.entity'; import { Client } from '../database/entities/client.entity'; import { PaperlessModule } from '../paperless/paperless.module'; @Module({ imports: [ TypeOrmModule.forFeature([Setting, Client]), PaperlessModule, ], providers: [AgrarmonitorService, AgrarmonitorWebService, AgrarmonitorPollingService], controllers: [AgrarmonitorController], exports: [AgrarmonitorService], }) export class AgrarmonitorModule {} ``` - [ ] **Step 2: Verify build compiles** ```bash cd paperless-backend && npm run build 2>&1 | tail -5 ``` Expected: no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add paperless-backend/src/agrarmonitor/agrarmonitor.module.ts git commit -m "feat: wire AgrarmonitorModule with TypeOrm, PaperlessModule, and new services" ``` --- ### Task 7: Add GET/PUT clients endpoints to SettingsController **Files:** - Modify: `paperless-backend/src/settings/settings.controller.ts` Add two methods at the end of the `// === Benutzer-Betrieb Zuordnung ===` section (around line 295), before the `// === Allgemeine Einstellungen ===` section. - [ ] **Step 1: Add the two client endpoints** In `paperless-backend/src/settings/settings.controller.ts`, find the line: ```typescript // === Allgemeine Einstellungen === ``` Add the following two methods immediately before it: ```typescript // === Betriebe (Clients) === @Get('clients') async getAllClients() { return this.clientRepo.find({ order: { Id: 'ASC' } }); } @Put('clients/:id') async updateClient(@Param('id') id: string, @Body() body: { AgrarmonitorBetriebId: number | null }) { await this.clientRepo.update(parseInt(id, 10), { AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null, }); return this.clientRepo.findOneByOrFail({ Id: parseInt(id, 10) }); } ``` - [ ] **Step 2: Verify build compiles** ```bash cd paperless-backend && npm run build 2>&1 | tail -5 ``` Expected: no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add paperless-backend/src/settings/settings.controller.ts git commit -m "feat: add GET/PUT /api/settings/clients endpoints for AgrarmonitorBetriebId management" ``` --- ### Task 8: Update .env.example and docker-compose.yml **Files:** - Modify: `.env.example` - Modify: `docker-compose.yml` - [ ] **Step 1: Add AGRARMONITOR_POLLING_CRON to .env.example** Find the line `AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung` and add the following line immediately after it: ``` AGRARMONITOR_POLLING_CRON=0 */30 * * * * # alle 30 Minuten (leer lassen = deaktiviert) ``` - [ ] **Step 2: Add AGRARMONITOR_POLLING_CRON to docker-compose.yml** Find the line `- AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-}` and add the following line immediately after it: ```yaml - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} ``` - [ ] **Step 3: Commit** ```bash git add .env.example docker-compose.yml git commit -m "chore: add AGRARMONITOR_POLLING_CRON env var to .env.example and docker-compose.yml" ``` --- ### Task 9: Frontend — Update API files **Files:** - Modify: `paperless-frontend/src/api/settings.ts` - Modify: `paperless-frontend/src/api/inbox.ts` - [ ] **Step 1: Update Client interface in inbox.ts** In `paperless-frontend/src/api/inbox.ts`, find: ```typescript export interface Client { Id: number; Name: string; PaperlessUserId: number; } ``` Replace with: ```typescript export interface Client { Id: number; Name: string; PaperlessUserId: number; AgrarmonitorBetriebId: number | null; } ``` - [ ] **Step 2: Add polling types and API methods to settings.ts** In `paperless-frontend/src/api/settings.ts`, find the `export const agrarmonitorApi = {` block and replace it with: ```typescript export interface PollingConfig { tagFertig: string; tagVerbucht: string; } export interface PollingResult { processed: number; updated: number; skipped: number; errors: string[]; } export interface AgrarmonitorClient { Id: number; Name: string; PaperlessUserId: number; AgrarmonitorBetriebId: number | null; } export const agrarmonitorApi = { getStatus: () => api.get('/api/agrarmonitor/status').then((r) => r.data), registerDevice: (pcName: string, agrarmonitorId: string) => api .post<{ success: boolean; message: string }>('/api/agrarmonitor/register', { pcName, agrarmonitorId }) .then((r) => r.data), getPollingConfig: () => api.get('/api/agrarmonitor/polling-config').then((r) => r.data), updatePollingConfig: (config: PollingConfig) => api.put('/api/agrarmonitor/polling-config', config).then((r) => r.data), runPolling: () => api.post('/api/agrarmonitor/run-polling', {}).then((r) => r.data), getAllClients: () => api.get('/api/settings/clients').then((r) => r.data), updateClient: (id: number, data: { AgrarmonitorBetriebId: number | null }) => api.put(`/api/settings/clients/${id}`, data).then((r) => r.data), }; ``` - [ ] **Step 3: Verify frontend TypeScript compiles** ```bash cd paperless-frontend && npm run build 2>&1 | tail -10 ``` Expected: no TypeScript errors. - [ ] **Step 4: Commit** ```bash git add paperless-frontend/src/api/settings.ts paperless-frontend/src/api/inbox.ts git commit -m "feat: add polling config/run API and AgrarmonitorBetriebId to frontend API layer" ``` --- ### Task 10: Frontend — Extend SettingsPage.tsx **Files:** - Modify: `paperless-frontend/src/pages/SettingsPage.tsx` Two sections to update: `AgrarmonitorTab` (add polling config + run button) and `UserClientsTab` (add Betriebe management table). #### Part A — AgrarmonitorTab - [ ] **Step 1: Add imports** Find the existing imports from `'../api/settings'` (around line 16). Add the new types: ```typescript agrarmonitorApi, type AgrarmonitorStatusData, type PollingConfig, type PollingResult, type AgrarmonitorClient, ``` (Replace the current `agrarmonitorApi, type AgrarmonitorStatusData,` with the above.) - [ ] **Step 2: Replace the AgrarmonitorTab function** Find the comment `// ═══════════════════════════════════════════════════════════════════` directly above `function AgrarmonitorTab()` and replace the entire `AgrarmonitorTab` function (up to the closing `}` before the next `// ═══...` comment) with: ```typescript // ═══════════════════════════════════════════════════════════════════ // Agrarmonitor Tab // ═══════════════════════════════════════════════════════════════════ function AgrarmonitorTab() { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [registering, setRegistering] = useState(false); const [status, setStatus] = useState(null); const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); const [pollingConfig, setPollingConfig] = useState({ tagFertig: '4', tagVerbucht: '9' }); const [savingConfig, setSavingConfig] = useState(false); const [runningPolling, setRunningPolling] = useState(false); const [pollingResult, setPollingResult] = useState(null); useEffect(() => { agrarmonitorApi.getPollingConfig().then(setPollingConfig).catch(() => {}); }, []); const handleLoadStatus = async () => { setLoading(true); setRegisterResult(null); try { const data = await agrarmonitorApi.getStatus(); setStatus(data); } catch (err: any) { const msg = err?.code === 'ECONNABORTED' ? 'Timeout – Backend antwortet nicht rechtzeitig' : (err?.response?.data?.message ?? err?.message ?? 'Netzwerkfehler'); setStatus({ connected: false, registriert: null, freigeschaltet: null, error: msg }); } finally { setLoading(false); } }; const handleRegister = async () => { const values = await form.validateFields(); setRegistering(true); setRegisterResult(null); try { const result = await agrarmonitorApi.registerDevice(values.pcName, values.agrarmonitorId); setRegisterResult(result); if (result.success) { message.success('Gerät erfolgreich registriert'); await handleLoadStatus(); } } catch { setRegisterResult({ success: false, message: 'Registrierung fehlgeschlagen' }); } finally { setRegistering(false); } }; const handleSaveConfig = async () => { setSavingConfig(true); try { await agrarmonitorApi.updatePollingConfig(pollingConfig); message.success('Konfiguration gespeichert'); } catch { message.error('Fehler beim Speichern'); } finally { setSavingConfig(false); } }; const handleRunPolling = async () => { setRunningPolling(true); setPollingResult(null); try { const result = await agrarmonitorApi.runPolling(); setPollingResult(result); } catch (err: any) { message.error(err?.response?.data?.message ?? 'Polling fehlgeschlagen'); } finally { setRunningPolling(false); } }; const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => { if (value === null) return ; return value ? {labelTrue} : {labelFalse}; }; return (
Agrarmonitor Verbindungsstatus und Geräte-Registrierung für die Agrarmonitor-Schnittstelle. Zugangsdaten werden in der .env konfiguriert.
{status && (
Verbindung: {status.connected ? Verbunden : Nicht verbunden}
Registriert: {renderStatusTag(status.registriert, 'Ja', 'Nein')}
Freigeschaltet: {renderStatusTag(status.freigeschaltet, 'Ja', 'Nein')}
{status.error && (
{status.error}
)}
)} {status?.registriert === false && (
{registerResult && (
{registerResult.message}
)}
)}
Tag: Fertig in Agrarmonitor
setPollingConfig((c) => ({ ...c, tagFertig: String(v ?? 4) }))} style={{ width: '100%' }} />
Tag: Verbucht
setPollingConfig((c) => ({ ...c, tagVerbucht: String(v ?? 9) }))} style={{ width: '100%' }} />
{pollingResult && ( {pollingResult.processed} verarbeitet {pollingResult.updated} aktualisiert {pollingResult.skipped} übersprungen {pollingResult.errors.length > 0 && ( {pollingResult.errors.length} Fehler )} )} {pollingResult?.errors && pollingResult.errors.length > 0 && (
{pollingResult.errors.map((e, i) =>
{e}
)}
)}
); } ``` #### Part B — UserClientsTab: add Betriebe management - [ ] **Step 3: Add agrarmonitorApi import to UserClientsTab usage area** The `agrarmonitorApi` and `AgrarmonitorClient` are already imported at the top of the file (added in Task 9, Step 1). - [ ] **Step 4: Replace the UserClientsTab function** Find the comment `// ═══════════════════════════════════════════════════════════════════` directly above `function UserClientsTab()` and replace the entire `UserClientsTab` function with: ```typescript // ═══════════════════════════════════════════════════════════════════ // Benutzer & Betriebe Tab // ═══════════════════════════════════════════════════════════════════ function UserClientsTab() { const [data, setData] = useState([]); const [clients, setClients] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [allClients, setAllClients] = useState([]); const [editValues, setEditValues] = useState>({}); const [savingClients, setSavingClients] = useState>({}); const load = useCallback(async () => { setLoading(true); try { const [ucs, cls, agrarClients] = await Promise.all([ settingsApi.getUserClients(), clientsApi.getMyClients(), agrarmonitorApi.getAllClients(), ]); setData(ucs); setClients(cls); setAllClients(agrarClients); const initialEdit: Record = {}; agrarClients.forEach((c) => { initialEdit[c.Id] = c.AgrarmonitorBetriebId; }); setEditValues(initialEdit); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const handleAdd = async () => { const values = await form.validateFields(); await settingsApi.createUserClient(values); message.success('Zuordnung erstellt'); setModalOpen(false); form.resetFields(); load(); }; const handleDelete = async (id: number) => { await settingsApi.deleteUserClient(id); message.success('Gelöscht'); load(); }; const handleSaveClient = async (clientId: number) => { setSavingClients((s) => ({ ...s, [clientId]: true })); try { await agrarmonitorApi.updateClient(clientId, { AgrarmonitorBetriebId: editValues[clientId] ?? null }); message.success('Gespeichert'); } catch { message.error('Fehler beim Speichern'); } finally { setSavingClients((s) => ({ ...s, [clientId]: false })); } }; const columns: ColumnsType = [ { title: 'User ID', dataIndex: 'UserId', key: 'userId' }, { title: 'Betrieb', key: 'client', render: (_, r) => clients.find(c => c.Id === r.ClientId)?.Name ?? r.ClientId, }, { title: 'Rolle', dataIndex: 'Role', key: 'role', render: (r: string) => {r}, }, { title: '', key: 'actions', width: 80, render: (_, record) => ( handleDelete(record.Id)}> ), }, ]; return ( <> Benutzer-Betrieb-Zuordnung Betriebe (Agrarmonitor-Mapping)
setModalOpen(false)}>
); } ``` - [ ] **Step 5: Verify frontend TypeScript compiles** ```bash cd paperless-frontend && npm run build 2>&1 | tail -10 ``` Expected: no TypeScript errors. - [ ] **Step 6: Commit** ```bash git add paperless-frontend/src/pages/SettingsPage.tsx git commit -m "feat: extend AgrarmonitorTab with polling config/run and UserClientsTab with BetriebId mapping" ``` --- ## Self-Review Checklist **Spec coverage:** - [x] `Client.AgrarmonitorBetriebId` → Task 2 - [x] Setting seed `agrarmonitor_tag_fertig` / `agrarmonitor_tag_verbucht` → Task 4 `onModuleInit` - [x] `AgrarmonitorPollingService.runPolling()` with cron + manual trigger → Tasks 4 + 5 - [x] `GET/PUT /api/agrarmonitor/polling-config` → Task 5 - [x] `POST /api/agrarmonitor/run-polling` → Task 5 - [x] Module wiring → Task 6 - [x] `GET/PUT /api/settings/clients/:id` → Task 7 - [x] `AGRARMONITOR_POLLING_CRON` env var → Task 8 - [x] Frontend polling config UI → Task 10 Part A - [x] Frontend Betriebe BetriebId column → Task 10 Part B - [x] `node-html-parser` install → Task 1 - [x] All HTML-scraping calls (livesearch, setEingangsdatum, setLieferscheinNummer) → Task 3 **Notes:** - `Betrieb` (Paperless custom field 6) and `Gruppe` are NOT set on the document during polling because these values are not stored in the `Client` entity. Only `owner` (from `Client.PaperlessUserId`) is updated. This matches the spec's explicit data model — the C# hardcoded switch is replaced by the `AgrarmonitorBetriebId` mapping. - Custom field IDs 7 (interneBelegnummer) and 9 (Eingangsdatum) are hardcoded in `AgrarmonitorPollingService` as constants `INTERN_BELEGNUMMER_FIELD_ID` and `EINGANGSDATUM_FIELD_ID`. Change these if your Paperless installation uses different IDs. - The cron guard `if (!process.env['AGRARMONITOR_POLLING_CRON']) return;` ensures the scheduled polling is a no-op when the env var is not set, even though the `@Cron` decorator falls back to `0 */30 * * * *`.