diff --git a/docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md b/docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md new file mode 100644 index 0000000..98e1248 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md @@ -0,0 +1,1358 @@ +# 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 * * * *`. diff --git a/docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md b/docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md new file mode 100644 index 0000000..9c66cdb --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md @@ -0,0 +1,135 @@ +# Agrarmonitor Polling Service — Design + +**Datum:** 2026-05-23 +**Branch:** Agrarmonitor + +--- + +## Kontext + +Der Polling-Service prüft regelmäßig Paperless-Dokumente, die als "fertig für Agrarmonitor" markiert sind (Tag-ID konfigurierbar), gleicht sie mit Agrarmonitor-Eingangsrechnungen ab und aktualisiert sowohl Agrarmonitor (Eingangsdatum, Lieferscheinnummer) als auch Paperless (Korrespondent, Betrieb, Tags) sobald ein Buchungsdatum vorliegt. + +Logik basiert auf `ProcessEingaenge.cs` aus dem C#-Paperlessworker. + +--- + +## Datenbankänderungen + +### `Client`-Entity +Neue nullable Spalte: +```typescript +@Column({ type: 'int', nullable: true }) +AgrarmonitorBetriebId: number | null; +``` +Verknüpft einen Paperless-Betrieb (Client) mit seiner Agrarmonitor-BetriebId. Wird vom Polling-Service für die Dokumentzuordnung genutzt. + +### `Setting`-Entity (neue Einträge, per Seed/upsert) +Zwei neue Einträge identifiziert über das `Tag`-Feld: +- `Tag = 'agrarmonitor_tag_fertig'`, Wert default `'4'` — Tag-ID für "fertig in Agrarmonitor" +- `Tag = 'agrarmonitor_tag_verbucht'`, Wert default `'9'` — Tag-ID für "verbucht" + +--- + +## Backend + +### Neue Datei: `agrarmonitor-polling.service.ts` + +**Methoden:** +- `runPolling(): Promise` — Haupt-Logik, synchron ausführbar +- Automatischer Start via `@Cron(env.AGRARMONITOR_POLLING_CRON)` + +**Polling-Logik (sequenziell):** +1. Tag-IDs aus `Setting`-Tabelle lesen (Tag-Fertig, Tag-Verbucht) +2. `agrarmonitorService.getClient()` → Connector holen +3. `client.fetchCustomers()` → aktive Lieferanten (`ist_lieferant=1`, `ist_aktiv=1`) als Paperless-Korrespondenten synchronisieren +4. Paperless-Dokumente mit Tag-Fertig laden +5. Pro Dokument mit `interneBelegnummer`: + - `client.eingangsrechnungenLivesearch(belegnummer)` aufrufen + - Kein Treffer → überspringen + - Mehrere Treffer → Fehler loggen, überspringen + - Kein `eingangsDatum` + Paperless hat `Eingangsdatum` → `client.setEingangsdatum()` aufrufen + - `buchungsDatum` vorhanden → Paperless-Dokument aktualisieren: + - Korrespondenten über `AgrarmonitorBetriebId`-Mapping setzen + - Betrieb/Owner/Gruppe aus Client-Tabelle übernehmen + - Tag-Fertig entfernen, Tag-Verbucht hinzufügen + - `paperlessService.updateDocument()` aufrufen + - 500ms Pause zwischen Dokumenten (API-Schonung) +6. `PollingResult` zurückgeben: `{processed, updated, skipped, errors}` + +**Abhängigkeiten:** `AgrarmonitorService`, `PaperlessService`, `Repository`, `Repository` + +### `AgrarmonitorModule` — Erweiterungen +```typescript +imports: [ + TypeOrmModule.forFeature([Setting, Client]), + PaperlessModule, + ScheduleModule, // bereits global registriert +] +providers: [AgrarmonitorService, AgrarmonitorPollingService] +``` + +### Neue Endpoints (in `AgrarmonitorController`) + +| Method | Route | Beschreibung | +|--------|-------|-------------| +| `GET` | `/api/agrarmonitor/polling-config` | Tag-IDs lesen | +| `PUT` | `/api/agrarmonitor/polling-config` | Tag-IDs speichern | +| `POST` | `/api/agrarmonitor/run-polling` | Polling manuell auslösen | + +Alle Routen: `@RequirePermissions(Permission.MANAGE_SETTINGS)` + +### `.env.example` — neuer Eintrag +``` +AGRARMONITOR_POLLING_CRON=0 */30 * * * * # alle 30 Minuten +``` + +--- + +## Frontend + +### Agrarmonitor-Tab (bestehend erweitern) + +**Neuer Abschnitt "Polling-Konfiguration":** +- Input "Tag: Fertig in Agrarmonitor" (Zahl, lädt aus `/api/agrarmonitor/polling-config`) +- Input "Tag: Verbucht" (Zahl) +- Speichern-Button → `PUT /api/agrarmonitor/polling-config` + +**Neuer Abschnitt "Polling ausführen":** +- Button "Jetzt ausführen" → `POST /api/agrarmonitor/run-polling` +- Ergebnisanzeige: "X verarbeitet, X aktualisiert, X Fehler" + +### "Benutzer & Betriebe"-Tab (bestehend erweitern) + +In der Client-Tabelle: neue Spalte "Agrarmonitor-BetriebId" (inline editierbar, Zahl oder leer). +Speichern via `PUT /api/settings/clients/:id` (neuer Endpoint oder bestehenden erweitern). + +--- + +## Datenfluss + +``` +Cron / manueller Trigger + → AgrarmonitorPollingService.runPolling() + → AgrarmonitorService.getClient() (Connector) + → PaperlessService.getDocuments() (Tag-Fertig-Filter) + → pro Dokument: + → client.eingangsrechnungenLivesearch() + → client.setEingangsdatum() (falls nötig) + → client.fetchCustomers() + PaperlessService.updateDocument() +``` + +--- + +## Fehlerbehandlung + +- Einzelne Dokument-Fehler werden geloggt, überspringen den Eintrag, brechen den gesamten Lauf nicht ab +- Connector-Fehler (Verbindung zu Agrarmonitor) brechen den Lauf ab, geben `{error}` zurück +- Ergebnis wird immer als strukturiertes Objekt zurückgegeben (nie 500) + +--- + +## Nicht im Scope + +- E-Mail-Benachrichtigung bei Fehlern (kommt ggf. später via Postprocessing) +- Rückgängig machen von Buchungen +- Polling-Log in der Datenbank (Ergebnis nur in Backend-Logs + API-Response)