Merge pull request 'Agrarmonitor' (#2) from Agrarmonitor into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 11s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-05-25 11:02:58 +00:00
15 changed files with 3203 additions and 7 deletions
+11
View File
@@ -50,3 +50,14 @@ BELEGNUMMER_SET_URL=https://beispiel-api.de/set-number/{Jahr}/{Nummer}
# Leer lassen für lokale Entwicklung (erlaubt alle Origins).
# NODE_ENV=production ohne CORS_ORIGIN blockiert alle Cross-Origin-Anfragen.
CORS_ORIGIN=
# --- Agrarmonitor ---
AGRARMONITOR_BASE_URL=https://admin7.agrarmonitor.de
AGRARMONITOR_API_BASE_URL=https://api.agrarmonitor.de
AGRARMONITOR_USERNAME=
AGRARMONITOR_PASSWORD=
AGRARMONITOR_API_TOKEN=
AGRARMONITOR_COOKIE_PATH=./data/agrarmonitor-cookies.json
AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung
AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren
AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren
+9
View File
@@ -38,6 +38,15 @@ services:
- IMAP_PASSWORD=${IMAP_PASSWORD:-}
- BELEGNUMMER_GET_URL=${BELEGNUMMER_GET_URL:-}
- BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-}
- AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de}
- AGRARMONITOR_API_BASE_URL=${AGRARMONITOR_API_BASE_URL:-https://api.agrarmonitor.de}
- AGRARMONITOR_USERNAME=${AGRARMONITOR_USERNAME:-}
- AGRARMONITOR_PASSWORD=${AGRARMONITOR_PASSWORD:-}
- AGRARMONITOR_API_TOKEN=${AGRARMONITOR_API_TOKEN:-}
- AGRARMONITOR_COOKIE_PATH=${AGRARMONITOR_COOKIE_PATH:-./data/agrarmonitor-cookies.json}
- AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-}
- AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-}
- AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-}
volumes:
- /mnt/scans:/mnt/scans
- /mnt/paperlessmanager:/mnt/data
File diff suppressed because it is too large Load Diff
@@ -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<PollingResult>` — 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<Setting>`, `Repository<Client>`
### `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)
+597 -3
View File
@@ -20,6 +20,7 @@
"@types/form-data": "^2.2.1",
"@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0",
"agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a30",
"axios": "^1.14.0",
"basic-ftp": "^5.2.1",
"chokidar": "^4.0.3",
@@ -215,6 +216,53 @@
"tslib": "^2.1.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -741,6 +789,18 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@buttercup/fetch": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz",
@@ -785,6 +845,140 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -975,6 +1169,23 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3040,7 +3251,7 @@
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
@@ -4640,6 +4851,26 @@
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/agrarmonitor-connector": {
"version": "0.1.0",
"resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a3063144a4f7746c9946d1e4f888f8f0f8b4",
"license": "MIT",
"dependencies": {
"axios": "^1.7.9",
"axios-cookiejar-support": "^5.0.5",
"jsdom": "^29.1.1",
"tough-cookie": "^4.1.4"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -4888,6 +5119,25 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios-cookiejar-support": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-5.0.5.tgz",
"integrity": "sha512-jJG+p7JnOYxkVrYkCDKBrLqUmcpwHZTNQrEcIEKr5qe7YVTyPAD9nCsi1cO5LDmQpQApfS430czO+oceI3g/3g==",
"license": "MIT",
"dependencies": {
"http-cookie-agent": "^6.0.8"
},
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"axios": ">=0.20.0",
"tough-cookie": ">=4.0.0"
}
},
"node_modules/babel-jest": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
@@ -5041,6 +5291,15 @@
"node": ">=10.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -5758,6 +6017,19 @@
"node": "*"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -5767,6 +6039,19 @@
"node": ">= 12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.20",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
@@ -5799,6 +6084,12 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -7331,6 +7622,18 @@
"integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -7385,6 +7688,30 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cookie-agent": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-6.0.8.tgz",
"integrity": "sha512-qnYh3yLSr2jBsTYkw11elq+T361uKAJaZ2dR4cfYZChw1dt9uL5t3zSUwehoqqVb4oldk1BpkXKm2oat8zV+oA==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.3"
},
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"tough-cookie": "^4.0.0 || ^5.0.0",
"undici": "^5.11.0 || ^6.0.0"
},
"peerDependenciesMeta": {
"undici": {
"optional": true
}
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -7648,6 +7975,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -8620,6 +8953,76 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsdom/node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -9162,6 +9565,12 @@
"is-buffer": "~1.1.6"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -9772,6 +10181,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@@ -10227,11 +10660,22 @@
"node": ">=10"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -10497,7 +10941,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10629,6 +11072,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -10968,6 +11423,15 @@
"node": ">= 8"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
@@ -11273,6 +11737,12 @@
"node": ">=0.10"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
@@ -11557,6 +12027,24 @@
"tlds": "bin.js"
}
},
"node_modules/tldts": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.30"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"license": "MIT"
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11618,6 +12106,42 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -12179,6 +12703,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -12358,6 +12893,18 @@
"node": ">= 0.8"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@@ -12450,6 +12997,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/webpack": {
"version": "5.105.4",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
@@ -12651,6 +13207,29 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -12762,6 +13341,21 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+1
View File
@@ -31,6 +31,7 @@
"@types/form-data": "^2.2.1",
"@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0",
"agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git",
"axios": "^1.14.0",
"basic-ftp": "^5.2.1",
"chokidar": "^4.0.3",
@@ -0,0 +1,517 @@
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 { 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;
const EXTERN_BELEGNUMMER_FIELD_ID = 3;
const DOCS_PAGE_SIZE = 500;
const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de';
export interface PollingResult {
processed: number;
updated: number;
skipped: number;
errors: string[];
}
@Injectable()
export class AgrarmonitorPollingService implements OnModuleInit {
private readonly logger = new Logger(AgrarmonitorPollingService.name);
private pollingRunning = false;
private uploadCheckRunning = false;
constructor(
private readonly agrarmonitorService: AgrarmonitorService,
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');
await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
await this.upsertSetting('agrarmonitor_link_field', '');
}
@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));
}
@Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *')
async scheduledUploadCheck() {
if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return;
this.processVerarbeiteteDocuments().catch((err) => this.logger.error('Cron-Upload-Check-Fehler:', err));
}
async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }> {
const [fertig, verbucht, hochgeladen, linkField] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
]);
return {
tagFertig: fertig?.Wert ?? '4',
tagVerbucht: verbucht?.Wert ?? '9',
tagHochgeladen: hochgeladen?.Wert ?? '',
linkField: linkField?.Wert ?? '',
};
}
async updatePollingConfig(
tagFertig: string,
tagVerbucht: string,
tagHochgeladen: string,
linkField: string,
): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }> {
await Promise.all([
this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }),
this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }),
this.settingRepo.update({ Tag: 'agrarmonitor_tag_hochgeladen' }, { Wert: tagHochgeladen }),
this.settingRepo.update({ Tag: 'agrarmonitor_link_field' }, { Wert: linkField }),
]);
return { tagFertig, tagVerbucht, tagHochgeladen, linkField };
}
async runPolling(): Promise<PollingResult> {
if (this.pollingRunning) {
this.logger.warn('Polling läuft bereits, überspringe');
return { processed: 0, updated: 0, skipped: 0, errors: ['Polling bereits aktiv'] };
}
this.pollingRunning = true;
const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] };
this.logger.log('Starte Agrarmonitor-Polling');
try {
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);
if (isNaN(tagFertigId) || isNaN(tagVerbuchtId)) {
const msg = 'Tag-IDs ungültig (keine Zahlen)';
this.logger.error(msg);
return { ...result, errors: [msg] };
}
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
try {
amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) {
const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`;
this.logger.error(msg);
return { ...result, errors: [msg] };
}
let customers: Awaited<ReturnType<typeof amClient.fetchCustomers>>;
try {
customers = await amClient.fetchCustomers();
} catch (err: unknown) {
const msg = `Kunden-Abruf fehlgeschlagen: ${err instanceof Error ? err.message : 'unbekannt'}`;
this.logger.error(msg);
return { ...result, errors: [msg] };
}
for (const customer of customers.filter(
(c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1,
)) {
try {
const lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
const displayName = this.buildCustomerName(customer, lieferantennummer);
const existing = await this.paperlessService.getCorrespondentByName(displayName);
if (!existing) {
await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
} catch (err: unknown) {
this.logger.warn(`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`);
}
}
const docsResponse = await this.paperlessService.getDocuments({
page: 1,
page_size: DOCS_PAGE_SIZE,
truncate_content: true,
tags__id__all: tagFertigId,
});
const docs: any[] = docsResponse?.results ?? [];
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente bereit — nur erste ${DOCS_PAGE_SIZE} werden verarbeitet`);
}
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 amClient.eingangsrechnungenLivesearch>>;
try {
amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Polling abgebrochen, nächster Lauf meldet sich neu an`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (amResults.length === 0) {
this.logger.log(`${interneBelegnummer} nicht in Agrarmonitor gefunden`);
result.skipped++;
await this.delay(500);
continue;
}
if (amResults.length > 1) {
const msg = `${interneBelegnummer}: Mehrfach gefunden`;
this.logger.error(msg);
result.errors.push(msg);
await this.delay(500);
continue;
}
const amDoc = amResults[0];
if (!amDoc.interneBelegNummer && interneBelegnummer) {
try {
await amClient.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer);
} catch (err: unknown) {
this.logger.warn(`${interneBelegnummer}: Lieferscheinnummer setzen fehlgeschlagen: ${err instanceof Error ? err.message : err}`);
}
}
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 amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum);
this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`);
}
}
result.skipped++;
} else if (amDoc.buchungsDatum) {
try {
let correspondentId: number | undefined;
const customer = customers.find((c) => Number(c.id) === amDoc.kundenId);
if (customer) {
const lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
const displayName = this.buildCustomerName(customer, lieferantennummer);
let corr = await this.paperlessService.getCorrespondentByName(displayName);
if (!corr) {
corr = await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
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 = [...new Set(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: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`;
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
result.errors.push(msg);
}
} 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`,
);
} finally {
this.pollingRunning = false;
}
return result;
}
async processVerarbeiteteDocuments(): Promise<PollingResult> {
if (this.uploadCheckRunning) {
this.logger.warn('Upload-Check läuft bereits, überspringe');
return { processed: 0, updated: 0, skipped: 0, errors: ['Upload-Check bereits aktiv'] };
}
this.uploadCheckRunning = true;
const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] };
this.logger.log('Starte Upload-Check');
try {
const [hochgeladenSetting, fertigSetting, linkFieldSetting] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
]);
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10);
if (isNaN(tagHochgeladenId)) {
this.logger.warn('Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen');
return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] };
}
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
try {
amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) {
const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`;
this.logger.error(msg);
return { ...result, errors: [msg] };
}
const docsResponse = await this.paperlessService.getDocuments({
page: 1,
page_size: DOCS_PAGE_SIZE,
truncate_content: true,
tags__id__all: tagHochgeladenId,
});
const docs: any[] = docsResponse?.results ?? [];
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`);
}
this.logger.log(`${docs.length} Dokumente laut Paperless im Dateieingang`);
for (const doc of docs) {
result.processed++;
const interneBelegnummer =
((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
)?.value as string ?? '';
if (!interneBelegnummer) {
this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`);
result.skipped++;
await this.delay(500);
continue;
}
let vorhanden: boolean;
try {
vorhanden = await amClient.eingangsrechnungVorhanden(interneBelegnummer);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`;
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (!vorhanden) {
result.skipped++;
await this.delay(500);
continue;
}
this.logger.log(`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`);
let amResults: Awaited<ReturnType<typeof amClient.eingangsrechnungenLivesearch>>;
try {
amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (amResults.length > 1) {
this.logger.log(`Dokument ${interneBelegnummer} ist doppelt vorhanden`);
result.skipped++;
await this.delay(500);
continue;
}
const amDoc = amResults[0];
try {
// Kundendaten abrufen
const customer = await amClient.getCustomerById(amDoc.kundenId);
const lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
if (!lieferantennummer) {
this.logger.log(`Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`);
result.skipped++;
await this.delay(500);
continue;
}
// Korrespondent ermitteln oder anlegen
const displayName = this.buildCustomerName(customer, lieferantennummer);
let corr = await this.paperlessService.getCorrespondentByName(displayName);
if (!corr) {
corr = await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
// Owner aus Client-Tabelle
let ownerId: number | undefined;
const matchedClient = await this.clientRepo.findOneBy({ AgrarmonitorBetriebId: amDoc.betriebId });
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
// Tags: hochgeladen entfernen, fertig hinzufügen
const currentTags: number[] = (doc.tags as number[]) ?? [];
const newTags = [...new Set(currentTags.filter((t) => t !== tagHochgeladenId).concat([tagFertigId]))];
// Custom fields aufbauen: bestehende behalten, extern + link setzen
const existingFields: any[] = ((doc.custom_fields as any[]) ?? []).map((f: any) => ({ ...f }));
this.setCustomField(existingFields, EXTERN_BELEGNUMMER_FIELD_ID, amDoc.belegNummer);
if (!isNaN(linkFieldId)) {
this.setCustomField(
existingFields,
linkFieldId,
`${AGRARMONITOR_BASE_URL}/rechnungen/detail/${amDoc.eingangId}`,
);
}
const updateData: Record<string, any> = {
title: (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer,
document_type: amDoc.dokumentTyp === 0 ? 1 : 2,
tags: newTags,
custom_fields: existingFields,
};
if (amDoc.belegDatum) updateData.created = amDoc.belegDatum.toISOString().slice(0, 10);
if (corr) updateData.correspondent = corr.id as number;
if (ownerId !== undefined) updateData.owner = ownerId;
await this.paperlessService.updateDocument(doc.id as number, updateData);
await this.paperlessService.addNote(
doc.id as number,
`Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`,
);
this.logger.log(`Beleg ${interneBelegnummer} auf AMfertig gesetzt`);
result.updated++;
} catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`;
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
result.errors.push(msg);
}
await this.delay(500);
}
this.logger.log(
`Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` +
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
);
} finally {
this.uploadCheckRunning = false;
}
return result;
}
private setCustomField(fields: any[], fieldId: number, value: any): void {
const existing = fields.find((f) => f.field === fieldId);
if (existing) {
existing.value = value;
} else {
fields.push({ field: fieldId, value });
}
}
private buildCustomerName(customer: Record<string, unknown>, 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 }),
);
}
}
}
@@ -0,0 +1,52 @@
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; tagHochgeladen: string; linkField: string }) {
return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField);
}
@Post('run-polling')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async runPolling() {
return this.pollingService.runPolling();
}
@Post('process-uploads')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async processUploads() {
return this.pollingService.processVerarbeiteteDocuments();
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AgrarmonitorService } from './agrarmonitor.service';
import { AgrarmonitorPollingService } from './agrarmonitor-polling.service';
import { AgrarmonitorController } from './agrarmonitor.controller';
import { PaperlessModule } from '../paperless/paperless.module';
import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Setting, Client]),
PaperlessModule,
],
providers: [AgrarmonitorService, AgrarmonitorPollingService],
controllers: [AgrarmonitorController],
exports: [AgrarmonitorService],
})
export class AgrarmonitorModule {}
@@ -0,0 +1,92 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createAgrarmonitorClient,
FileCookieStore,
AesGcmCookieEncryptor,
type AgrarmonitorConnectorResult,
} from 'agrarmonitor-connector';
export interface AgrarmonitorStatusDto {
connected: boolean;
registriert: boolean | null;
freigeschaltet: boolean | null;
error?: string;
}
export interface AgrarmonitorRegisterResultDto {
success: boolean;
message: string;
}
@Injectable()
export class AgrarmonitorService {
private readonly logger = new Logger(AgrarmonitorService.name);
private client: AgrarmonitorConnectorResult | null = null;
constructor(private readonly configService: ConfigService) {}
async getClient(): Promise<AgrarmonitorConnectorResult> {
if (this.client) return this.client;
const username = this.configService.get<string>('AGRARMONITOR_USERNAME', '');
const password = this.configService.get<string>('AGRARMONITOR_PASSWORD', '');
const baseUrl = this.configService.get<string>('AGRARMONITOR_BASE_URL', 'https://admin7.agrarmonitor.de');
const apiBaseUrl = this.configService.get<string>('AGRARMONITOR_API_BASE_URL', 'https://api.agrarmonitor.de');
const apiToken = this.configService.get<string>('AGRARMONITOR_API_TOKEN');
const cookiePath = this.configService.get<string>('AGRARMONITOR_COOKIE_PATH', './data/agrarmonitor-cookies.json');
const encryptionKey = this.configService.get<string>('AGRARMONITOR_ENCRYPTION_KEY');
const encryptor = encryptionKey ? new AesGcmCookieEncryptor(encryptionKey) : undefined;
const cookieStore = new FileCookieStore(cookiePath, { encryptor, logger: this.logger });
this.client = await createAgrarmonitorClient({
baseUrl,
apiBaseUrl,
apiToken,
username,
password,
cookieStore,
autoLogin: true,
autoRetry: false,
timeoutMs: 10000,
loginStrategy: 'redirect',
logger: this.logger,
});
return this.client;
}
clearClient(): void {
this.client = null;
}
async getStatus(): Promise<AgrarmonitorStatusDto> {
try {
const client = await this.getClient();
const [registrierungStatus, freigeschaltetStatus] = await Promise.all([
client.checkRegistriert(),
client.checkFreigeschaltet(),
]);
return {
connected: true,
registriert: registrierungStatus.registriert,
freigeschaltet: freigeschaltetStatus.freigeschaltet,
};
} catch (err: any) {
this.client = null;
return {
connected: false,
registriert: null,
freigeschaltet: null,
error: err?.message ?? 'Verbindung fehlgeschlagen',
};
}
}
async registerDevice(pcName: string, agrarmonitorId: string): Promise<AgrarmonitorRegisterResultDto> {
const client = await this.getClient();
const result = await client.registerDevice({ agrarmonitorId, pcName });
return { success: result.success, message: result.message };
}
}
+2
View File
@@ -18,6 +18,7 @@ import { BarcodeModule } from './barcode/barcode.module';
import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postprocessor.module';
import { UserSettingsModule } from './user-settings/user-settings.module';
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
import * as path from 'path';
@Module({
@@ -47,6 +48,7 @@ import * as path from 'path';
InboxPostprocessorModule,
UserSettingsModule,
LabelPrintAgentModule,
AgrarmonitorModule,
],
})
export class AppModule {}
@@ -10,4 +10,7 @@ export class Client {
@Column({ type: 'int' })
PaperlessUserId!: number;
@Column({ type: 'int', nullable: true })
AgrarmonitorBetriebId!: number | null;
}
@@ -385,4 +385,17 @@ export class SettingsController {
return this.corrSettingRepo.save(setting);
}
// === Betriebe ===
@Get('clients')
async getClients() {
return this.clientRepo.find({ order: { Name: 'ASC' } });
}
@Put('clients/:id')
async updateClient(@Param('id') id: string, @Body() body: { AgrarmonitorBetriebId: number | null }) {
const clientId = parseInt(id, 10);
await this.clientRepo.update(clientId, { AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null });
return this.clientRepo.findOneByOrFail({ Id: clientId });
}
}
+50
View File
@@ -78,6 +78,13 @@ export interface SettingUserClient {
Role: 'viewer' | 'editor' | 'admin';
}
export interface SettingClient {
Id: number;
Name: string;
PaperlessUserId: number;
AgrarmonitorBetriebId: number | null;
}
export const settingsApi = {
// Dokumenttypen
getDocTypes: () => api.get<SettingDocType[]>('/api/settings/document-types').then(r => r.data),
@@ -144,6 +151,11 @@ export const settingsApi = {
updateCorrespondentSetting: (id: number, agrarmonitorId: number | null) =>
api.put<any>(`/api/settings/correspondents/${id}`, { agrarmonitorId }).then(r => r.data),
// Betriebe
getClients: () => api.get<SettingClient[]>('/api/settings/clients').then(r => r.data),
updateClient: (id: number, AgrarmonitorBetriebId: number | null) =>
api.put<SettingClient>(`/api/settings/clients/${id}`, { AgrarmonitorBetriebId }).then(r => r.data),
// Inbox-Postprozessor (global, deprecated)
listInboxActions: () =>
api.get<InboxAction[]>('/api/settings/inbox-actions').then((r) => r.data),
@@ -176,3 +188,41 @@ export const INBOX_ACTION_LABELS: Record<InboxActionType, string> = {
EXPORT: 'Export (FTP/WebDAV)',
PAPERLESS: 'In Paperless importieren',
};
export interface AgrarmonitorStatusData {
connected: boolean;
registriert: boolean | null;
freigeschaltet: boolean | null;
error?: string;
}
export interface AgrarmonitorPollingConfig {
tagFertig: string;
tagVerbucht: string;
tagHochgeladen: string;
linkField: string;
}
export interface AgrarmonitorPollingResult {
processed: number;
updated: number;
skipped: number;
errors: string[];
}
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<AgrarmonitorPollingConfig>('/api/agrarmonitor/polling-config').then((r) => r.data),
updatePollingConfig: (config: AgrarmonitorPollingConfig) =>
api.put<AgrarmonitorPollingConfig>('/api/agrarmonitor/polling-config', config).then((r) => r.data),
runPolling: () =>
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/run-polling').then((r) => r.data),
processUploads: () =>
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/process-uploads').then((r) => r.data),
};
+342 -2
View File
@@ -8,7 +8,7 @@ import {
UserOutlined, FileTextOutlined, ThunderboltOutlined,
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined,
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd';
@@ -19,6 +19,8 @@ import {
type SettingPostprocessingLog, type FilterGroup, type FilterCondition,
INBOX_ACTION_LABELS,
type InboxAction, type InboxActionType,
agrarmonitorApi, type AgrarmonitorStatusData,
type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult,
} from '../api/settings';
import { clientsApi, type Client } from '../api/inbox';
import { apiKeysApi, type ApiKey } from '../api/api-keys';
@@ -212,7 +214,9 @@ function FilterBuilder({ value, onChange, tags, docTypes, correspondents, custom
function UserClientsTab() {
const [data, setData] = useState<SettingUserClient[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [allClients, setAllClients] = useState<SettingClient[]>([]);
const [loading, setLoading] = useState(true);
const [clientsLoading, setClientsLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
@@ -228,7 +232,15 @@ function UserClientsTab() {
} finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const loadAllClients = useCallback(async () => {
setClientsLoading(true);
try {
const cls = await settingsApi.getClients();
setAllClients(cls);
} finally { setClientsLoading(false); }
}, []);
useEffect(() => { load(); loadAllClients(); }, [load, loadAllClients]);
const handleAdd = async () => {
const values = await form.validateFields();
@@ -245,6 +257,37 @@ function UserClientsTab() {
load();
};
const handleUpdateBetriebId = async (id: number, val: number | null) => {
try {
const updated = await settingsApi.updateClient(id, val);
setAllClients(prev => prev.map(c => c.Id === id ? updated : c));
} catch {
message.error('Speichern fehlgeschlagen');
}
};
const allClientColumns: ColumnsType<SettingClient> = [
{ title: 'Name', dataIndex: 'Name', key: 'name' },
{
title: 'Agrarmonitor-BetriebId',
dataIndex: 'AgrarmonitorBetriebId',
key: 'betriebId',
render: (val: number | null, record) => (
<InputNumber
value={val ?? undefined}
placeholder=""
min={1}
style={{ width: 120 }}
onBlur={(e) => {
const parsed = e.target.value ? parseInt(e.target.value, 10) : null;
const current = val ?? null;
if (parsed !== current) handleUpdateBetriebId(record.Id, isNaN(parsed as number) ? null : parsed);
}}
/>
),
},
];
const columns: ColumnsType<SettingUserClient> = [
{ title: 'User ID', dataIndex: 'UserId', key: 'userId' },
{
@@ -276,6 +319,21 @@ function UserClientsTab() {
Zuordnung hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Divider />
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe Agrarmonitor-Zuordnung</Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Ordne jedem Betrieb die zugehörige Agrarmonitor-BetriebId zu. Wird beim Polling verwendet.
</Typography.Text>
<Table
dataSource={allClients}
columns={allClientColumns}
loading={clientsLoading}
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 }]}>
@@ -2221,6 +2279,283 @@ function BarcodeTemplatesTab() {
}
// ═══════════════════════════════════════════════════════════════════
// Agrarmonitor Tab
// ═══════════════════════════════════════════════════════════════════
function AgrarmonitorTab() {
const [form] = Form.useForm();
const [pollingForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [registering, setRegistering] = useState(false);
const [pollingConfigLoading, setPollingConfigLoading] = useState(false);
const [pollingSaving, setPollingSaving] = useState(false);
const [pollingRunning, setPollingRunning] = useState(false);
const [uploadCheckRunning, setUploadCheckRunning] = useState(false);
const [status, setStatus] = useState<AgrarmonitorStatusData | null>(null);
const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null);
const [pollingResult, setPollingResult] = useState<AgrarmonitorPollingResult | null>(null);
const [uploadCheckResult, setUploadCheckResult] = useState<AgrarmonitorPollingResult | null>(null);
const [customFields, setCustomFields] = useState<PaperlessCustomField[]>([]);
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 handleLoadPollingConfig = useCallback(async () => {
setPollingConfigLoading(true);
try {
const cfg = await agrarmonitorApi.getPollingConfig();
pollingForm.setFieldsValue(cfg);
} catch {
message.error('Polling-Konfiguration konnte nicht geladen werden');
} finally {
setPollingConfigLoading(false);
}
}, [pollingForm]);
useEffect(() => {
handleLoadPollingConfig();
paperlessApi.getCustomFields().then(setCustomFields).catch(() => {});
}, [handleLoadPollingConfig]);
const handleSavePollingConfig = async () => {
const values = await pollingForm.validateFields() as AgrarmonitorPollingConfig;
setPollingSaving(true);
try {
await agrarmonitorApi.updatePollingConfig(values);
message.success('Konfiguration gespeichert');
} catch {
message.error('Speichern fehlgeschlagen');
} finally {
setPollingSaving(false);
}
};
const handleRunPolling = async () => {
setPollingRunning(true);
setPollingResult(null);
try {
const result = await agrarmonitorApi.runPolling();
setPollingResult(result);
} catch {
message.error('Polling fehlgeschlagen');
} finally {
setPollingRunning(false);
}
};
const handleProcessUploads = async () => {
setUploadCheckRunning(true);
setUploadCheckResult(null);
try {
const result = await agrarmonitorApi.processUploads();
setUploadCheckResult(result);
} catch {
message.error('Upload-Check fehlgeschlagen');
} finally {
setUploadCheckRunning(false);
}
};
const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => {
if (value === null) return <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%' }}>
<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" loading={pollingConfigLoading}>
<Form form={pollingForm} layout="vertical">
<Form.Item
name="tagFertig"
label="Tag-ID: Fertig in Agrarmonitor"
rules={[{ required: true, message: 'Pflichtfeld' }]}
>
<Input placeholder="4" style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="tagVerbucht"
label="Tag-ID: Verbucht"
rules={[{ required: true, message: 'Pflichtfeld' }]}
>
<Input placeholder="9" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="tagHochgeladen" label="Tag-ID: Hochgeladen in Agrarmonitor">
<Input placeholder="3" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="linkField" label="Custom Field: Agrarmonitor-Link">
<Select allowClear placeholder="Kein Feld ausgewählt" style={{ width: 280 }}>
{customFields.map(f => (
<Select.Option key={f.id} value={String(f.id)}>{f.id}: {f.name}</Select.Option>
))}
</Select>
</Form.Item>
<Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}>
Speichern
</Button>
</Form>
</Card>
<Card size="small" title="Polling ausführen">
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={pollingRunning} onClick={handleRunPolling}>
Jetzt ausführen
</Button>
{pollingResult && (
<div>
<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>
)}
{pollingResult.errors.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{pollingResult.errors.map((e, i) => (
<li key={i} style={{ color: '#ff4d4f' }}>{e}</li>
))}
</ul>
)}
</div>
)}
</Space>
</Card>
<Card size="small" title="Dokumenten-Verarbeitung">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig",
sobald sie im Agrarmonitor-Buchungssystem erscheinen.
</Typography.Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={uploadCheckRunning} onClick={handleProcessUploads}>
Jetzt prüfen
</Button>
{uploadCheckResult && (
<div>
<Tag color="blue">{uploadCheckResult.processed} geprüft</Tag>
<Tag color="success">{uploadCheckResult.updated} aktualisiert</Tag>
<Tag>{uploadCheckResult.skipped} übersprungen</Tag>
{uploadCheckResult.errors.length > 0 && (
<Tag color="error">{uploadCheckResult.errors.length} Fehler</Tag>
)}
{uploadCheckResult.errors.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{uploadCheckResult.errors.map((e, i) => (
<li key={i} style={{ color: '#ff4d4f' }}>{e}</li>
))}
</ul>
)}
</div>
)}
</Space>
</Card>
</Space>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Settings Page
// ═══════════════════════════════════════════════════════════════════
@@ -2277,6 +2612,11 @@ export default function SettingsPage() {
label: <span><KeyOutlined /> API-Keys</span>,
children: <ApiKeysTab />,
},
{
key: 'agrarmonitor',
label: <span><GlobalOutlined /> Agrarmonitor</span>,
children: <AgrarmonitorTab />,
},
]}
/>
</Card>