From 1f5dcf4a17242921f7baa8646742f8d2d8e0895a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 21 May 2026 21:30:46 +0200 Subject: [PATCH 01/19] feat: add Agrarmonitor integration module - New backend module (agrarmonitor) with status check and device registration - Frontend settings tab with connection status display and registration form - Environment variables for base URLs, credentials, cookie path and encryption key - Docker Compose env passthrough for agrarmonitor config Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 9 + docker-compose.yml | 7 + paperless-backend/package-lock.json | 600 +++++++++++++++++- paperless-backend/package.json | 1 + .../agrarmonitor/agrarmonitor.controller.ts | 22 + .../src/agrarmonitor/agrarmonitor.module.ts | 10 + .../src/agrarmonitor/agrarmonitor.service.ts | 86 +++ paperless-backend/src/app.module.ts | 2 + paperless-frontend/src/api/settings.ts | 16 + paperless-frontend/src/pages/SettingsPage.tsx | 132 +++- 10 files changed, 881 insertions(+), 4 deletions(-) create mode 100644 paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts create mode 100644 paperless-backend/src/agrarmonitor/agrarmonitor.module.ts create mode 100644 paperless-backend/src/agrarmonitor/agrarmonitor.service.ts diff --git a/.env.example b/.env.example index 54a1a9f..b82ef31 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,12 @@ 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 diff --git a/docker-compose.yml b/docker-compose.yml index 330718e..4cebfd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,13 @@ 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:-} volumes: - /mnt/scans:/mnt/scans - /mnt/paperlessmanager:/mnt/data diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index bf5ef09..693adea 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -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", "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#921c67503b68e46d504ac1c72fb1372cda633006", + "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", diff --git a/paperless-backend/package.json b/paperless-backend/package.json index cb5e5da..5f4d168 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -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", diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts new file mode 100644 index 0000000..085f4df --- /dev/null +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; + +@Controller('api/agrarmonitor') +export class AgrarmonitorController { + constructor(private readonly service: AgrarmonitorService) {} + + @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); + } +} diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts new file mode 100644 index 0000000..ec9a75e --- /dev/null +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { AgrarmonitorController } from './agrarmonitor.controller'; + +@Module({ + providers: [AgrarmonitorService], + controllers: [AgrarmonitorController], + exports: [AgrarmonitorService], +}) +export class AgrarmonitorModule {} diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts new file mode 100644 index 0000000..0f9c5e0 --- /dev/null +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts @@ -0,0 +1,86 @@ +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 { + if (this.client) return this.client; + + const username = this.configService.get('AGRARMONITOR_USERNAME', ''); + const password = this.configService.get('AGRARMONITOR_PASSWORD', ''); + const baseUrl = this.configService.get('AGRARMONITOR_BASE_URL', 'https://admin7.agrarmonitor.de'); + const apiBaseUrl = this.configService.get('AGRARMONITOR_API_BASE_URL', 'https://api.agrarmonitor.de'); + const apiToken = this.configService.get('AGRARMONITOR_API_TOKEN'); + const cookiePath = this.configService.get('AGRARMONITOR_COOKIE_PATH', './data/agrarmonitor-cookies.json'); + const encryptionKey = this.configService.get('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: true, + logger: this.logger, + }); + + return this.client; + } + + async getStatus(): Promise { + 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 { + const client = await this.getClient(); + const result = await client.registerDevice({ agrarmonitorId, pcName }); + return { success: result.success, message: result.message }; + } +} diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index 00c5afc..f557e8a 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -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 {} diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index e4b399d..7b19b6d 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -176,3 +176,19 @@ export const INBOX_ACTION_LABELS: Record = { EXPORT: 'Export (FTP/WebDAV)', PAPERLESS: 'In Paperless importieren', }; + +export interface AgrarmonitorStatusData { + connected: boolean; + registriert: boolean | null; + freigeschaltet: boolean | null; + error?: string; +} + +export const agrarmonitorApi = { + getStatus: () => + api.get('/api/agrarmonitor/status').then((r) => r.data), + registerDevice: (pcName: string, agrarmonitorId: string) => + api + .post<{ success: boolean; message: string }>('/api/agrarmonitor/register', { pcName, agrarmonitorId }) + .then((r) => r.data), +}; diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index eaf93aa..e85b830 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -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,7 @@ import { type SettingPostprocessingLog, type FilterGroup, type FilterCondition, INBOX_ACTION_LABELS, type InboxAction, type InboxActionType, + agrarmonitorApi, type AgrarmonitorStatusData, } from '../api/settings'; import { clientsApi, type Client } from '../api/inbox'; import { apiKeysApi, type ApiKey } from '../api/api-keys'; @@ -2221,6 +2222,130 @@ function BarcodeTemplatesTab() { } +// ═══════════════════════════════════════════════════════════════════ +// Agrarmonitor Tab +// ═══════════════════════════════════════════════════════════════════ + +function AgrarmonitorTab() { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [registering, setRegistering] = useState(false); + const [status, setStatus] = useState(null); + const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); + + const handleLoadStatus = async () => { + setLoading(true); + setRegisterResult(null); + try { + const data = await agrarmonitorApi.getStatus(); + setStatus(data); + } catch { + setStatus({ connected: false, registriert: null, freigeschaltet: null, error: 'Netzwerkfehler' }); + } 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 renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => { + if (value === null) return ; + return value + ? {labelTrue} + : {labelFalse}; + }; + + return ( +
+ Agrarmonitor + + Verbindungsstatus und Geräte-Registrierung für die Agrarmonitor-Schnittstelle. + Zugangsdaten werden in der .env konfiguriert. + + + +
+ +
+ + {status && ( + + +
+ Verbindung: + {status.connected + ? Verbunden + : Nicht verbunden} +
+
+ Registriert: + {renderStatusTag(status.registriert, 'Ja', 'Nein')} +
+
+ Freigeschaltet: + {renderStatusTag(status.freigeschaltet, 'Ja', 'Nein')} +
+ {status.error && ( +
{status.error}
+ )} +
+
+ )} + + {status?.registriert === false && ( + +
+ + + + + + + +
+ {registerResult && ( +
+ + {registerResult.message} + +
+ )} +
+ )} +
+
+ ); +} + + // ═══════════════════════════════════════════════════════════════════ // Settings Page // ═══════════════════════════════════════════════════════════════════ @@ -2277,6 +2402,11 @@ export default function SettingsPage() { label: API-Keys, children: , }, + { + key: 'agrarmonitor', + label: Agrarmonitor, + children: , + }, ]} /> From 9f395784714d7bf639ad67638021ff3da4aea1d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 21 May 2026 22:10:36 +0200 Subject: [PATCH 02/19] fix: disable auto-retry and improve error messages in Agrarmonitor - Set autoRetry: false and timeoutMs: 10000 in AgrarmonitorService - Show specific error message on timeout or backend error in frontend Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/src/agrarmonitor/agrarmonitor.service.ts | 3 ++- paperless-frontend/src/pages/SettingsPage.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts index 0f9c5e0..bff9a17 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts @@ -48,7 +48,8 @@ export class AgrarmonitorService { password, cookieStore, autoLogin: true, - autoRetry: true, + autoRetry: false, + timeoutMs: 10000, logger: this.logger, }); diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index e85b830..b779018 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -2239,8 +2239,11 @@ function AgrarmonitorTab() { try { const data = await agrarmonitorApi.getStatus(); setStatus(data); - } catch { - setStatus({ connected: false, registriert: null, freigeschaltet: null, error: 'Netzwerkfehler' }); + } 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); } From 0d4302dc7eaeb76d607617b3ca2758f3a5b606ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:35:47 +0200 Subject: [PATCH 03/19] chore: add node-html-parser for Agrarmonitor HTML scraping --- paperless-backend/package-lock.json | 57 +++++++++++++++++++++++++++++ paperless-backend/package.json | 1 + 2 files changed, 58 insertions(+) diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 693adea..0ac7259 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -30,6 +30,7 @@ "jwks-rsa": "^4.0.1", "mailparser": "^3.9.8", "mysql2": "^3.20.0", + "node-html-parser": "^7.1.0", "nodemailer": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -5336,6 +5337,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -6017,6 +6024,22 @@ "node": "*" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -6030,6 +6053,18 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "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", @@ -9941,6 +9976,16 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9987,6 +10032,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/paperless-backend/package.json b/paperless-backend/package.json index 5f4d168..e8a1d3e 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -41,6 +41,7 @@ "jwks-rsa": "^4.0.1", "mailparser": "^3.9.8", "mysql2": "^3.20.0", + "node-html-parser": "^7.1.0", "nodemailer": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", From f4131ebcf0bd46faf03734fb7d945edac25eaf86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:36:23 +0200 Subject: [PATCH 04/19] feat: add AgrarmonitorBetriebId to Client entity --- paperless-backend/src/database/entities/client.entity.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/paperless-backend/src/database/entities/client.entity.ts b/paperless-backend/src/database/entities/client.entity.ts index b9d736f..1d9fbff 100644 --- a/paperless-backend/src/database/entities/client.entity.ts +++ b/paperless-backend/src/database/entities/client.entity.ts @@ -10,4 +10,7 @@ export class Client { @Column({ type: 'int' }) PaperlessUserId!: number; + + @Column({ type: 'int', nullable: true }) + AgrarmonitorBetriebId!: number | null; } From 433b3be7fa800f50d4d62c92891e28485e016c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:37:52 +0200 Subject: [PATCH 05/19] feat: add AgrarmonitorWebService with livesearch and date setters --- .../agrarmonitor/agrarmonitor-web.service.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts new file mode 100644 index 0000000..02daa4b --- /dev/null +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts @@ -0,0 +1,199 @@ +// paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { parse } from 'node-html-parser'; +import { AgrarmonitorService } from './agrarmonitor.service'; + +export interface EingangsrechnungEntry { + eingangId: number; + belegNummer: string; + interneBelegNummer: string; + kundenId: number; + betriebId: number; + buchungsDatum: Date | null; + eingangsDatum: Date | null; +} + +@Injectable() +export class AgrarmonitorWebService { + private readonly logger = new Logger(AgrarmonitorWebService.name); + private readonly baseUrl: string; + + constructor( + private readonly agrarmonitorService: AgrarmonitorService, + private readonly configService: ConfigService, + ) { + this.baseUrl = this.configService.get( + 'AGRARMONITOR_BASE_URL', + 'https://admin7.agrarmonitor.de', + ); + } + + async eingangsrechnungenLivesearch(suchstring: string): Promise { + const client = await this.agrarmonitorService.getClient(); + + await client.http.get('/'); + const searchUrl = + `/module/dateien/livesearch.php?suchstring=${encodeURIComponent(suchstring)}` + + `&stammdatum_typ=-1&mobil=-1&sensibel=-1&firma=0&itemsperpage=100000&seite=1`; + const { data: html } = await client.http.get(searchUrl, { responseType: 'text' }); + + const root = parse('' + html + ''); + const table = root.querySelector('table#dateien'); + if (!table) return []; + + const rows = table.querySelectorAll('tbody tr'); + const results: EingangsrechnungEntry[] = []; + + for (const row of rows) { + const tds = row.querySelectorAll('td'); + if (tds.length < 4) continue; + if (!tds[3].text.trim().startsWith('Eingangsrechnungen')) continue; + + const linkEl = tds[3].querySelector('a'); + if (!linkEl) continue; + + const href = linkEl.getAttribute('href') ?? ''; + const eingangId = parseInt(href.split('/').pop() ?? '0', 10); + if (!eingangId) continue; + + const belegText = linkEl.text; + const belegParts = belegText.split(','); + const belegNummer = belegParts[0]?.trim() ?? ''; + + const { data: editHtml } = await client.http.get( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, + { responseType: 'text' }, + ); + const editRoot = parse(editHtml); + + const interneBelegNummer = + editRoot.querySelector('input[name="lieferscheinnummer"]')?.getAttribute('value') ?? ''; + const kundenId = parseInt( + editRoot + .querySelector('select[name="rgempf"] option[selected]') + ?.getAttribute('value') ?? '0', + 10, + ); + const betriebId = parseInt( + editRoot + .querySelector('select[name="firma_id"] option[selected]') + ?.getAttribute('value') ?? '0', + 10, + ); + + const { data: detailHtml } = await client.http.get( + `/eingangsrechnungen/detail/${eingangId}`, + { responseType: 'text' }, + ); + const detailRoot = parse(detailHtml); + const receivedEl = detailRoot.getElementById('receivedStatus'); + + let eingangsDatum: Date | null = null; + let buchungsDatum: Date | null = null; + + if (receivedEl) { + const eingangsText = receivedEl.text.trim(); + if (eingangsText !== 'Nicht empfangen' && eingangsText.length > 13) { + eingangsDatum = this.parseGermanDate(eingangsText.substring(13)); + } + + const parentText = receivedEl.parentNode?.text ?? ''; + const dashIdx = parentText.lastIndexOf('-'); + const buchenText = dashIdx >= 0 ? parentText.substring(dashIdx + 1).trim() : ''; + if (buchenText !== 'Nicht gebucht' && buchenText.length > 11) { + buchungsDatum = this.parseGermanDate(buchenText.substring(11)); + } + } + + results.push({ eingangId, belegNummer, interneBelegNummer, kundenId, betriebId, buchungsDatum, eingangsDatum }); + } + + return results; + } + + async setEingangsdatum(eingangId: number, _belegNummer: string, datum: Date): Promise { + const client = await this.agrarmonitorService.getClient(); + const params = new URLSearchParams(); + params.append('datum', this.formatGermanDate(datum)); + params.append('receiptID', String(eingangId)); + try { + const res = await client.http.post( + '/module/eingangsrechnungen/api/updateReceived.php', + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, + Origin: this.baseUrl, + }, + }, + ); + return res.status < 400; + } catch (err: any) { + this.logger.error(`setEingangsdatum(${eingangId}) Fehler: ${err?.message}`); + return false; + } + } + + async setLieferscheinNummer(eingangId: number, lieferscheinNummer: string): Promise { + const client = await this.agrarmonitorService.getClient(); + const { data: editHtml } = await client.http.get( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, + { responseType: 'text' }, + ); + const editRoot = parse(editHtml); + + const rechnungsnummer = + editRoot.querySelector('input[name="rechnungsnummer"]')?.getAttribute('value') ?? ''; + const rechnungsdatum = + editRoot.querySelector('input[name="rechnungsdatum"]')?.getAttribute('value') ?? ''; + const rgempf = + editRoot + .querySelector('select[name="rgempf"] option[selected]') + ?.getAttribute('value') ?? ''; + const addressName = + editRoot.querySelector('input[name="addressName"]')?.getAttribute('value') ?? ''; + + const params = new URLSearchParams(); + params.append('lieferscheinnummer', lieferscheinNummer); + params.append('rechnungsnummer', rechnungsnummer); + params.append('rechnungsdatum', rechnungsdatum); + params.append('rgempf', rgempf); + params.append('adresstext', addressName); + + try { + const res = await client.http.post( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=update&rechnungId=${eingangId}`, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, + Origin: this.baseUrl, + }, + }, + ); + return res.status < 400; + } catch (err: any) { + this.logger.error(`setLieferscheinNummer(${eingangId}) Fehler: ${err?.message}`); + return false; + } + } + + private parseGermanDate(str: string): Date | null { + const parts = str.trim().split('.'); + if (parts.length !== 3) return null; + const [dd, mm, yy] = parts; + const year = parseInt(yy, 10) < 50 ? 2000 + parseInt(yy, 10) : 1900 + parseInt(yy, 10); + const d = new Date(year, parseInt(mm, 10) - 1, parseInt(dd, 10)); + return isNaN(d.getTime()) ? null : d; + } + + private formatGermanDate(date: Date): string { + const dd = String(date.getDate()).padStart(2, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const yy = String(date.getFullYear()).slice(-2); + return `${dd}.${mm}.${yy}`; + } +} From 79874bf54fb137be00f8a4b2e239c74c51e5c97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:41:01 +0200 Subject: [PATCH 06/19] fix: harden AgrarmonitorWebService error handling and date parsing Co-Authored-By: Claude Sonnet 4.6 --- .../agrarmonitor/agrarmonitor-web.service.ts | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts index 02daa4b..da50743 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts @@ -32,7 +32,11 @@ export class AgrarmonitorWebService { async eingangsrechnungenLivesearch(suchstring: string): Promise { const client = await this.agrarmonitorService.getClient(); - await client.http.get('/'); + try { + await client.http.get('/'); + } catch (err: any) { + this.logger.warn(`Session-Refresh fehlgeschlagen: ${err?.message}`); + } const searchUrl = `/module/dateien/livesearch.php?suchstring=${encodeURIComponent(suchstring)}` + `&stammdatum_typ=-1&mobil=-1&sensibel=-1&firma=0&itemsperpage=100000&seite=1`; @@ -94,16 +98,12 @@ export class AgrarmonitorWebService { if (receivedEl) { const eingangsText = receivedEl.text.trim(); - if (eingangsText !== 'Nicht empfangen' && eingangsText.length > 13) { - eingangsDatum = this.parseGermanDate(eingangsText.substring(13)); - } + const eingangsMatch = eingangsText.match(/Empfangen am (\d{2}\.\d{2}\.\d{2,4})/); + if (eingangsMatch) eingangsDatum = this.parseGermanDate(eingangsMatch[1]); 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)); - } + const buchenMatch = parentText.match(/Gebucht am (\d{2}\.\d{2}\.\d{2,4})/); + if (buchenMatch) buchungsDatum = this.parseGermanDate(buchenMatch[1]); } results.push({ eingangId, belegNummer, interneBelegNummer, kundenId, betriebId, buchungsDatum, eingangsDatum }); @@ -112,7 +112,7 @@ export class AgrarmonitorWebService { return results; } - async setEingangsdatum(eingangId: number, _belegNummer: string, datum: Date): Promise { + async setEingangsdatum(eingangId: number, datum: Date): Promise { const client = await this.agrarmonitorService.getClient(); const params = new URLSearchParams(); params.append('datum', this.formatGermanDate(datum)); @@ -138,31 +138,36 @@ export class AgrarmonitorWebService { async setLieferscheinNummer(eingangId: number, lieferscheinNummer: string): Promise { const client = await this.agrarmonitorService.getClient(); - const { data: editHtml } = await client.http.get( - `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, - { responseType: 'text' }, - ); - const editRoot = parse(editHtml); - - const rechnungsnummer = - editRoot.querySelector('input[name="rechnungsnummer"]')?.getAttribute('value') ?? ''; - const rechnungsdatum = - editRoot.querySelector('input[name="rechnungsdatum"]')?.getAttribute('value') ?? ''; - const rgempf = - editRoot - .querySelector('select[name="rgempf"] option[selected]') - ?.getAttribute('value') ?? ''; - const addressName = - editRoot.querySelector('input[name="addressName"]')?.getAttribute('value') ?? ''; - - const params = new URLSearchParams(); - params.append('lieferscheinnummer', lieferscheinNummer); - params.append('rechnungsnummer', rechnungsnummer); - params.append('rechnungsdatum', rechnungsdatum); - params.append('rgempf', rgempf); - params.append('adresstext', addressName); - try { + const { data: editHtml } = await client.http.get( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, + { responseType: 'text' }, + ); + const editRoot = parse(editHtml); + + const rechnungsnummer = + editRoot.querySelector('input[name="rechnungsnummer"]')?.getAttribute('value') ?? ''; + const rechnungsdatum = + editRoot.querySelector('input[name="rechnungsdatum"]')?.getAttribute('value') ?? ''; + const rgempf = + editRoot + .querySelector('select[name="rgempf"] option[selected]') + ?.getAttribute('value') ?? ''; + const addressName = + editRoot.querySelector('input[name="addressName"]')?.getAttribute('value') ?? ''; + + if (!rgempf) { + this.logger.warn(`setLieferscheinNummer(${eingangId}): kein Empfänger im Formular, übersprungen`); + return false; + } + + const params = new URLSearchParams(); + params.append('lieferscheinnummer', lieferscheinNummer); + params.append('rechnungsnummer', rechnungsnummer); + params.append('rechnungsdatum', rechnungsdatum); + params.append('rgempf', rgempf); + params.append('adresstext', addressName); + const res = await client.http.post( `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=update&rechnungId=${eingangId}`, params.toString(), @@ -185,7 +190,10 @@ export class AgrarmonitorWebService { 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 yearRaw = parseInt(yy, 10); + const year = yy.length === 2 + ? (yearRaw < 50 ? 2000 + yearRaw : 1900 + yearRaw) + : yearRaw; const d = new Date(year, parseInt(mm, 10) - 1, parseInt(dd, 10)); return isNaN(d.getTime()) ? null : d; } From dd0fcfc2e51f2bef0a9fe1044de196144ad78555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:45:47 +0200 Subject: [PATCH 07/19] feat: add AgrarmonitorPollingService with cron and runPolling --- .../agrarmonitor-polling.service.ts | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts new file mode 100644 index 0000000..6346718 --- /dev/null +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -0,0 +1,252 @@ +// paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { AgrarmonitorWebService } from './agrarmonitor-web.service'; +import { PaperlessService } from '../paperless/paperless.service'; +import { Setting } from '../database/entities/setting.entity'; +import { Client } from '../database/entities/client.entity'; + +const INTERN_BELEGNUMMER_FIELD_ID = 7; +const EINGANGSDATUM_FIELD_ID = 9; + +export interface PollingResult { + processed: number; + updated: number; + skipped: number; + errors: string[]; +} + +@Injectable() +export class AgrarmonitorPollingService implements OnModuleInit { + private readonly logger = new Logger(AgrarmonitorPollingService.name); + + constructor( + private readonly agrarmonitorService: AgrarmonitorService, + private readonly webService: AgrarmonitorWebService, + private readonly paperlessService: PaperlessService, + @InjectRepository(Setting) private readonly settingRepo: Repository, + @InjectRepository(Client) private readonly clientRepo: Repository, + ) {} + + async onModuleInit() { + await this.upsertSetting('agrarmonitor_tag_fertig', '4'); + await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); + } + + @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') + async scheduledPolling() { + if (!process.env['AGRARMONITOR_POLLING_CRON']) return; + this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); + } + + async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string }> { + const [fertig, verbucht] = await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + ]); + return { + tagFertig: fertig?.Wert ?? '4', + tagVerbucht: verbucht?.Wert ?? '9', + }; + } + + async updatePollingConfig(tagFertig: string, tagVerbucht: string): Promise<{ tagFertig: string; tagVerbucht: string }> { + await this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }); + await this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }); + return { tagFertig, tagVerbucht }; + } + + async runPolling(): Promise { + const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] }; + this.logger.log('Starte Agrarmonitor-Polling'); + + const [tagFertigSetting, tagVerbuchtSetting] = await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + ]); + const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10); + const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10); + + let amClient: Awaited>; + try { + amClient = await this.agrarmonitorService.getClient(); + } catch (err: unknown) { + const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`; + this.logger.error(msg); + return { ...result, errors: [msg] }; + } + + let customers: Awaited>; + 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, + )) { + const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; + const searchName = `(${lieferantennummer})`; + const displayName = this.buildCustomerName(customer, lieferantennummer); + const existing = await this.paperlessService.getCorrespondentByName(searchName); + if (!existing) { + await this.paperlessService.addCorrespondent({ + name: displayName, + match: '', + matching_algorithm: 0, + is_insensitive: true, + owner: null, + }); + } + } + + const docsResponse = await this.paperlessService.getDocuments({ + page: 1, + page_size: 9999, + truncate_content: true, + tags__id__all: tagFertigId, + }); + const docs: any[] = docsResponse?.results ?? []; + this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); + + for (const doc of docs) { + result.processed++; + + const interneBelegnummer = + ((doc.custom_fields as any[]) ?? []).find( + (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, + )?.value as string ?? ''; + + if (!interneBelegnummer) { + this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`); + result.skipped++; + await this.delay(500); + continue; + } + + let amResults: Awaited>; + try { + amResults = await this.webService.eingangsrechnungenLivesearch(interneBelegnummer); + } catch (err: unknown) { + 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) { + 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, eingangsdatum); + this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); + } + } + } 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 searchName = `(${lieferantennummer})`; + const displayName = this.buildCustomerName(customer, lieferantennummer); + let corr = await this.paperlessService.getCorrespondentByName(searchName); + if (!corr) { + corr = await this.paperlessService.addCorrespondent({ + name: displayName, + match: '', + matching_algorithm: 0, + is_insensitive: true, + owner: null, + }); + } + if (corr) correspondentId = corr.id as number; + } + + let ownerId: number | undefined; + const matchedClient = await this.clientRepo.findOneBy({ + AgrarmonitorBetriebId: amDoc.betriebId, + }); + if (matchedClient) ownerId = matchedClient.PaperlessUserId; + + const currentTags: number[] = (doc.tags as number[]) ?? []; + const newTags = currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]); + + const updateData: Record = { tags: newTags }; + if (correspondentId !== undefined) updateData.correspondent = correspondentId; + if (ownerId !== undefined) updateData.owner = ownerId; + + await this.paperlessService.updateDocument(doc.id as number, updateData); + this.logger.log(`Beleg ${interneBelegnummer} gebucht`); + result.updated++; + } catch (err: 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`, + ); + return result; + } + + private buildCustomerName(customer: Record, nummer: string): string { + const firma = (customer['firma'] as string) ?? ''; + const nachname = (customer['nachname'] as string) ?? ''; + const vorname = (customer['vorname'] as string) ?? ''; + const name = firma || (nachname + (vorname ? ', ' + vorname : '')); + return `${name} (${nummer})`; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async upsertSetting(tag: string, defaultValue: string): Promise { + const existing = await this.settingRepo.findOneBy({ Tag: tag }); + if (!existing) { + await this.settingRepo.save( + this.settingRepo.create({ Typ: 1, Wert: defaultValue, Tag: tag }), + ); + } + } +} From f3df38610cb008f21ed5d9306fb7bde27bd6912d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:56:23 +0200 Subject: [PATCH 08/19] refactor: replace AgrarmonitorWebService with connector methods - Delete agrarmonitor-web.service.ts (HTML-scraping no longer needed) - Rewrite AgrarmonitorPollingService to call connector directly (eingangsrechnungenLivesearch, setEingangsdatum, setLieferscheinNummer) - Fix quality issues: concurrency guard, customer-sync try/catch, tag dedup via Set, parseInt NaN guard, page_size overflow warning - Update AgrarmonitorModule to import TypeORM/PaperlessModule - Remove node-html-parser dependency - Update agrarmonitor-connector to latest Gitea commit Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/package-lock.json | 59 +--- paperless-backend/package.json | 1 - .../agrarmonitor-polling.service.ts | 326 ++++++++++-------- .../agrarmonitor/agrarmonitor-web.service.ts | 207 ----------- .../src/agrarmonitor/agrarmonitor.module.ts | 11 +- 5 files changed, 188 insertions(+), 416 deletions(-) delete mode 100644 paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 0ac7259..ee01b34 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -30,7 +30,6 @@ "jwks-rsa": "^4.0.1", "mailparser": "^3.9.8", "mysql2": "^3.20.0", - "node-html-parser": "^7.1.0", "nodemailer": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -4863,7 +4862,7 @@ }, "node_modules/agrarmonitor-connector": { "version": "0.1.0", - "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#921c67503b68e46d504ac1c72fb1372cda633006", + "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cf6bc1b5cc7e5ffa060c4a37bcea7d9ea6635527", "license": "MIT", "dependencies": { "axios": "^1.7.9", @@ -5337,12 +5336,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -6024,22 +6017,6 @@ "node": "*" } }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -6053,18 +6030,6 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "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", @@ -9976,16 +9941,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-html-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", - "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", - "license": "MIT", - "dependencies": { - "css-select": "^5.1.0", - "he": "1.2.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10032,18 +9987,6 @@ "node": ">=8" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/paperless-backend/package.json b/paperless-backend/package.json index e8a1d3e..5f4d168 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -41,7 +41,6 @@ "jwks-rsa": "^4.0.1", "mailparser": "^3.9.8", "mysql2": "^3.20.0", - "node-html-parser": "^7.1.0", "nodemailer": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 6346718..890f00f 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -1,16 +1,15 @@ -// 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; +const DOCS_PAGE_SIZE = 500; export interface PollingResult { processed: number; @@ -22,10 +21,10 @@ export interface PollingResult { @Injectable() export class AgrarmonitorPollingService implements OnModuleInit { private readonly logger = new Logger(AgrarmonitorPollingService.name); + private pollingRunning = false; constructor( private readonly agrarmonitorService: AgrarmonitorService, - private readonly webService: AgrarmonitorWebService, private readonly paperlessService: PaperlessService, @InjectRepository(Setting) private readonly settingRepo: Repository, @InjectRepository(Client) private readonly clientRepo: Repository, @@ -60,172 +59,201 @@ export class AgrarmonitorPollingService implements OnModuleInit { } async runPolling(): Promise { + 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'); - const [tagFertigSetting, tagVerbuchtSetting] = await Promise.all([ - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), - ]); - const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10); - const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10); - - let amClient: Awaited>; try { - amClient = await this.agrarmonitorService.getClient(); - } catch (err: unknown) { - const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`; - this.logger.error(msg); - return { ...result, errors: [msg] }; - } + 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 customers: Awaited>; - 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, - )) { - const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; - const searchName = `(${lieferantennummer})`; - const displayName = this.buildCustomerName(customer, lieferantennummer); - const existing = await this.paperlessService.getCorrespondentByName(searchName); - if (!existing) { - await this.paperlessService.addCorrespondent({ - name: displayName, - match: '', - matching_algorithm: 0, - is_insensitive: true, - owner: null, - }); - } - } - - const docsResponse = await this.paperlessService.getDocuments({ - page: 1, - page_size: 9999, - truncate_content: true, - tags__id__all: tagFertigId, - }); - const docs: any[] = docsResponse?.results ?? []; - this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); - - for (const doc of docs) { - result.processed++; - - const interneBelegnummer = - ((doc.custom_fields as any[]) ?? []).find( - (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, - )?.value as string ?? ''; - - if (!interneBelegnummer) { - this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`); - result.skipped++; - await this.delay(500); - continue; - } - - let amResults: Awaited>; - try { - amResults = await this.webService.eingangsrechnungenLivesearch(interneBelegnummer); - } catch (err: unknown) { - 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`; + if (isNaN(tagFertigId) || isNaN(tagVerbuchtId)) { + const msg = 'Tag-IDs ungültig (keine Zahlen)'; this.logger.error(msg); - result.errors.push(msg); - await this.delay(500); - continue; + return { ...result, errors: [msg] }; } - const amDoc = amResults[0]; - - if (!amDoc.interneBelegNummer && interneBelegnummer) { - await this.webService.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer); + let amClient: Awaited>; + 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] }; } - 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, eingangsdatum); - this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); - } - } - } else if (amDoc.buchungsDatum) { + let customers: Awaited>; + 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 { - let correspondentId: number | undefined; - const customer = customers.find((c) => 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; + 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, + }); } - - let ownerId: number | undefined; - const matchedClient = await this.clientRepo.findOneBy({ - AgrarmonitorBetriebId: amDoc.betriebId, - }); - if (matchedClient) ownerId = matchedClient.PaperlessUserId; - - const currentTags: number[] = (doc.tags as number[]) ?? []; - const newTags = currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]); - - const updateData: Record = { tags: newTags }; - if (correspondentId !== undefined) updateData.correspondent = correspondentId; - if (ownerId !== undefined) updateData.owner = ownerId; - - await this.paperlessService.updateDocument(doc.id as number, updateData); - this.logger.log(`Beleg ${interneBelegnummer} gebucht`); - result.updated++; } catch (err: unknown) { - const msg = `${interneBelegnummer}: Update-Fehler`; + 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>; + try { + amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer); + } catch (err: unknown) { + const msg = `${interneBelegnummer}: Livesearch-Fehler`; this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); result.errors.push(msg); + await this.delay(500); + continue; } - } else { - result.skipped++; + + 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 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 = [...new Set(currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]))]; + + const updateData: Record = { tags: newTags }; + if (correspondentId !== undefined) updateData.correspondent = correspondentId; + if (ownerId !== undefined) updateData.owner = ownerId; + + await this.paperlessService.updateDocument(doc.id as number, updateData); + this.logger.log(`Beleg ${interneBelegnummer} gebucht`); + result.updated++; + } catch (err: 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); } - 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; } - this.logger.log( - `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` + - `${result.skipped} übersprungen, ${result.errors.length} Fehler`, - ); return result; } diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts deleted file mode 100644 index da50743..0000000 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts +++ /dev/null @@ -1,207 +0,0 @@ -// paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { parse } from 'node-html-parser'; -import { AgrarmonitorService } from './agrarmonitor.service'; - -export interface EingangsrechnungEntry { - eingangId: number; - belegNummer: string; - interneBelegNummer: string; - kundenId: number; - betriebId: number; - buchungsDatum: Date | null; - eingangsDatum: Date | null; -} - -@Injectable() -export class AgrarmonitorWebService { - private readonly logger = new Logger(AgrarmonitorWebService.name); - private readonly baseUrl: string; - - constructor( - private readonly agrarmonitorService: AgrarmonitorService, - private readonly configService: ConfigService, - ) { - this.baseUrl = this.configService.get( - 'AGRARMONITOR_BASE_URL', - 'https://admin7.agrarmonitor.de', - ); - } - - async eingangsrechnungenLivesearch(suchstring: string): Promise { - const client = await this.agrarmonitorService.getClient(); - - try { - await client.http.get('/'); - } catch (err: any) { - this.logger.warn(`Session-Refresh fehlgeschlagen: ${err?.message}`); - } - const searchUrl = - `/module/dateien/livesearch.php?suchstring=${encodeURIComponent(suchstring)}` + - `&stammdatum_typ=-1&mobil=-1&sensibel=-1&firma=0&itemsperpage=100000&seite=1`; - const { data: html } = await client.http.get(searchUrl, { responseType: 'text' }); - - const root = parse('' + html + ''); - const table = root.querySelector('table#dateien'); - if (!table) return []; - - const rows = table.querySelectorAll('tbody tr'); - const results: EingangsrechnungEntry[] = []; - - for (const row of rows) { - const tds = row.querySelectorAll('td'); - if (tds.length < 4) continue; - if (!tds[3].text.trim().startsWith('Eingangsrechnungen')) continue; - - const linkEl = tds[3].querySelector('a'); - if (!linkEl) continue; - - const href = linkEl.getAttribute('href') ?? ''; - const eingangId = parseInt(href.split('/').pop() ?? '0', 10); - if (!eingangId) continue; - - const belegText = linkEl.text; - const belegParts = belegText.split(','); - const belegNummer = belegParts[0]?.trim() ?? ''; - - const { data: editHtml } = await client.http.get( - `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, - { responseType: 'text' }, - ); - const editRoot = parse(editHtml); - - const interneBelegNummer = - editRoot.querySelector('input[name="lieferscheinnummer"]')?.getAttribute('value') ?? ''; - const kundenId = parseInt( - editRoot - .querySelector('select[name="rgempf"] option[selected]') - ?.getAttribute('value') ?? '0', - 10, - ); - const betriebId = parseInt( - editRoot - .querySelector('select[name="firma_id"] option[selected]') - ?.getAttribute('value') ?? '0', - 10, - ); - - const { data: detailHtml } = await client.http.get( - `/eingangsrechnungen/detail/${eingangId}`, - { responseType: 'text' }, - ); - const detailRoot = parse(detailHtml); - const receivedEl = detailRoot.getElementById('receivedStatus'); - - let eingangsDatum: Date | null = null; - let buchungsDatum: Date | null = null; - - if (receivedEl) { - const eingangsText = receivedEl.text.trim(); - const eingangsMatch = eingangsText.match(/Empfangen am (\d{2}\.\d{2}\.\d{2,4})/); - if (eingangsMatch) eingangsDatum = this.parseGermanDate(eingangsMatch[1]); - - const parentText = receivedEl.parentNode?.text ?? ''; - const buchenMatch = parentText.match(/Gebucht am (\d{2}\.\d{2}\.\d{2,4})/); - if (buchenMatch) buchungsDatum = this.parseGermanDate(buchenMatch[1]); - } - - results.push({ eingangId, belegNummer, interneBelegNummer, kundenId, betriebId, buchungsDatum, eingangsDatum }); - } - - return results; - } - - async setEingangsdatum(eingangId: number, datum: Date): Promise { - const client = await this.agrarmonitorService.getClient(); - const params = new URLSearchParams(); - params.append('datum', this.formatGermanDate(datum)); - params.append('receiptID', String(eingangId)); - try { - const res = await client.http.post( - '/module/eingangsrechnungen/api/updateReceived.php', - params.toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, - Origin: this.baseUrl, - }, - }, - ); - return res.status < 400; - } catch (err: any) { - this.logger.error(`setEingangsdatum(${eingangId}) Fehler: ${err?.message}`); - return false; - } - } - - async setLieferscheinNummer(eingangId: number, lieferscheinNummer: string): Promise { - const client = await this.agrarmonitorService.getClient(); - try { - const { data: editHtml } = await client.http.get( - `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, - { responseType: 'text' }, - ); - const editRoot = parse(editHtml); - - const rechnungsnummer = - editRoot.querySelector('input[name="rechnungsnummer"]')?.getAttribute('value') ?? ''; - const rechnungsdatum = - editRoot.querySelector('input[name="rechnungsdatum"]')?.getAttribute('value') ?? ''; - const rgempf = - editRoot - .querySelector('select[name="rgempf"] option[selected]') - ?.getAttribute('value') ?? ''; - const addressName = - editRoot.querySelector('input[name="addressName"]')?.getAttribute('value') ?? ''; - - if (!rgempf) { - this.logger.warn(`setLieferscheinNummer(${eingangId}): kein Empfänger im Formular, übersprungen`); - return false; - } - - const params = new URLSearchParams(); - params.append('lieferscheinnummer', lieferscheinNummer); - params.append('rechnungsnummer', rechnungsnummer); - params.append('rechnungsdatum', rechnungsdatum); - params.append('rgempf', rgempf); - params.append('adresstext', addressName); - - 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 yearRaw = parseInt(yy, 10); - const year = yy.length === 2 - ? (yearRaw < 50 ? 2000 + yearRaw : 1900 + yearRaw) - : yearRaw; - 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}`; - } -} diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts index ec9a75e..f805cfe 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts @@ -1,9 +1,18 @@ 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({ - providers: [AgrarmonitorService], + imports: [ + TypeOrmModule.forFeature([Setting, Client]), + PaperlessModule, + ], + providers: [AgrarmonitorService, AgrarmonitorPollingService], controllers: [AgrarmonitorController], exports: [AgrarmonitorService], }) From f3e3df3724a85a869f3f15e90c15dfce80712d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:58:31 +0200 Subject: [PATCH 09/19] feat: add polling endpoints to AgrarmonitorController GET /api/agrarmonitor/polling-config PUT /api/agrarmonitor/polling-config POST /api/agrarmonitor/run-polling Co-Authored-By: Claude Sonnet 4.6 --- .../agrarmonitor/agrarmonitor.controller.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index 085f4df..76b9aed 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -1,11 +1,15 @@ -import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common'; +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) {} + constructor( + private readonly service: AgrarmonitorService, + private readonly pollingService: AgrarmonitorPollingService, + ) {} @Get('status') @RequirePermissions(Permission.MANAGE_SETTINGS) @@ -19,4 +23,23 @@ export class AgrarmonitorController { 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(); + } } From 31d51dc19d95c6a46e49a3815b37c3994fa905ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:59:26 +0200 Subject: [PATCH 10/19] feat: add GET/PUT clients endpoints to SettingsController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/settings/clients — list all Betriebe ordered by name PUT /api/settings/clients/:id — update AgrarmonitorBetriebId Co-Authored-By: Claude Sonnet 4.6 --- .../src/settings/settings.controller.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/paperless-backend/src/settings/settings.controller.ts b/paperless-backend/src/settings/settings.controller.ts index c2cea5c..a2f7e26 100644 --- a/paperless-backend/src/settings/settings.controller.ts +++ b/paperless-backend/src/settings/settings.controller.ts @@ -376,13 +376,26 @@ export class SettingsController { async updateCorrespondentSetting(@Param('id') id: string, @Body() body: { agrarmonitorId: number | null }) { const corrId = parseInt(id, 10); let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corrId }); - + if (!setting) { setting = this.corrSettingRepo.create({ CorrespondentId: corrId, AgrarmonitorId: body.agrarmonitorId }); } else { setting.AgrarmonitorId = body.agrarmonitorId; } - + 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 }); + } } From bed797db510e278d4b0140b0b0a4c44492e2d00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 14:59:38 +0200 Subject: [PATCH 11/19] chore: add AGRARMONITOR_POLLING_CRON to .env.example Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index b82ef31..254c991 100644 --- a/.env.example +++ b/.env.example @@ -59,3 +59,4 @@ 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 From 5ca202a59ec0332432750d69e6cfeed9ad00963d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 15:02:02 +0200 Subject: [PATCH 12/19] feat: extend frontend API client for Agrarmonitor polling - Add SettingClient interface and getClients/updateClient methods - Add AgrarmonitorPollingConfig/Result interfaces - Add getPollingConfig, updatePollingConfig, runPolling to agrarmonitorApi Co-Authored-By: Claude Sonnet 4.6 --- paperless-frontend/src/api/settings.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index 7b19b6d..8fabd38 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -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('/api/settings/document-types').then(r => r.data), @@ -144,6 +151,11 @@ export const settingsApi = { updateCorrespondentSetting: (id: number, agrarmonitorId: number | null) => api.put(`/api/settings/correspondents/${id}`, { agrarmonitorId }).then(r => r.data), + // Betriebe + getClients: () => api.get('/api/settings/clients').then(r => r.data), + updateClient: (id: number, AgrarmonitorBetriebId: number | null) => + api.put(`/api/settings/clients/${id}`, { AgrarmonitorBetriebId }).then(r => r.data), + // Inbox-Postprozessor (global, deprecated) listInboxActions: () => api.get('/api/settings/inbox-actions').then((r) => r.data), @@ -184,6 +196,18 @@ export interface AgrarmonitorStatusData { error?: string; } +export interface AgrarmonitorPollingConfig { + tagFertig: string; + tagVerbucht: string; +} + +export interface AgrarmonitorPollingResult { + processed: number; + updated: number; + skipped: number; + errors: string[]; +} + export const agrarmonitorApi = { getStatus: () => api.get('/api/agrarmonitor/status').then((r) => r.data), @@ -191,4 +215,10 @@ export const agrarmonitorApi = { api .post<{ success: boolean; message: string }>('/api/agrarmonitor/register', { pcName, agrarmonitorId }) .then((r) => r.data), + getPollingConfig: () => + api.get('/api/agrarmonitor/polling-config').then((r) => r.data), + updatePollingConfig: (config: AgrarmonitorPollingConfig) => + api.put('/api/agrarmonitor/polling-config', config).then((r) => r.data), + runPolling: () => + api.post('/api/agrarmonitor/run-polling').then((r) => r.data), }; From 6e1f995fe59380155a70d30535a81a3aac57afe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 15:04:17 +0200 Subject: [PATCH 13/19] feat: extend SettingsPage with Agrarmonitor polling UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benutzer & Betriebe tab: - Add Betriebe table with inline-editable AgrarmonitorBetriebId column Agrarmonitor tab: - Add Polling-Konfiguration card (tag-IDs, auto-loaded, save button) - Add Polling ausführen card (run button, result display with error list) Co-Authored-By: Claude Sonnet 4.6 --- paperless-frontend/src/pages/SettingsPage.tsx | 151 +++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index b779018..c1081f9 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -20,6 +20,7 @@ import { 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'; @@ -213,7 +214,9 @@ function FilterBuilder({ value, onChange, tags, docTypes, correspondents, custom function UserClientsTab() { const [data, setData] = useState([]); const [clients, setClients] = useState([]); + const [allClients, setAllClients] = useState([]); const [loading, setLoading] = useState(true); + const [clientsLoading, setClientsLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); @@ -229,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(); @@ -246,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 = [ + { title: 'Name', dataIndex: 'Name', key: 'name' }, + { + title: 'Agrarmonitor-BetriebId', + dataIndex: 'AgrarmonitorBetriebId', + key: 'betriebId', + render: (val: number | null, record) => ( + { + 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 = [ { title: 'User ID', dataIndex: 'UserId', key: 'userId' }, { @@ -277,6 +319,21 @@ function UserClientsTab() { Zuordnung hinzufügen + + + Betriebe — Agrarmonitor-Zuordnung + + Ordne jedem Betrieb die zugehörige Agrarmonitor-BetriebId zu. Wird beim Polling verwendet. + +
+ setModalOpen(false)}>
@@ -2228,10 +2285,15 @@ function BarcodeTemplatesTab() { 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 [status, setStatus] = useState(null); const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); + const [pollingResult, setPollingResult] = useState(null); const handleLoadStatus = async () => { setLoading(true); @@ -2267,6 +2329,46 @@ function AgrarmonitorTab() { } }; + 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(); }, [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 renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => { if (value === null) return ; return value @@ -2343,6 +2445,53 @@ function AgrarmonitorTab() { )} )} + + + + + + + + + + + + + + + + + {pollingResult && ( +
+ {pollingResult.processed} verarbeitet + {pollingResult.updated} aktualisiert + {pollingResult.skipped} übersprungen + {pollingResult.errors.length > 0 && ( + {pollingResult.errors.length} Fehler + )} + {pollingResult.errors.length > 0 && ( +
    + {pollingResult.errors.map((e, i) => ( +
  • {e}
  • + ))} +
+ )} +
+ )} +
+
); From e5271fc0354f8b74f4859c817a8baca82f17a888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 15:05:20 +0200 Subject: [PATCH 14/19] chore: add AGRARMONITOR_POLLING_CRON to docker-compose.yml Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 4cebfd2..2a94afe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: - 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:-} volumes: - /mnt/scans:/mnt/scans - /mnt/paperlessmanager:/mnt/data From 1d11d8a3bdf9c46844a9f77f146e2293bffee423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 15:09:53 +0200 Subject: [PATCH 15/19] docs: add Agrarmonitor polling design plans Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-23-agrarmonitor-polling.md | 1358 +++++++++++++++++ .../2026-05-23-agrarmonitor-polling-design.md | 135 ++ 2 files changed, 1493 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md create mode 100644 docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md diff --git a/docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md b/docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md new file mode 100644 index 0000000..98e1248 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-agrarmonitor-polling.md @@ -0,0 +1,1358 @@ +# Agrarmonitor Polling Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the Agrarmonitor module with a polling service that checks Paperless documents against Agrarmonitor invoices and updates both systems when a booking date is found. + +**Architecture:** A new `AgrarmonitorWebService` handles the HTML scraping of admin7.agrarmonitor.de (livesearch, setEingangsdatum, setLieferscheinNummer). A new `AgrarmonitorPollingService` orchestrates the polling loop (cron + manual trigger), reads tag-ID settings from the DB, and updates both Agrarmonitor and Paperless. The `Client` entity gets an `AgrarmonitorBetriebId` column so the BetriebId-to-Owner mapping is configurable instead of hardcoded. + +**Tech Stack:** NestJS, TypeORM (MySQL, synchronize:true), `node-html-parser`, `@nestjs/schedule` Cron, PaperlessService (already exists), React 19 + Ant Design. + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `paperless-backend/package.json` | +| Modify | `paperless-backend/src/database/entities/client.entity.ts` | +| **Create** | `paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts` | +| **Create** | `paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts` | +| Modify | `paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts` | +| Modify | `paperless-backend/src/agrarmonitor/agrarmonitor.module.ts` | +| Modify | `paperless-backend/src/settings/settings.controller.ts` | +| Modify | `.env.example` | +| Modify | `docker-compose.yml` | +| Modify | `paperless-frontend/src/api/settings.ts` | +| Modify | `paperless-frontend/src/api/inbox.ts` | +| Modify | `paperless-frontend/src/pages/SettingsPage.tsx` | + +--- + +### Task 1: Install node-html-parser + +**Files:** +- Modify: `paperless-backend/package.json` + +- [ ] **Step 1: Install the package** + +```bash +cd paperless-backend && npm install node-html-parser +``` + +Expected: `node-html-parser` appears in `dependencies` in `package.json`. + +- [ ] **Step 2: Verify TypeScript types are available** + +```bash +cd paperless-backend && node -e "const { parse } = require('node-html-parser'); console.log(typeof parse)" +``` + +Expected: `function` + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/package.json paperless-backend/package-lock.json +git commit -m "chore: add node-html-parser for Agrarmonitor HTML scraping" +``` + +--- + +### Task 2: Extend Client entity with AgrarmonitorBetriebId + +**Files:** +- Modify: `paperless-backend/src/database/entities/client.entity.ts` + +- [ ] **Step 1: Add the nullable column** + +Replace the full file content with: + +```typescript +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +@Entity('clients') +export class Client { + @PrimaryGeneratedColumn() + Id!: number; + + @Column({ type: 'varchar', length: 45 }) + Name!: string; + + @Column({ type: 'int' }) + PaperlessUserId!: number; + + @Column({ type: 'int', nullable: true }) + AgrarmonitorBetriebId!: number | null; +} +``` + +- [ ] **Step 2: Verify build compiles** + +```bash +cd paperless-backend && npm run build 2>&1 | tail -5 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/src/database/entities/client.entity.ts +git commit -m "feat: add AgrarmonitorBetriebId to Client entity" +``` + +--- + +### Task 3: Create AgrarmonitorWebService (HTML scraping) + +**Files:** +- Create: `paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts` + +This service wraps the three Agrarmonitor web actions: livesearch, setEingangsdatum, setLieferscheinNummer. It calls the session-authenticated `client.http` Axios instance from `AgrarmonitorService`. + +- [ ] **Step 1: Create the file** + +```typescript +// paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { parse } from 'node-html-parser'; +import { AgrarmonitorService } from './agrarmonitor.service'; + +export interface EingangsrechnungEntry { + eingangId: number; + belegNummer: string; + interneBelegNummer: string; + kundenId: number; + betriebId: number; + buchungsDatum: Date | null; + eingangsDatum: Date | null; +} + +@Injectable() +export class AgrarmonitorWebService { + private readonly logger = new Logger(AgrarmonitorWebService.name); + private readonly baseUrl: string; + + constructor( + private readonly agrarmonitorService: AgrarmonitorService, + private readonly configService: ConfigService, + ) { + this.baseUrl = this.configService.get( + 'AGRARMONITOR_BASE_URL', + 'https://admin7.agrarmonitor.de', + ); + } + + async eingangsrechnungenLivesearch(suchstring: string): Promise { + const client = await this.agrarmonitorService.getClient(); + + await client.http.get('/'); + const searchUrl = + `/module/dateien/livesearch.php?suchstring=${encodeURIComponent(suchstring)}` + + `&stammdatum_typ=-1&mobil=-1&sensibel=-1&firma=0&itemsperpage=100000&seite=1`; + const { data: html } = await client.http.get(searchUrl, { responseType: 'text' }); + + const root = parse('' + html + ''); + const table = root.querySelector('table#dateien'); + if (!table) return []; + + const rows = table.querySelectorAll('tbody tr'); + const results: EingangsrechnungEntry[] = []; + + for (const row of rows) { + const tds = row.querySelectorAll('td'); + if (tds.length < 4) continue; + if (!tds[3].text.trim().startsWith('Eingangsrechnungen')) continue; + + const linkEl = tds[3].querySelector('a'); + if (!linkEl) continue; + + const href = linkEl.getAttribute('href') ?? ''; + const eingangId = parseInt(href.split('/').pop() ?? '0', 10); + if (!eingangId) continue; + + const belegText = linkEl.text; + const belegParts = belegText.split(','); + const belegNummer = belegParts[0]?.trim() ?? ''; + + const { data: editHtml } = await client.http.get( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, + { responseType: 'text' }, + ); + const editRoot = parse(editHtml); + + const interneBelegNummer = + editRoot.querySelector('input[name="lieferscheinnummer"]')?.getAttribute('value') ?? ''; + const kundenId = parseInt( + editRoot + .querySelector('select[name="rgempf"] option[selected]') + ?.getAttribute('value') ?? '0', + 10, + ); + const betriebId = parseInt( + editRoot + .querySelector('select[name="firma_id"] option[selected]') + ?.getAttribute('value') ?? '0', + 10, + ); + + const { data: detailHtml } = await client.http.get( + `/eingangsrechnungen/detail/${eingangId}`, + { responseType: 'text' }, + ); + const detailRoot = parse(detailHtml); + const receivedEl = detailRoot.getElementById('receivedStatus'); + + let eingangsDatum: Date | null = null; + let buchungsDatum: Date | null = null; + + if (receivedEl) { + const eingangsText = receivedEl.text.trim(); + if (eingangsText !== 'Nicht empfangen' && eingangsText.length > 13) { + eingangsDatum = this.parseGermanDate(eingangsText.substring(13)); + } + + const parentText = receivedEl.parentNode?.text ?? ''; + const dashIdx = parentText.lastIndexOf('-'); + const buchenText = dashIdx >= 0 ? parentText.substring(dashIdx + 1).trim() : ''; + if (buchenText !== 'Nicht gebucht' && buchenText.length > 11) { + buchungsDatum = this.parseGermanDate(buchenText.substring(11)); + } + } + + results.push({ eingangId, belegNummer, interneBelegNummer, kundenId, betriebId, buchungsDatum, eingangsDatum }); + } + + return results; + } + + async setEingangsdatum(eingangId: number, _belegNummer: string, datum: Date): Promise { + const client = await this.agrarmonitorService.getClient(); + const params = new URLSearchParams(); + params.append('datum', this.formatGermanDate(datum)); + params.append('receiptID', String(eingangId)); + try { + const res = await client.http.post( + '/module/eingangsrechnungen/api/updateReceived.php', + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, + Origin: this.baseUrl, + }, + }, + ); + return res.status < 400; + } catch (err: any) { + this.logger.error(`setEingangsdatum(${eingangId}) Fehler: ${err?.message}`); + return false; + } + } + + async setLieferscheinNummer(eingangId: number, lieferscheinNummer: string): Promise { + const client = await this.agrarmonitorService.getClient(); + const { data: editHtml } = await client.http.get( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=edit&rechnungId=${eingangId}`, + { responseType: 'text' }, + ); + const editRoot = parse(editHtml); + + const rechnungsnummer = + editRoot.querySelector('input[name="rechnungsnummer"]')?.getAttribute('value') ?? ''; + const rechnungsdatum = + editRoot.querySelector('input[name="rechnungsdatum"]')?.getAttribute('value') ?? ''; + const rgempf = + editRoot + .querySelector('select[name="rgempf"] option[selected]') + ?.getAttribute('value') ?? ''; + const addressName = + editRoot.querySelector('input[name="addressName"]')?.getAttribute('value') ?? ''; + + const params = new URLSearchParams(); + params.append('lieferscheinnummer', lieferscheinNummer); + params.append('rechnungsnummer', rechnungsnummer); + params.append('rechnungsdatum', rechnungsdatum); + params.append('rgempf', rgempf); + params.append('adresstext', addressName); + + try { + const res = await client.http.post( + `/module/eingangsrechnungen/api/eingangsrechnungen.php?id=update&rechnungId=${eingangId}`, + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Referer: `${this.baseUrl}/eingangsrechnungen/detail/${eingangId}`, + Origin: this.baseUrl, + }, + }, + ); + return res.status < 400; + } catch (err: any) { + this.logger.error(`setLieferscheinNummer(${eingangId}) Fehler: ${err?.message}`); + return false; + } + } + + private parseGermanDate(str: string): Date | null { + const parts = str.trim().split('.'); + if (parts.length !== 3) return null; + const [dd, mm, yy] = parts; + const year = parseInt(yy, 10) < 50 ? 2000 + parseInt(yy, 10) : 1900 + parseInt(yy, 10); + const d = new Date(year, parseInt(mm, 10) - 1, parseInt(dd, 10)); + return isNaN(d.getTime()) ? null : d; + } + + private formatGermanDate(date: Date): string { + const dd = String(date.getDate()).padStart(2, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const yy = String(date.getFullYear()).slice(-2); + return `${dd}.${mm}.${yy}`; + } +} +``` + +- [ ] **Step 2: Verify build compiles** + +```bash +cd paperless-backend && npm run build 2>&1 | tail -5 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/src/agrarmonitor/agrarmonitor-web.service.ts +git commit -m "feat: add AgrarmonitorWebService with livesearch and date setters" +``` + +--- + +### Task 4: Create AgrarmonitorPollingService + +**Files:** +- Create: `paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts` + +This service: +- Seeds the two tag-ID settings on startup (`agrarmonitor_tag_fertig` default `'4'`, `agrarmonitor_tag_verbucht` default `'9'`) +- Runs on cron when `AGRARMONITOR_POLLING_CRON` env var is set +- Exposes `runPolling()` and `getPollingConfig()`/`updatePollingConfig()` for the controller + +Custom field IDs (hardcoded to match the C# reference, change if your Paperless installation differs): +- `interneBelegnummer` = custom field `7` +- `Eingangsdatum` = custom field `9` + +- [ ] **Step 1: Create the file** + +```typescript +// paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { AgrarmonitorWebService } from './agrarmonitor-web.service'; +import { PaperlessService } from '../paperless/paperless.service'; +import { Setting } from '../database/entities/setting.entity'; +import { Client } from '../database/entities/client.entity'; + +const INTERN_BELEGNUMMER_FIELD_ID = 7; +const EINGANGSDATUM_FIELD_ID = 9; + +export interface PollingResult { + processed: number; + updated: number; + skipped: number; + errors: string[]; +} + +@Injectable() +export class AgrarmonitorPollingService implements OnModuleInit { + private readonly logger = new Logger(AgrarmonitorPollingService.name); + + constructor( + private readonly agrarmonitorService: AgrarmonitorService, + private readonly webService: AgrarmonitorWebService, + private readonly paperlessService: PaperlessService, + @InjectRepository(Setting) private readonly settingRepo: Repository, + @InjectRepository(Client) private readonly clientRepo: Repository, + ) {} + + async onModuleInit() { + await this.upsertSetting('agrarmonitor_tag_fertig', '4'); + await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); + } + + @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') + async scheduledPolling() { + if (!process.env['AGRARMONITOR_POLLING_CRON']) return; + this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); + } + + async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string }> { + const [fertig, verbucht] = await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + ]); + return { + tagFertig: fertig?.Wert ?? '4', + tagVerbucht: verbucht?.Wert ?? '9', + }; + } + + async updatePollingConfig(tagFertig: string, tagVerbucht: string): Promise<{ tagFertig: string; tagVerbucht: string }> { + await this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }); + await this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }); + return { tagFertig, tagVerbucht }; + } + + async runPolling(): Promise { + const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] }; + this.logger.log('Starte Agrarmonitor-Polling'); + + const [tagFertigSetting, tagVerbuchtSetting] = await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + ]); + const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10); + const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10); + + let amClient: Awaited>; + try { + amClient = await this.agrarmonitorService.getClient(); + } catch (err: any) { + const msg = `Connector-Fehler: ${err?.message ?? 'unbekannt'}`; + this.logger.error(msg); + return { ...result, errors: [msg] }; + } + + let customers: any[]; + try { + customers = await amClient.fetchCustomers(); + } catch (err: any) { + const msg = `Kunden-Abruf fehlgeschlagen: ${err?.message ?? 'unbekannt'}`; + this.logger.error(msg); + return { ...result, errors: [msg] }; + } + + for (const customer of customers.filter( + (c: any) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1, + )) { + const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; + const searchName = `(${lieferantennummer})`; + const displayName = this.buildCustomerName(customer, lieferantennummer); + const existing = await this.paperlessService.getCorrespondentByName(searchName); + if (!existing) { + await this.paperlessService.addCorrespondent({ + name: displayName, + match: '', + matching_algorithm: 0, + is_insensitive: true, + owner: null, + }); + } + } + + const docsResponse = await this.paperlessService.getDocuments({ + page: 1, + page_size: 9999, + truncate_content: true, + tags__id__all: tagFertigId, + }); + const docs: any[] = docsResponse?.results ?? []; + this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); + + for (const doc of docs) { + result.processed++; + + const interneBelegnummer = ((doc.custom_fields as any[]) ?? []).find( + (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, + )?.value as string ?? ''; + + if (!interneBelegnummer) { + this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`); + result.skipped++; + await this.delay(500); + continue; + } + + let amResults: Awaited>; + try { + amResults = await this.webService.eingangsrechnungenLivesearch(interneBelegnummer); + } catch (err: any) { + this.logger.error(`Livesearch ${interneBelegnummer}: ${err?.message}`); + result.errors.push(`${interneBelegnummer}: Livesearch-Fehler`); + await this.delay(500); + continue; + } + + if (amResults.length === 0) { + this.logger.log(`${interneBelegnummer} nicht in Agrarmonitor gefunden`); + result.skipped++; + await this.delay(500); + continue; + } + + if (amResults.length > 1) { + this.logger.error(`${interneBelegnummer} mehrfach in Agrarmonitor gefunden`); + result.errors.push(`${interneBelegnummer}: Mehrfach gefunden`); + await this.delay(500); + continue; + } + + const amDoc = amResults[0]; + + if (!amDoc.interneBelegNummer && interneBelegnummer) { + await this.webService.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer); + } + + if (!amDoc.eingangsDatum) { + const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find( + (cf: any) => cf.field === EINGANGSDATUM_FIELD_ID, + ); + if (eingangsdatumField?.value) { + const eingangsdatum = new Date(eingangsdatumField.value as string); + if (!isNaN(eingangsdatum.getTime())) { + await this.webService.setEingangsdatum(amDoc.eingangId, amDoc.belegNummer, eingangsdatum); + this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); + } + } + } else if (amDoc.buchungsDatum) { + try { + let correspondentId: number | undefined; + const customer = customers.find((c: any) => Number(c.id) === amDoc.kundenId); + if (customer) { + const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; + const searchName = `(${lieferantennummer})`; + const displayName = this.buildCustomerName(customer, lieferantennummer); + let corr = await this.paperlessService.getCorrespondentByName(searchName); + if (!corr) { + corr = await this.paperlessService.addCorrespondent({ + name: displayName, + match: '', + matching_algorithm: 0, + is_insensitive: true, + owner: null, + }); + } + if (corr) correspondentId = corr.id as number; + } + + let ownerId: number | undefined; + const matchedClient = await this.clientRepo.findOneBy({ + AgrarmonitorBetriebId: amDoc.betriebId, + }); + if (matchedClient) ownerId = matchedClient.PaperlessUserId; + + const currentTags: number[] = (doc.tags as number[]) ?? []; + const newTags = currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]); + + const updateData: Record = { tags: newTags }; + if (correspondentId !== undefined) updateData.correspondent = correspondentId; + if (ownerId !== undefined) updateData.owner = ownerId; + + await this.paperlessService.updateDocument(doc.id as number, updateData); + this.logger.log(`Beleg ${interneBelegnummer} gebucht`); + result.updated++; + } catch (err: any) { + this.logger.error(`Update ${interneBelegnummer}: ${err?.message}`); + result.errors.push(`${interneBelegnummer}: Update-Fehler`); + } + } else { + result.skipped++; + } + + await this.delay(500); + } + + this.logger.log( + `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ${result.skipped} übersprungen, ${result.errors.length} Fehler`, + ); + return result; + } + + private buildCustomerName(customer: any, nummer: string): string { + const firma = (customer.firma as string) ?? ''; + const nachname = (customer['nachname'] as string) ?? ''; + const vorname = (customer['vorname'] as string) ?? ''; + const name = firma || (nachname + (vorname ? ', ' + vorname : '')); + return `${name} (${nummer})`; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async upsertSetting(tag: string, defaultValue: string): Promise { + const existing = await this.settingRepo.findOneBy({ Tag: tag }); + if (!existing) { + await this.settingRepo.save( + this.settingRepo.create({ Typ: 1, Wert: defaultValue, Tag: tag }), + ); + } + } +} +``` + +- [ ] **Step 2: Verify build compiles** + +```bash +cd paperless-backend && npm run build 2>&1 | tail -5 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +git commit -m "feat: add AgrarmonitorPollingService with cron and runPolling" +``` + +--- + +### Task 5: Extend AgrarmonitorController with 3 new endpoints + +**Files:** +- Modify: `paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts` + +Replace the entire file with: + +- [ ] **Step 1: Update the controller** + +```typescript +// paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +import { Body, Controller, Get, HttpCode, Post, Put } from '@nestjs/common'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; + +@Controller('api/agrarmonitor') +export class AgrarmonitorController { + constructor( + private readonly service: AgrarmonitorService, + private readonly pollingService: AgrarmonitorPollingService, + ) {} + + @Get('status') + @RequirePermissions(Permission.MANAGE_SETTINGS) + async getStatus() { + return this.service.getStatus(); + } + + @Post('register') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async registerDevice(@Body() body: { pcName: string; agrarmonitorId: string }) { + return this.service.registerDevice(body.pcName, body.agrarmonitorId); + } + + @Get('polling-config') + @RequirePermissions(Permission.MANAGE_SETTINGS) + async getPollingConfig() { + return this.pollingService.getPollingConfig(); + } + + @Put('polling-config') + @RequirePermissions(Permission.MANAGE_SETTINGS) + async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string }) { + return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht); + } + + @Post('run-polling') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async runPolling() { + return this.pollingService.runPolling(); + } +} +``` + +- [ ] **Step 2: Verify build compiles** + +```bash +cd paperless-backend && npm run build 2>&1 | tail -5 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +git commit -m "feat: add polling-config and run-polling endpoints to AgrarmonitorController" +``` + +--- + +### Task 6: Update AgrarmonitorModule + +**Files:** +- Modify: `paperless-backend/src/agrarmonitor/agrarmonitor.module.ts` + +- [ ] **Step 1: Replace the module file** + +```typescript +// paperless-backend/src/agrarmonitor/agrarmonitor.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { AgrarmonitorWebService } from './agrarmonitor-web.service'; +import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; +import { AgrarmonitorController } from './agrarmonitor.controller'; +import { Setting } from '../database/entities/setting.entity'; +import { Client } from '../database/entities/client.entity'; +import { PaperlessModule } from '../paperless/paperless.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Setting, Client]), + PaperlessModule, + ], + providers: [AgrarmonitorService, AgrarmonitorWebService, AgrarmonitorPollingService], + controllers: [AgrarmonitorController], + exports: [AgrarmonitorService], +}) +export class AgrarmonitorModule {} +``` + +- [ ] **Step 2: Verify build compiles** + +```bash +cd paperless-backend && npm run build 2>&1 | tail -5 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/src/agrarmonitor/agrarmonitor.module.ts +git commit -m "feat: wire AgrarmonitorModule with TypeOrm, PaperlessModule, and new services" +``` + +--- + +### Task 7: Add GET/PUT clients endpoints to SettingsController + +**Files:** +- Modify: `paperless-backend/src/settings/settings.controller.ts` + +Add two methods at the end of the `// === Benutzer-Betrieb Zuordnung ===` section (around line 295), before the `// === Allgemeine Einstellungen ===` section. + +- [ ] **Step 1: Add the two client endpoints** + +In `paperless-backend/src/settings/settings.controller.ts`, find the line: + +```typescript + // === Allgemeine Einstellungen === +``` + +Add the following two methods immediately before it: + +```typescript + // === Betriebe (Clients) === + @Get('clients') + async getAllClients() { + return this.clientRepo.find({ order: { Id: 'ASC' } }); + } + + @Put('clients/:id') + async updateClient(@Param('id') id: string, @Body() body: { AgrarmonitorBetriebId: number | null }) { + await this.clientRepo.update(parseInt(id, 10), { + AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null, + }); + return this.clientRepo.findOneByOrFail({ Id: parseInt(id, 10) }); + } + +``` + +- [ ] **Step 2: Verify build compiles** + +```bash +cd paperless-backend && npm run build 2>&1 | tail -5 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add paperless-backend/src/settings/settings.controller.ts +git commit -m "feat: add GET/PUT /api/settings/clients endpoints for AgrarmonitorBetriebId management" +``` + +--- + +### Task 8: Update .env.example and docker-compose.yml + +**Files:** +- Modify: `.env.example` +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Add AGRARMONITOR_POLLING_CRON to .env.example** + +Find the line `AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung` and add the following line immediately after it: + +``` +AGRARMONITOR_POLLING_CRON=0 */30 * * * * # alle 30 Minuten (leer lassen = deaktiviert) +``` + +- [ ] **Step 2: Add AGRARMONITOR_POLLING_CRON to docker-compose.yml** + +Find the line `- AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-}` and add the following line immediately after it: + +```yaml + - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} +``` + +- [ ] **Step 3: Commit** + +```bash +git add .env.example docker-compose.yml +git commit -m "chore: add AGRARMONITOR_POLLING_CRON env var to .env.example and docker-compose.yml" +``` + +--- + +### Task 9: Frontend — Update API files + +**Files:** +- Modify: `paperless-frontend/src/api/settings.ts` +- Modify: `paperless-frontend/src/api/inbox.ts` + +- [ ] **Step 1: Update Client interface in inbox.ts** + +In `paperless-frontend/src/api/inbox.ts`, find: + +```typescript +export interface Client { + Id: number; + Name: string; + PaperlessUserId: number; +} +``` + +Replace with: + +```typescript +export interface Client { + Id: number; + Name: string; + PaperlessUserId: number; + AgrarmonitorBetriebId: number | null; +} +``` + +- [ ] **Step 2: Add polling types and API methods to settings.ts** + +In `paperless-frontend/src/api/settings.ts`, find the `export const agrarmonitorApi = {` block and replace it with: + +```typescript +export interface PollingConfig { + tagFertig: string; + tagVerbucht: string; +} + +export interface PollingResult { + processed: number; + updated: number; + skipped: number; + errors: string[]; +} + +export interface AgrarmonitorClient { + Id: number; + Name: string; + PaperlessUserId: number; + AgrarmonitorBetriebId: number | null; +} + +export const agrarmonitorApi = { + getStatus: () => + api.get('/api/agrarmonitor/status').then((r) => r.data), + registerDevice: (pcName: string, agrarmonitorId: string) => + api + .post<{ success: boolean; message: string }>('/api/agrarmonitor/register', { pcName, agrarmonitorId }) + .then((r) => r.data), + getPollingConfig: () => + api.get('/api/agrarmonitor/polling-config').then((r) => r.data), + updatePollingConfig: (config: PollingConfig) => + api.put('/api/agrarmonitor/polling-config', config).then((r) => r.data), + runPolling: () => + api.post('/api/agrarmonitor/run-polling', {}).then((r) => r.data), + getAllClients: () => + api.get('/api/settings/clients').then((r) => r.data), + updateClient: (id: number, data: { AgrarmonitorBetriebId: number | null }) => + api.put(`/api/settings/clients/${id}`, data).then((r) => r.data), +}; +``` + +- [ ] **Step 3: Verify frontend TypeScript compiles** + +```bash +cd paperless-frontend && npm run build 2>&1 | tail -10 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 4: Commit** + +```bash +git add paperless-frontend/src/api/settings.ts paperless-frontend/src/api/inbox.ts +git commit -m "feat: add polling config/run API and AgrarmonitorBetriebId to frontend API layer" +``` + +--- + +### Task 10: Frontend — Extend SettingsPage.tsx + +**Files:** +- Modify: `paperless-frontend/src/pages/SettingsPage.tsx` + +Two sections to update: `AgrarmonitorTab` (add polling config + run button) and `UserClientsTab` (add Betriebe management table). + +#### Part A — AgrarmonitorTab + +- [ ] **Step 1: Add imports** + +Find the existing imports from `'../api/settings'` (around line 16). Add the new types: + +```typescript + agrarmonitorApi, type AgrarmonitorStatusData, + type PollingConfig, type PollingResult, type AgrarmonitorClient, +``` + +(Replace the current `agrarmonitorApi, type AgrarmonitorStatusData,` with the above.) + +- [ ] **Step 2: Replace the AgrarmonitorTab function** + +Find the comment `// ═══════════════════════════════════════════════════════════════════` directly above `function AgrarmonitorTab()` and replace the entire `AgrarmonitorTab` function (up to the closing `}` before the next `// ═══...` comment) with: + +```typescript +// ═══════════════════════════════════════════════════════════════════ +// Agrarmonitor Tab +// ═══════════════════════════════════════════════════════════════════ + +function AgrarmonitorTab() { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [registering, setRegistering] = useState(false); + const [status, setStatus] = useState(null); + const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); + + const [pollingConfig, setPollingConfig] = useState({ tagFertig: '4', tagVerbucht: '9' }); + const [savingConfig, setSavingConfig] = useState(false); + const [runningPolling, setRunningPolling] = useState(false); + const [pollingResult, setPollingResult] = useState(null); + + useEffect(() => { + agrarmonitorApi.getPollingConfig().then(setPollingConfig).catch(() => {}); + }, []); + + const handleLoadStatus = async () => { + setLoading(true); + setRegisterResult(null); + try { + const data = await agrarmonitorApi.getStatus(); + setStatus(data); + } catch (err: any) { + const msg = err?.code === 'ECONNABORTED' + ? 'Timeout – Backend antwortet nicht rechtzeitig' + : (err?.response?.data?.message ?? err?.message ?? 'Netzwerkfehler'); + setStatus({ connected: false, registriert: null, freigeschaltet: null, error: msg }); + } finally { + setLoading(false); + } + }; + + const handleRegister = async () => { + const values = await form.validateFields(); + setRegistering(true); + setRegisterResult(null); + try { + const result = await agrarmonitorApi.registerDevice(values.pcName, values.agrarmonitorId); + setRegisterResult(result); + if (result.success) { + message.success('Gerät erfolgreich registriert'); + await handleLoadStatus(); + } + } catch { + setRegisterResult({ success: false, message: 'Registrierung fehlgeschlagen' }); + } finally { + setRegistering(false); + } + }; + + const handleSaveConfig = async () => { + setSavingConfig(true); + try { + await agrarmonitorApi.updatePollingConfig(pollingConfig); + message.success('Konfiguration gespeichert'); + } catch { + message.error('Fehler beim Speichern'); + } finally { + setSavingConfig(false); + } + }; + + const handleRunPolling = async () => { + setRunningPolling(true); + setPollingResult(null); + try { + const result = await agrarmonitorApi.runPolling(); + setPollingResult(result); + } catch (err: any) { + message.error(err?.response?.data?.message ?? 'Polling fehlgeschlagen'); + } finally { + setRunningPolling(false); + } + }; + + const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => { + if (value === null) return ; + return value + ? {labelTrue} + : {labelFalse}; + }; + + return ( +
+ Agrarmonitor + + Verbindungsstatus und Geräte-Registrierung für die Agrarmonitor-Schnittstelle. + Zugangsdaten werden in der .env konfiguriert. + + + +
+ +
+ + {status && ( + + +
+ Verbindung: + {status.connected + ? Verbunden + : Nicht verbunden} +
+
+ Registriert: + {renderStatusTag(status.registriert, 'Ja', 'Nein')} +
+
+ Freigeschaltet: + {renderStatusTag(status.freigeschaltet, 'Ja', 'Nein')} +
+ {status.error && ( +
{status.error}
+ )} +
+
+ )} + + {status?.registriert === false && ( + +
+ + + + + + + + + {registerResult && ( +
+ + {registerResult.message} + +
+ )} +
+ )} + + + + +
+
Tag: Fertig in Agrarmonitor
+ setPollingConfig((c) => ({ ...c, tagFertig: String(v ?? 4) }))} + style={{ width: '100%' }} + /> + + +
Tag: Verbucht
+ setPollingConfig((c) => ({ ...c, tagVerbucht: String(v ?? 9) }))} + style={{ width: '100%' }} + /> + + + + + + + + + + {pollingResult && ( + + {pollingResult.processed} verarbeitet + {pollingResult.updated} aktualisiert + {pollingResult.skipped} übersprungen + {pollingResult.errors.length > 0 && ( + {pollingResult.errors.length} Fehler + )} + + )} + {pollingResult?.errors && pollingResult.errors.length > 0 && ( +
+ {pollingResult.errors.map((e, i) =>
{e}
)} +
+ )} +
+
+ + + ); +} +``` + +#### Part B — UserClientsTab: add Betriebe management + +- [ ] **Step 3: Add agrarmonitorApi import to UserClientsTab usage area** + +The `agrarmonitorApi` and `AgrarmonitorClient` are already imported at the top of the file (added in Task 9, Step 1). + +- [ ] **Step 4: Replace the UserClientsTab function** + +Find the comment `// ═══════════════════════════════════════════════════════════════════` directly above `function UserClientsTab()` and replace the entire `UserClientsTab` function with: + +```typescript +// ═══════════════════════════════════════════════════════════════════ +// Benutzer & Betriebe Tab +// ═══════════════════════════════════════════════════════════════════ + +function UserClientsTab() { + const [data, setData] = useState([]); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + + const [allClients, setAllClients] = useState([]); + const [editValues, setEditValues] = useState>({}); + const [savingClients, setSavingClients] = useState>({}); + + const load = useCallback(async () => { + setLoading(true); + try { + const [ucs, cls, agrarClients] = await Promise.all([ + settingsApi.getUserClients(), + clientsApi.getMyClients(), + agrarmonitorApi.getAllClients(), + ]); + setData(ucs); + setClients(cls); + setAllClients(agrarClients); + const initialEdit: Record = {}; + agrarClients.forEach((c) => { initialEdit[c.Id] = c.AgrarmonitorBetriebId; }); + setEditValues(initialEdit); + } finally { setLoading(false); } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleAdd = async () => { + const values = await form.validateFields(); + await settingsApi.createUserClient(values); + message.success('Zuordnung erstellt'); + setModalOpen(false); + form.resetFields(); + load(); + }; + + const handleDelete = async (id: number) => { + await settingsApi.deleteUserClient(id); + message.success('Gelöscht'); + load(); + }; + + const handleSaveClient = async (clientId: number) => { + setSavingClients((s) => ({ ...s, [clientId]: true })); + try { + await agrarmonitorApi.updateClient(clientId, { AgrarmonitorBetriebId: editValues[clientId] ?? null }); + message.success('Gespeichert'); + } catch { + message.error('Fehler beim Speichern'); + } finally { + setSavingClients((s) => ({ ...s, [clientId]: false })); + } + }; + + const columns: ColumnsType = [ + { title: 'User ID', dataIndex: 'UserId', key: 'userId' }, + { + title: 'Betrieb', + key: 'client', + render: (_, r) => clients.find(c => c.Id === r.ClientId)?.Name ?? r.ClientId, + }, + { + title: 'Rolle', + dataIndex: 'Role', + key: 'role', + render: (r: string) => {r}, + }, + { + title: '', + key: 'actions', + width: 80, + render: (_, record) => ( + handleDelete(record.Id)}> + + ), + }, + ]; + + return ( + <> + Benutzer-Betrieb-Zuordnung + +
+ + + Betriebe (Agrarmonitor-Mapping) +
+ + setModalOpen(false)}> +
+ + + + + + + + + + +
+ + ); +} +``` + +- [ ] **Step 5: Verify frontend TypeScript compiles** + +```bash +cd paperless-frontend && npm run build 2>&1 | tail -10 +``` + +Expected: no TypeScript errors. + +- [ ] **Step 6: Commit** + +```bash +git add paperless-frontend/src/pages/SettingsPage.tsx +git commit -m "feat: extend AgrarmonitorTab with polling config/run and UserClientsTab with BetriebId mapping" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- [x] `Client.AgrarmonitorBetriebId` → Task 2 +- [x] Setting seed `agrarmonitor_tag_fertig` / `agrarmonitor_tag_verbucht` → Task 4 `onModuleInit` +- [x] `AgrarmonitorPollingService.runPolling()` with cron + manual trigger → Tasks 4 + 5 +- [x] `GET/PUT /api/agrarmonitor/polling-config` → Task 5 +- [x] `POST /api/agrarmonitor/run-polling` → Task 5 +- [x] Module wiring → Task 6 +- [x] `GET/PUT /api/settings/clients/:id` → Task 7 +- [x] `AGRARMONITOR_POLLING_CRON` env var → Task 8 +- [x] Frontend polling config UI → Task 10 Part A +- [x] Frontend Betriebe BetriebId column → Task 10 Part B +- [x] `node-html-parser` install → Task 1 +- [x] All HTML-scraping calls (livesearch, setEingangsdatum, setLieferscheinNummer) → Task 3 + +**Notes:** +- `Betrieb` (Paperless custom field 6) and `Gruppe` are NOT set on the document during polling because these values are not stored in the `Client` entity. Only `owner` (from `Client.PaperlessUserId`) is updated. This matches the spec's explicit data model — the C# hardcoded switch is replaced by the `AgrarmonitorBetriebId` mapping. +- Custom field IDs 7 (interneBelegnummer) and 9 (Eingangsdatum) are hardcoded in `AgrarmonitorPollingService` as constants `INTERN_BELEGNUMMER_FIELD_ID` and `EINGANGSDATUM_FIELD_ID`. Change these if your Paperless installation uses different IDs. +- The cron guard `if (!process.env['AGRARMONITOR_POLLING_CRON']) return;` ensures the scheduled polling is a no-op when the env var is not set, even though the `@Cron` decorator falls back to `0 */30 * * * *`. diff --git a/docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md b/docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md new file mode 100644 index 0000000..9c66cdb --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-agrarmonitor-polling-design.md @@ -0,0 +1,135 @@ +# Agrarmonitor Polling Service — Design + +**Datum:** 2026-05-23 +**Branch:** Agrarmonitor + +--- + +## Kontext + +Der Polling-Service prüft regelmäßig Paperless-Dokumente, die als "fertig für Agrarmonitor" markiert sind (Tag-ID konfigurierbar), gleicht sie mit Agrarmonitor-Eingangsrechnungen ab und aktualisiert sowohl Agrarmonitor (Eingangsdatum, Lieferscheinnummer) als auch Paperless (Korrespondent, Betrieb, Tags) sobald ein Buchungsdatum vorliegt. + +Logik basiert auf `ProcessEingaenge.cs` aus dem C#-Paperlessworker. + +--- + +## Datenbankänderungen + +### `Client`-Entity +Neue nullable Spalte: +```typescript +@Column({ type: 'int', nullable: true }) +AgrarmonitorBetriebId: number | null; +``` +Verknüpft einen Paperless-Betrieb (Client) mit seiner Agrarmonitor-BetriebId. Wird vom Polling-Service für die Dokumentzuordnung genutzt. + +### `Setting`-Entity (neue Einträge, per Seed/upsert) +Zwei neue Einträge identifiziert über das `Tag`-Feld: +- `Tag = 'agrarmonitor_tag_fertig'`, Wert default `'4'` — Tag-ID für "fertig in Agrarmonitor" +- `Tag = 'agrarmonitor_tag_verbucht'`, Wert default `'9'` — Tag-ID für "verbucht" + +--- + +## Backend + +### Neue Datei: `agrarmonitor-polling.service.ts` + +**Methoden:** +- `runPolling(): Promise` — Haupt-Logik, synchron ausführbar +- Automatischer Start via `@Cron(env.AGRARMONITOR_POLLING_CRON)` + +**Polling-Logik (sequenziell):** +1. Tag-IDs aus `Setting`-Tabelle lesen (Tag-Fertig, Tag-Verbucht) +2. `agrarmonitorService.getClient()` → Connector holen +3. `client.fetchCustomers()` → aktive Lieferanten (`ist_lieferant=1`, `ist_aktiv=1`) als Paperless-Korrespondenten synchronisieren +4. Paperless-Dokumente mit Tag-Fertig laden +5. Pro Dokument mit `interneBelegnummer`: + - `client.eingangsrechnungenLivesearch(belegnummer)` aufrufen + - Kein Treffer → überspringen + - Mehrere Treffer → Fehler loggen, überspringen + - Kein `eingangsDatum` + Paperless hat `Eingangsdatum` → `client.setEingangsdatum()` aufrufen + - `buchungsDatum` vorhanden → Paperless-Dokument aktualisieren: + - Korrespondenten über `AgrarmonitorBetriebId`-Mapping setzen + - Betrieb/Owner/Gruppe aus Client-Tabelle übernehmen + - Tag-Fertig entfernen, Tag-Verbucht hinzufügen + - `paperlessService.updateDocument()` aufrufen + - 500ms Pause zwischen Dokumenten (API-Schonung) +6. `PollingResult` zurückgeben: `{processed, updated, skipped, errors}` + +**Abhängigkeiten:** `AgrarmonitorService`, `PaperlessService`, `Repository`, `Repository` + +### `AgrarmonitorModule` — Erweiterungen +```typescript +imports: [ + TypeOrmModule.forFeature([Setting, Client]), + PaperlessModule, + ScheduleModule, // bereits global registriert +] +providers: [AgrarmonitorService, AgrarmonitorPollingService] +``` + +### Neue Endpoints (in `AgrarmonitorController`) + +| Method | Route | Beschreibung | +|--------|-------|-------------| +| `GET` | `/api/agrarmonitor/polling-config` | Tag-IDs lesen | +| `PUT` | `/api/agrarmonitor/polling-config` | Tag-IDs speichern | +| `POST` | `/api/agrarmonitor/run-polling` | Polling manuell auslösen | + +Alle Routen: `@RequirePermissions(Permission.MANAGE_SETTINGS)` + +### `.env.example` — neuer Eintrag +``` +AGRARMONITOR_POLLING_CRON=0 */30 * * * * # alle 30 Minuten +``` + +--- + +## Frontend + +### Agrarmonitor-Tab (bestehend erweitern) + +**Neuer Abschnitt "Polling-Konfiguration":** +- Input "Tag: Fertig in Agrarmonitor" (Zahl, lädt aus `/api/agrarmonitor/polling-config`) +- Input "Tag: Verbucht" (Zahl) +- Speichern-Button → `PUT /api/agrarmonitor/polling-config` + +**Neuer Abschnitt "Polling ausführen":** +- Button "Jetzt ausführen" → `POST /api/agrarmonitor/run-polling` +- Ergebnisanzeige: "X verarbeitet, X aktualisiert, X Fehler" + +### "Benutzer & Betriebe"-Tab (bestehend erweitern) + +In der Client-Tabelle: neue Spalte "Agrarmonitor-BetriebId" (inline editierbar, Zahl oder leer). +Speichern via `PUT /api/settings/clients/:id` (neuer Endpoint oder bestehenden erweitern). + +--- + +## Datenfluss + +``` +Cron / manueller Trigger + → AgrarmonitorPollingService.runPolling() + → AgrarmonitorService.getClient() (Connector) + → PaperlessService.getDocuments() (Tag-Fertig-Filter) + → pro Dokument: + → client.eingangsrechnungenLivesearch() + → client.setEingangsdatum() (falls nötig) + → client.fetchCustomers() + PaperlessService.updateDocument() +``` + +--- + +## Fehlerbehandlung + +- Einzelne Dokument-Fehler werden geloggt, überspringen den Eintrag, brechen den gesamten Lauf nicht ab +- Connector-Fehler (Verbindung zu Agrarmonitor) brechen den Lauf ab, geben `{error}` zurück +- Ergebnis wird immer als strukturiertes Objekt zurückgegeben (nie 500) + +--- + +## Nicht im Scope + +- E-Mail-Benachrichtigung bei Fehlern (kommt ggf. später via Postprocessing) +- Rückgängig machen von Buchungen +- Polling-Log in der Datenbank (Ergebnis nur in Backend-Logs + API-Response) From 74cd2477f10b7fde2821f8ac0c98333b187a0976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 23 May 2026 16:25:47 +0200 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20two=20polling=20bugs=20=E2=80=94?= =?UTF-8?q?=20correspondent=20400=20and=20Agrarmonitor=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 400 on Korrespondenten-Sync: getCorrespondentByName was called with searchName "(12345)" but checked exact match against full displayName "Firma (12345)". Always returned null → duplicate addCorrespondent on every run → Paperless 400. Fix: search by displayName directly. - 403 on Livesearch: cached Agrarmonitor session expired. Fix: detect 401/403 from connector, call clearClient() to invalidate cache, break out of the polling loop so next cron run re-authenticates. Co-Authored-By: Claude Sonnet 4.6 --- .../src/agrarmonitor/agrarmonitor-polling.service.ts | 11 +++++++++-- .../src/agrarmonitor/agrarmonitor.service.ts | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 890f00f..1859bbb 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -105,9 +105,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { )) { try { const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; - const searchName = `(${lieferantennummer})`; const displayName = this.buildCustomerName(customer, lieferantennummer); - const existing = await this.paperlessService.getCorrespondentByName(searchName); + const existing = await this.paperlessService.getCorrespondentByName(displayName); if (!existing) { await this.paperlessService.addCorrespondent({ name: displayName, @@ -153,6 +152,14 @@ export class AgrarmonitorPollingService implements OnModuleInit { 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); diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts index bff9a17..1fdb49c 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts @@ -56,6 +56,10 @@ export class AgrarmonitorService { return this.client; } + clearClient(): void { + this.client = null; + } + async getStatus(): Promise { try { const client = await this.getClient(); From 1133023c48dc0dd686c51571d35ef0782f8648e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 25 May 2026 11:38:45 +0200 Subject: [PATCH 17/19] chore: update agrarmonitor-connector to commit cd89a30 Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index ee01b34..141a35e 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -20,7 +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", + "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", @@ -4862,7 +4862,7 @@ }, "node_modules/agrarmonitor-connector": { "version": "0.1.0", - "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cf6bc1b5cc7e5ffa060c4a37bcea7d9ea6635527", + "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a3063144a4f7746c9946d1e4f888f8f0f8b4", "license": "MIT", "dependencies": { "axios": "^1.7.9", From a726f863f0aaca56dab92689c791ef60db200259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 25 May 2026 11:41:22 +0200 Subject: [PATCH 18/19] feat: set loginStrategy to 'redirect' in AgrarmonitorService Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/src/agrarmonitor/agrarmonitor.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts index 1fdb49c..dfba116 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts @@ -50,6 +50,7 @@ export class AgrarmonitorService { autoLogin: true, autoRetry: false, timeoutMs: 10000, + loginStrategy: 'redirect', logger: this.logger, }); From 8c5a81ed27f7fda66001bff2143478f14e85642d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 25 May 2026 12:11:44 +0200 Subject: [PATCH 19/19] feat: implement ProcessVerarbeiteteDocuments (Upload-Check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported ProcessVerarbeiteteDocuments() from C# ProcessUploads.cs: - Checks docs tagged "hochgeladen" → eingangsrechnungVorhanden() - On match: livesearch, update title/type/created/correspondent/tags, set custom fields (externeBelegnummer, AgrarmonitorLink), addNote - Tag "hochgeladen" → "fertig" swap; owner via Client.AgrarmonitorBetriebId - 401/403 guard: clearClient() + break (same pattern as runPolling) - Cron: AGRARMONITOR_UPLOAD_CHECK_CRON (default: 0 * * * * *) - New settings: agrarmonitor_tag_hochgeladen, agrarmonitor_link_field - Endpoint: POST /api/agrarmonitor/process-uploads - Frontend: polling-config extended with tagHochgeladen + linkField select, new card "Dokumenten-Verarbeitung" with run button + result display Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + docker-compose.yml | 1 + .../agrarmonitor-polling.service.ts | 246 +++++++++++++++++- .../agrarmonitor/agrarmonitor.controller.ts | 11 +- paperless-frontend/src/api/settings.ts | 4 + paperless-frontend/src/pages/SettingsPage.tsx | 60 ++++- 6 files changed, 312 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 254c991..504d47e 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,4 @@ 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 diff --git a/docker-compose.yml b/docker-compose.yml index 2a94afe..0635a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: - 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 diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 1859bbb..0c826eb 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -9,7 +9,9 @@ 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; @@ -22,6 +24,7 @@ export interface PollingResult { export class AgrarmonitorPollingService implements OnModuleInit { private readonly logger = new Logger(AgrarmonitorPollingService.name); private pollingRunning = false; + private uploadCheckRunning = false; constructor( private readonly agrarmonitorService: AgrarmonitorService, @@ -33,6 +36,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { 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 * * * *') @@ -41,21 +46,40 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); } - async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string }> { - const [fertig, verbucht] = await Promise.all([ + @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): 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 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 { @@ -210,9 +234,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { const customer = customers.find((c) => 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); + let corr = await this.paperlessService.getCorrespondentByName(displayName); if (!corr) { corr = await this.paperlessService.addCorrespondent({ name: displayName, @@ -264,6 +287,213 @@ export class AgrarmonitorPollingService implements OnModuleInit { return result; } + async processVerarbeiteteDocuments(): Promise { + 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>; + 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>; + 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 = { + 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, nummer: string): string { const firma = (customer['firma'] as string) ?? ''; const nachname = (customer['nachname'] as string) ?? ''; diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index 76b9aed..c5c5192 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -32,8 +32,8 @@ export class AgrarmonitorController { @Put('polling-config') @RequirePermissions(Permission.MANAGE_SETTINGS) - async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string }) { - return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht); + 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') @@ -42,4 +42,11 @@ export class AgrarmonitorController { async runPolling() { return this.pollingService.runPolling(); } + + @Post('process-uploads') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async processUploads() { + return this.pollingService.processVerarbeiteteDocuments(); + } } diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index 8fabd38..6819ef6 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -199,6 +199,8 @@ export interface AgrarmonitorStatusData { export interface AgrarmonitorPollingConfig { tagFertig: string; tagVerbucht: string; + tagHochgeladen: string; + linkField: string; } export interface AgrarmonitorPollingResult { @@ -221,4 +223,6 @@ export const agrarmonitorApi = { api.put('/api/agrarmonitor/polling-config', config).then((r) => r.data), runPolling: () => api.post('/api/agrarmonitor/run-polling').then((r) => r.data), + processUploads: () => + api.post('/api/agrarmonitor/process-uploads').then((r) => r.data), }; diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index c1081f9..aff7136 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -2291,9 +2291,12 @@ function AgrarmonitorTab() { const [pollingConfigLoading, setPollingConfigLoading] = useState(false); const [pollingSaving, setPollingSaving] = useState(false); const [pollingRunning, setPollingRunning] = useState(false); + const [uploadCheckRunning, setUploadCheckRunning] = useState(false); const [status, setStatus] = useState(null); const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); const [pollingResult, setPollingResult] = useState(null); + const [uploadCheckResult, setUploadCheckResult] = useState(null); + const [customFields, setCustomFields] = useState([]); const handleLoadStatus = async () => { setLoading(true); @@ -2341,7 +2344,10 @@ function AgrarmonitorTab() { } }, [pollingForm]); - useEffect(() => { handleLoadPollingConfig(); }, [handleLoadPollingConfig]); + useEffect(() => { + handleLoadPollingConfig(); + paperlessApi.getCustomFields().then(setCustomFields).catch(() => {}); + }, [handleLoadPollingConfig]); const handleSavePollingConfig = async () => { const values = await pollingForm.validateFields() as AgrarmonitorPollingConfig; @@ -2369,6 +2375,19 @@ function AgrarmonitorTab() { } }; + 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 ; return value @@ -2462,6 +2481,16 @@ function AgrarmonitorTab() { > + + + + + + @@ -2492,6 +2521,35 @@ function AgrarmonitorTab() { )} + + + + Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig", + sobald sie im Agrarmonitor-Buchungssystem erscheinen. + + + + {uploadCheckResult && ( +
+ {uploadCheckResult.processed} geprüft + {uploadCheckResult.updated} aktualisiert + {uploadCheckResult.skipped} übersprungen + {uploadCheckResult.errors.length > 0 && ( + {uploadCheckResult.errors.length} Fehler + )} + {uploadCheckResult.errors.length > 0 && ( +
    + {uploadCheckResult.errors.map((e, i) => ( +
  • {e}
  • + ))} +
+ )} +
+ )} +
+
);