Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
47 KiB
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
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
cd paperless-backend && node -e "const { parse } = require('node-html-parser'); console.log(typeof parse)"
Expected: function
- Step 3: Commit
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:
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
cd paperless-backend && npm run build 2>&1 | tail -5
Expected: no TypeScript errors.
- Step 3: Commit
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
// 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<string>(
'AGRARMONITOR_BASE_URL',
'https://admin7.agrarmonitor.de',
);
}
async eingangsrechnungenLivesearch(suchstring: string): Promise<EingangsrechnungEntry[]> {
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<string>(searchUrl, { responseType: 'text' });
const root = parse('<doc>' + html + '</doc>');
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<string>(
`/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<string>(
`/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<boolean> {
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<boolean> {
const client = await this.agrarmonitorService.getClient();
const { data: editHtml } = await client.http.get<string>(
`/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
cd paperless-backend && npm run build 2>&1 | tail -5
Expected: no TypeScript errors.
- Step 3: Commit
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_fertigdefault'4',agrarmonitor_tag_verbuchtdefault'9') - Runs on cron when
AGRARMONITOR_POLLING_CRONenv var is set - Exposes
runPolling()andgetPollingConfig()/updatePollingConfig()for the controller
Custom field IDs (hardcoded to match the C# reference, change if your Paperless installation differs):
-
interneBelegnummer= custom field7 -
Eingangsdatum= custom field9 -
Step 1: Create the file
// 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<Setting>,
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
) {}
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<PollingResult> {
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<ReturnType<typeof this.agrarmonitorService.getClient>>;
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<ReturnType<typeof this.webService.eingangsrechnungenLivesearch>>;
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<string, any> = { 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async upsertSetting(tag: string, defaultValue: string): Promise<void> {
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
cd paperless-backend && npm run build 2>&1 | tail -5
Expected: no TypeScript errors.
- Step 3: Commit
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
// 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
cd paperless-backend && npm run build 2>&1 | tail -5
Expected: no TypeScript errors.
- Step 3: Commit
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
// 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
cd paperless-backend && npm run build 2>&1 | tail -5
Expected: no TypeScript errors.
- Step 3: Commit
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:
// === Allgemeine Einstellungen ===
Add the following two methods immediately before it:
// === 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
cd paperless-backend && npm run build 2>&1 | tail -5
Expected: no TypeScript errors.
- Step 3: Commit
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:
- AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-}
- Step 3: Commit
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:
export interface Client {
Id: number;
Name: string;
PaperlessUserId: number;
}
Replace with:
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:
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<AgrarmonitorStatusData>('/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<PollingConfig>('/api/agrarmonitor/polling-config').then((r) => r.data),
updatePollingConfig: (config: PollingConfig) =>
api.put<PollingConfig>('/api/agrarmonitor/polling-config', config).then((r) => r.data),
runPolling: () =>
api.post<PollingResult>('/api/agrarmonitor/run-polling', {}).then((r) => r.data),
getAllClients: () =>
api.get<AgrarmonitorClient[]>('/api/settings/clients').then((r) => r.data),
updateClient: (id: number, data: { AgrarmonitorBetriebId: number | null }) =>
api.put<AgrarmonitorClient>(`/api/settings/clients/${id}`, data).then((r) => r.data),
};
- Step 3: Verify frontend TypeScript compiles
cd paperless-frontend && npm run build 2>&1 | tail -10
Expected: no TypeScript errors.
- Step 4: Commit
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:
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:
// ═══════════════════════════════════════════════════════════════════
// Agrarmonitor Tab
// ═══════════════════════════════════════════════════════════════════
function AgrarmonitorTab() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [registering, setRegistering] = useState(false);
const [status, setStatus] = useState<AgrarmonitorStatusData | null>(null);
const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null);
const [pollingConfig, setPollingConfig] = useState<PollingConfig>({ tagFertig: '4', tagVerbucht: '9' });
const [savingConfig, setSavingConfig] = useState(false);
const [runningPolling, setRunningPolling] = useState(false);
const [pollingResult, setPollingResult] = useState<PollingResult | null>(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 <Tag>–</Tag>;
return value
? <Tag color="success">{labelTrue}</Tag>
: <Tag color="error">{labelFalse}</Tag>;
};
return (
<div style={{ maxWidth: 600 }}>
<Typography.Title level={4}>Agrarmonitor</Typography.Title>
<Typography.Paragraph type="secondary">
Verbindungsstatus und Geräte-Registrierung für die Agrarmonitor-Schnittstelle.
Zugangsdaten werden in der <code>.env</code> konfiguriert.
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<Button loading={loading} onClick={handleLoadStatus}>
Status abrufen
</Button>
</div>
{status && (
<Card size="small" title="Status">
<Space direction="vertical">
<div>
<strong>Verbindung: </strong>
{status.connected
? <Tag color="success">Verbunden</Tag>
: <Tag color="error">Nicht verbunden</Tag>}
</div>
<div>
<strong>Registriert: </strong>
{renderStatusTag(status.registriert, 'Ja', 'Nein')}
</div>
<div>
<strong>Freigeschaltet: </strong>
{renderStatusTag(status.freigeschaltet, 'Ja', 'Nein')}
</div>
{status.error && (
<div style={{ color: '#ff4d4f' }}>{status.error}</div>
)}
</Space>
</Card>
)}
{status?.registriert === false && (
<Card size="small" title="Gerät registrieren">
<Form form={form} layout="vertical">
<Form.Item
name="pcName"
label="PC-Name"
rules={[{ required: true, message: 'Bitte PC-Name eingeben' }]}
>
<Input placeholder="BUERO-PC-01" />
</Form.Item>
<Form.Item
name="agrarmonitorId"
label="Agrarmonitor-ID / Firma"
rules={[{ required: true, message: 'Bitte Agrarmonitor-ID eingeben' }]}
>
<Input placeholder="Agrarmonitor-ID" />
</Form.Item>
<Button type="primary" loading={registering} onClick={handleRegister}>
Gerät registrieren
</Button>
</Form>
{registerResult && (
<div style={{ marginTop: 12 }}>
<Tag color={registerResult.success ? 'success' : 'error'}>
{registerResult.message}
</Tag>
</div>
)}
</Card>
)}
<Card size="small" title="Polling-Konfiguration">
<Space direction="vertical" style={{ width: '100%' }}>
<Row gutter={16}>
<Col span={12}>
<div style={{ marginBottom: 4 }}>Tag: Fertig in Agrarmonitor</div>
<InputNumber
value={parseInt(pollingConfig.tagFertig, 10)}
min={1}
onChange={(v) => setPollingConfig((c) => ({ ...c, tagFertig: String(v ?? 4) }))}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<div style={{ marginBottom: 4 }}>Tag: Verbucht</div>
<InputNumber
value={parseInt(pollingConfig.tagVerbucht, 10)}
min={1}
onChange={(v) => setPollingConfig((c) => ({ ...c, tagVerbucht: String(v ?? 9) }))}
style={{ width: '100%' }}
/>
</Col>
</Row>
<Button loading={savingConfig} onClick={handleSaveConfig}>
Konfiguration speichern
</Button>
</Space>
</Card>
<Card size="small" title="Polling ausführen">
<Space direction="vertical" style={{ width: '100%' }}>
<Button type="primary" loading={runningPolling} onClick={handleRunPolling}>
Jetzt ausführen
</Button>
{pollingResult && (
<Space wrap>
<Tag color="blue">{pollingResult.processed} verarbeitet</Tag>
<Tag color="success">{pollingResult.updated} aktualisiert</Tag>
<Tag>{pollingResult.skipped} übersprungen</Tag>
{pollingResult.errors.length > 0 && (
<Tag color="error">{pollingResult.errors.length} Fehler</Tag>
)}
</Space>
)}
{pollingResult?.errors && pollingResult.errors.length > 0 && (
<div style={{ fontSize: 12, color: '#ff4d4f' }}>
{pollingResult.errors.map((e, i) => <div key={i}>{e}</div>)}
</div>
)}
</Space>
</Card>
</Space>
</div>
);
}
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:
// ═══════════════════════════════════════════════════════════════════
// Benutzer & Betriebe Tab
// ═══════════════════════════════════════════════════════════════════
function UserClientsTab() {
const [data, setData] = useState<SettingUserClient[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [allClients, setAllClients] = useState<AgrarmonitorClient[]>([]);
const [editValues, setEditValues] = useState<Record<number, number | null>>({});
const [savingClients, setSavingClients] = useState<Record<number, boolean>>({});
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<number, number | null> = {};
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<SettingUserClient> = [
{ 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) => <Tag color={r === 'admin' ? 'red' : r === 'editor' ? 'blue' : 'default'}>{r}</Tag>,
},
{
title: '',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm title="Wirklich löschen?" onConfirm={() => handleDelete(record.Id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
),
},
];
const clientColumns: ColumnsType<AgrarmonitorClient> = [
{ title: 'Betrieb', dataIndex: 'Name', key: 'name' },
{
title: 'Agrarmonitor-BetriebId',
key: 'betriebId',
width: 220,
render: (_, record) => (
<InputNumber
value={editValues[record.Id] ?? undefined}
min={1}
placeholder="–"
onChange={(v) => setEditValues((prev) => ({ ...prev, [record.Id]: v ?? null }))}
style={{ width: 120 }}
/>
),
},
{
title: '',
key: 'save',
width: 100,
render: (_, record) => (
<Button
size="small"
loading={savingClients[record.Id]}
onClick={() => handleSaveClient(record.Id)}
>
Speichern
</Button>
),
},
];
return (
<>
<Typography.Title level={5} style={{ marginTop: 0 }}>Benutzer-Betrieb-Zuordnung</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
Zuordnung hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Divider />
<Typography.Title level={5}>Betriebe (Agrarmonitor-Mapping)</Typography.Title>
<Table
dataSource={allClients}
columns={clientColumns}
loading={loading}
rowKey="Id"
size="small"
pagination={false}
/>
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="UserId" label="User ID (Authentik)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="ClientId" label="Betrieb" rules={[{ required: true }]}>
<Select>
{clients.map(c => <Select.Option key={c.Id} value={c.Id}>{c.Name}</Select.Option>)}
</Select>
</Form.Item>
<Form.Item name="Role" label="Rolle" initialValue="editor">
<Select>
<Select.Option value="viewer">Viewer</Select.Option>
<Select.Option value="editor">Editor</Select.Option>
<Select.Option value="admin">Admin</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</>
);
}
- Step 5: Verify frontend TypeScript compiles
cd paperless-frontend && npm run build 2>&1 | tail -10
Expected: no TypeScript errors.
- Step 6: Commit
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:
Client.AgrarmonitorBetriebId→ Task 2- Setting seed
agrarmonitor_tag_fertig/agrarmonitor_tag_verbucht→ Task 4onModuleInit AgrarmonitorPollingService.runPolling()with cron + manual trigger → Tasks 4 + 5GET/PUT /api/agrarmonitor/polling-config→ Task 5POST /api/agrarmonitor/run-polling→ Task 5- Module wiring → Task 6
GET/PUT /api/settings/clients/:id→ Task 7AGRARMONITOR_POLLING_CRONenv var → Task 8- Frontend polling config UI → Task 10 Part A
- Frontend Betriebe BetriebId column → Task 10 Part B
node-html-parserinstall → Task 1- All HTML-scraping calls (livesearch, setEingangsdatum, setLieferscheinNummer) → Task 3
Notes:
Betrieb(Paperless custom field 6) andGruppeare NOT set on the document during polling because these values are not stored in theCliententity. Onlyowner(fromClient.PaperlessUserId) is updated. This matches the spec's explicit data model — the C# hardcoded switch is replaced by theAgrarmonitorBetriebIdmapping.- Custom field IDs 7 (interneBelegnummer) and 9 (Eingangsdatum) are hardcoded in
AgrarmonitorPollingServiceas constantsINTERN_BELEGNUMMER_FIELD_IDandEINGANGSDATUM_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@Crondecorator falls back to0 */30 * * * *.