From 3d4125b29a749b887c460055d4f215dd8383bf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 28 Sep 2026 08:51:13 +0200 Subject: [PATCH] feat(agrarmonitor): fehlende Lieferantennummern vergeben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Bereich „Lieferantennummern überprüfen“ in den Agrarmonitor- Einstellungen. Er vergibt allen aktiven Lieferanten ohne Lieferantennummer eine fortlaufende Nummer im Schema 1555xxxx, ab der höchsten vergebenen Nummer (Inaktive zählen mit, Altlasten werden ignoriert). - Vorschau „Lieferant → neue Nummer“, geschrieben wird erst nach Bestätigung; Einzelmodus zum Testen an einem Lieferanten - formData wird wie in der Weboberfläche aus #form_detail aufgebaut, nur die Lieferantennummer ändert sich - Dublettencheck über vorhanden.php, Erfolgskontrolle durch Neuladen des Details, Abbruch beim ersten Fehler - Lauf im Hintergrund mit Status-Abfrage (wegen Proxy-Timeouts), Listenfilter werden danach zurückgesetzt - HTML-Parsing mit cheerio, Unit-Tests für Parser und Vergabe-Ablauf Co-Authored-By: Claude Opus 5.5 --- paperless-backend/package-lock.json | 285 +++++++++++++ paperless-backend/package.json | 1 + .../agrarmonitor-polling.service.ts | 2 +- .../agrarmonitor/agrarmonitor.controller.ts | 45 ++- .../src/agrarmonitor/agrarmonitor.module.ts | 7 +- .../lieferantennummern.parser.spec.ts | 196 +++++++++ .../agrarmonitor/lieferantennummern.parser.ts | 181 +++++++++ .../lieferantennummern.service.spec.ts | 151 +++++++ .../lieferantennummern.service.ts | 376 ++++++++++++++++++ paperless-frontend/src/api/settings.ts | 54 +++ paperless-frontend/src/pages/SettingsPage.tsx | 212 ++++++++++ 11 files changed, 1507 insertions(+), 3 deletions(-) create mode 100644 paperless-backend/src/agrarmonitor/lieferantennummern.parser.spec.ts create mode 100644 paperless-backend/src/agrarmonitor/lieferantennummern.parser.ts create mode 100644 paperless-backend/src/agrarmonitor/lieferantennummern.service.spec.ts create mode 100644 paperless-backend/src/agrarmonitor/lieferantennummern.service.ts diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 52e965a..d94e9f8 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -23,6 +23,7 @@ "agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git", "axios": "^1.14.0", "basic-ftp": "^5.2.1", + "cheerio": "^1.2.0", "chokidar": "^4.0.3", "dotenv": "^17.4.2", "fast-xml-parser": "^5.11.1", @@ -5543,6 +5544,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", @@ -5802,6 +5809,109 @@ "dev": true, "license": "MIT" }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/cheerio/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -6200,6 +6310,22 @@ "node": ">= 8" } }, + "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", @@ -6213,6 +6339,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-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -6582,6 +6720,31 @@ "node": ">=8.10.0" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -6596,6 +6759,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -10057,6 +10232,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", @@ -10263,6 +10450,79 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.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", @@ -13239,6 +13499,31 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", diff --git a/paperless-backend/package.json b/paperless-backend/package.json index 811595c..0ea2720 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -38,6 +38,7 @@ "agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git", "axios": "^1.14.0", "basic-ftp": "^5.2.1", + "cheerio": "^1.2.0", "chokidar": "^4.0.3", "dotenv": "^17.4.2", "fast-xml-parser": "^5.11.1", diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 55b1ee9..3fa615b 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -16,7 +16,7 @@ const DOCS_PAGE_SIZE = 500; const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de'; /** Fehlermeldung inkl. aufgerufener Seite (Methode + URL), falls es ein HTTP-Fehler ist. */ -function fehlerText(err: unknown): string { +export function fehlerText(err: unknown): string { const message = err instanceof Error ? err.message : String(err); if (!axios.isAxiosError(err) || !err.config) return message; const method = (err.config.method ?? 'get').toUpperCase(); diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index c98f6f8..7cfd23e 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -1,6 +1,15 @@ -import { Body, Controller, Get, HttpCode, Post, Put } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + Post, + Put, +} from '@nestjs/common'; import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; +import { LieferantennummernService } from './lieferantennummern.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -9,6 +18,7 @@ export class AgrarmonitorController { constructor( private readonly service: AgrarmonitorService, private readonly pollingService: AgrarmonitorPollingService, + private readonly lieferantennummernService: LieferantennummernService, ) {} @Get('status') @@ -86,4 +96,37 @@ export class AgrarmonitorController { ) { return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId); } + + @Get('lieferantennummern/status') + @RequirePermissions(Permission.MANAGE_SETTINGS) + getLieferantennummernStatus() { + return this.lieferantennummernService.getStatus(); + } + + @Post('lieferantennummern/vorschau') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async lieferantennummernVorschau() { + return this.lieferantennummernService.vorschau(); + } + + @Post('lieferantennummern/vergeben') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async lieferantennummernVergeben( + @Body() body: { startNummer: number; nurKundeId?: number }, + ) { + const startNummer = Number(body.startNummer); + const nurKundeId = + body.nurKundeId === undefined || body.nurKundeId === null + ? undefined + : Number(body.nurKundeId); + if ( + !Number.isInteger(startNummer) || + (nurKundeId !== undefined && !Number.isInteger(nurKundeId)) + ) { + throw new BadRequestException('startNummer bzw. nurKundeId ungültig'); + } + return this.lieferantennummernService.starten(startNummer, nurKundeId); + } } diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts index 3d068fc..52eed31 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; import { AgrarmonitorController } from './agrarmonitor.controller'; +import { LieferantennummernService } from './lieferantennummern.service'; import { PaperlessModule } from '../paperless/paperless.module'; import { Setting } from '../database/entities/setting.entity'; import { Client } from '../database/entities/client.entity'; @@ -13,7 +14,11 @@ import { CorrespondentSetting } from '../database/entities/correspondent-setting TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]), PaperlessModule, ], - providers: [AgrarmonitorService, AgrarmonitorPollingService], + providers: [ + AgrarmonitorService, + AgrarmonitorPollingService, + LieferantennummernService, + ], controllers: [AgrarmonitorController], exports: [AgrarmonitorService], }) diff --git a/paperless-backend/src/agrarmonitor/lieferantennummern.parser.spec.ts b/paperless-backend/src/agrarmonitor/lieferantennummern.parser.spec.ts new file mode 100644 index 0000000..ebb434b --- /dev/null +++ b/paperless-backend/src/agrarmonitor/lieferantennummern.parser.spec.ts @@ -0,0 +1,196 @@ +import { + baueFormData, + bestimmeKandidaten, + hoechsteLieferantennummer, + lieferantennummerAusDetail, + parseLieferantenListe, +} from './lieferantennummern.parser'; + +const AKTIV = ''; + +function zeile( + id: number, + nummer: string, + firma: string, + name: string, + aktiv: boolean, +): string { + return ( + `` + + `${nummer}${firma}${name}` + + `Straße 112345Ort` + + `${aktiv ? AKTIV : ''}` + ); +} + +function liste(...zeilen: string[]): string { + return ( + '
' + + zeilen.join('') + + '
Nr
' + ); +} + +describe('parseLieferantenListe', () => { + it('liest Id, Nummer, Firma, Name und Aktiv-Status', () => { + const html = liste( + zeile(71860, '15550700', 'AGROLAB Agrar GmbH', '-', true), + zeile(71861, '-', '-', 'Müller, Hans', false), + ); + expect(parseLieferantenListe(html)).toEqual([ + { + kundeId: 71860, + lieferantennummer: '15550700', + firma: 'AGROLAB Agrar GmbH', + name: '', + aktiv: true, + }, + { + kundeId: 71861, + lieferantennummer: '', + firma: '', + name: 'Müller, Hans', + aktiv: false, + }, + ]); + }); + + it('wirft, wenn die Tabelle fehlt (z. B. Login-Seite)', () => { + expect(() => + parseLieferantenListe('Login'), + ).toThrow(/kunden_table/); + }); +}); + +describe('hoechsteLieferantennummer', () => { + it('berücksichtigt Inaktive und ignoriert Altlasten außerhalb des Schemas', () => { + const lieferanten = parseLieferantenListe( + liste( + zeile(1, '15550029', 'A', '-', true), + zeile(2, '15550029', 'B', '-', true), + zeile(3, '30', 'C', '-', true), + zeile(4, '39', 'D', '-', true), + zeile(5, '15550705', 'E', '-', false), + zeile(6, '15550700', 'F', '-', true), + zeile(7, '155507050', 'G', '-', true), + zeile(8, '-', 'H', '-', true), + ), + ); + expect(hoechsteLieferantennummer(lieferanten)).toBe(15550705); + }); + + it('liefert null, wenn keine Nummer ins Schema passt', () => { + const lieferanten = parseLieferantenListe( + liste(zeile(1, '30', 'A', '-', true)), + ); + expect(hoechsteLieferantennummer(lieferanten)).toBeNull(); + }); +}); + +describe('bestimmeKandidaten', () => { + it('nimmt nur aktive ohne Nummer, sortiert nach Firma bzw. Name', () => { + const lieferanten = parseLieferantenListe( + liste( + zeile(1, '-', 'Zeta GmbH', '-', true), + zeile(2, '-', '-', 'Ärger, Anna', true), + zeile(3, '-', 'Beta KG', '-', false), + zeile(4, '15550001', 'Alpha AG', '-', true), + zeile(5, '', 'Bauer GbR', '-', true), + ), + ); + expect(bestimmeKandidaten(lieferanten).map((l) => l.kundeId)).toEqual([ + 2, 5, 1, + ]); + }); +}); + +const DETAIL = ` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+`; + +describe('baueFormData', () => { + it('baut formData wie die Weboberfläche und setzt nur die Lieferantennummer', () => { + expect(baueFormData(DETAIL, '15550706')).toEqual({ + kundennummer: '21207', + lieferantennummer: '15550706', + action_on_save: 'dieser_kunde', + debitorennummer: '0', + geheim: 'x', + ist_kunde: '', + ist_lieferant: '1', + kundengruppen_auswahl: ['3', '7'], + lieferantengruppen_auswahl: ['9'], + arbeitsgebiet_id: [], + farben: ['rot'], + notiz: 'Zeile 1\nZeile 2', + land_iso_3166: 'DE', + zahlungsbedingung: '0', + eigenschaften: { eig_12: 'abc', eig_13: '' }, + }); + }); + + it('liefert ein leeres eigenschaften-Objekt ohne Eigenschaftentabelle', () => { + const html = + '
'; + expect(baueFormData(html, '15550706')).toEqual({ + lieferantennummer: '15550706', + kundengruppen_auswahl: [], + lieferantengruppen_auswahl: [], + arbeitsgebiet_id: [], + eigenschaften: {}, + }); + }); + + it('wirft, wenn das Formular fehlt', () => { + expect(() => baueFormData('', '1')).toThrow(/form_detail/); + }); +}); + +describe('lieferantennummerAusDetail', () => { + it('liest #lieferantennummer getrimmt', () => { + const html = + '
'; + expect(lieferantennummerAusDetail(html)).toBe('15550706'); + }); + + it('wirft, wenn das Feld fehlt', () => { + expect(() => lieferantennummerAusDetail('')).toThrow( + /lieferantennummer/, + ); + }); +}); diff --git a/paperless-backend/src/agrarmonitor/lieferantennummern.parser.ts b/paperless-backend/src/agrarmonitor/lieferantennummern.parser.ts new file mode 100644 index 0000000..9be3ee9 --- /dev/null +++ b/paperless-backend/src/agrarmonitor/lieferantennummern.parser.ts @@ -0,0 +1,181 @@ +import * as cheerio from 'cheerio'; +import type { Cheerio, CheerioAPI } from 'cheerio'; +import type { Element } from 'domhandler'; + +/** Schema der fortlaufenden Lieferantennummern, z. B. 15550706. */ +export const LIEFERANTENNUMMER_SCHEMA = /^1555\d{4}$/; + +export interface ListenLieferant { + kundeId: number; + /** Leer, wenn Agrarmonitor "-" anzeigt. */ + lieferantennummer: string; + firma: string; + name: string; + aktiv: boolean; +} + +export type FormDataWert = string | string[] | Record; +export type KundeFormData = Record; + +/** Agrarmonitor zeigt leere Zellen als "-". */ +function zellText(td: Cheerio): string { + const text = td.text().trim(); + return text === '-' ? '' : text; +} + +/** Liest die Lieferantenliste aus der Antwort von livesearch.php. */ +export function parseLieferantenListe(html: string): ListenLieferant[] { + const $ = cheerio.load(html); + const table = $('table#kunden_table'); + if (table.length === 0) { + throw new Error( + 'Tabelle #kunden_table nicht gefunden (Session abgelaufen?)', + ); + } + return table + .find('tr[data-id]') + .toArray() + .map((tr) => { + const tds = $(tr).children('td'); + return { + kundeId: Number($(tr).attr('data-id')), + lieferantennummer: zellText(tds.eq(0)), + firma: zellText(tds.eq(1)), + name: zellText(tds.eq(2)), + aktiv: tds.eq(7).find('i.fa-check').length > 0, + }; + }); +} + +export function anzeigeName( + l: Pick, +): string { + return l.firma || l.name; +} + +/** Höchste Nummer im Schema über alle (auch inaktive) Lieferanten; null, wenn keine passt. */ +export function hoechsteLieferantennummer( + lieferanten: ListenLieferant[], +): number | null { + let max: number | null = null; + for (const l of lieferanten) { + if (!LIEFERANTENNUMMER_SCHEMA.test(l.lieferantennummer)) continue; + const n = Number(l.lieferantennummer); + if (max === null || n > max) max = n; + } + return max; +} + +/** Aktive Lieferanten ohne Nummer, sortiert nach Firma bzw. Name. */ +export function bestimmeKandidaten( + lieferanten: ListenLieferant[], +): ListenLieferant[] { + return lieferanten + .filter((l) => l.aktiv && !l.lieferantennummer) + .sort((a, b) => + anzeigeName(a).localeCompare(anzeigeName(b), 'de', { + sensitivity: 'base', + }), + ); +} + +/** Aktuelle Lieferantennummer aus /kunden/detail/. */ +export function lieferantennummerAusDetail(html: string): string { + const input = cheerio.load(html)('input#lieferantennummer'); + if (input.length === 0) { + throw new Error( + 'Feld #lieferantennummer nicht gefunden (Session abgelaufen?)', + ); + } + return (input.attr('value') ?? '').trim(); +} + +function istDisabled(el: Cheerio): boolean { + return el.attr('disabled') !== undefined; +} + +function istChecked(el: Cheerio): boolean { + return el.attr('checked') !== undefined; +} + +function angehakteWerte($: CheerioAPI, name: string): string[] { + return $(`input[type=checkbox][name="${name}"]`) + .toArray() + .map((el) => $(el)) + .filter(istChecked) + .map((cb) => cb.attr('value') ?? 'on'); +} + +const WERT_TYPEN = new Set(['text', 'hidden', 'number', 'password']); + +/** + * Baut formData für updateKunde.php genau wie die Agrarmonitor-Weboberfläche + * aus #form_detail und setzt nur die Lieferantennummer neu. + */ +export function baueFormData( + html: string, + lieferantennummer: string, +): KundeFormData { + const $ = cheerio.load(html); + const form = $('form#form_detail'); + if (form.length === 0) { + throw new Error( + 'Formular #form_detail nicht gefunden (Session abgelaufen?)', + ); + } + const formData: KundeFormData = {}; + + const checkboxen = new Map[]>(); + for (const el of form.find('input[name]').toArray()) { + const input = $(el); + if (istDisabled(input)) continue; + const name = input.attr('name')!; + const typ = input.attr('type')?.toLowerCase(); + if (typ === 'checkbox') { + checkboxen.set(name, [...(checkboxen.get(name) ?? []), input]); + } else if (typ && WERT_TYPEN.has(typ)) { + formData[name] = input.attr('value') ?? ''; + } + } + for (const [name, gruppe] of checkboxen) { + formData[name] = + gruppe.length === 1 + ? istChecked(gruppe[0]) + ? (gruppe[0].attr('value') ?? 'on') + : '' + : gruppe.filter(istChecked).map((cb) => cb.attr('value') ?? 'on'); + } + + for (const el of form.find('textarea[name]').toArray()) { + const textarea = $(el); + if (!istDisabled(textarea)) { + formData[textarea.attr('name')!] = textarea.text(); + } + } + + for (const el of form.find('select[name]').toArray()) { + const select = $(el); + if (istDisabled(select)) continue; + let option = select.find('option[selected]').first(); + if (option.length === 0) option = select.find('option').first(); + formData[select.attr('name')!] = + option.length === 0 ? '' : (option.attr('value') ?? option.text()); + } + + formData.kundengruppen_auswahl = angehakteWerte($, 'kundengruppen_auswahl'); + formData.lieferantengruppen_auswahl = angehakteWerte( + $, + 'lieferantengruppen_auswahl', + ); + formData.arbeitsgebiet_id = angehakteWerte($, 'arbeitsgebiet_id'); + + const eigenschaften: Record = {}; + for (const el of $('#eigenschaftenTable tbody input').toArray()) { + const id = $(el).attr('id'); + if (id) eigenschaften[id] = $(el).attr('value') ?? ''; + } + formData.eigenschaften = eigenschaften; + + formData.lieferantennummer = lieferantennummer; + return formData; +} diff --git a/paperless-backend/src/agrarmonitor/lieferantennummern.service.spec.ts b/paperless-backend/src/agrarmonitor/lieferantennummern.service.spec.ts new file mode 100644 index 0000000..43d381a --- /dev/null +++ b/paperless-backend/src/agrarmonitor/lieferantennummern.service.spec.ts @@ -0,0 +1,151 @@ +import type { AxiosInstance } from 'axios'; +import type { AgrarmonitorService } from './agrarmonitor.service'; +import { + LieferantennummernService, + type LieferantennummerLaufErgebnis, +} from './lieferantennummern.service'; +import type { ListenLieferant } from './lieferantennummern.parser'; + +// Der echte Connector lädt jsdom (ESM), das Jest nicht verarbeiten kann – hier wird er nicht gebraucht. +jest.mock('agrarmonitor-connector', () => ({})); + +interface UpdateBody { + kundeID: number; + formData: Record; +} + +function detail(nummer: string): string { + return ( + '
' + + '' + + `` + + '' + + '' + + '' + + '
' + ); +} + +function kandidat(kundeId: number, firma: string): ListenLieferant { + return { kundeId, lieferantennummer: '', firma, name: '', aktiv: true }; +} + +function neuerLauf(gesamt: number): LieferantennummerLaufErgebnis { + return { + modus: 'alle', + gestartetAm: '', + beendetAm: null, + gesamt, + vergeben: 0, + uebersprungen: 0, + zuPruefen: 0, + fehler: 0, + abgebrochen: false, + eintraege: [], + }; +} + +/** + * Simuliert Agrarmonitor: speichert Nummern aus updateKunde.php pro Kunde, + * vorhanden.php meldet für die Ids in `dubletten` ähnliche Kontakte. + */ +function fakeHttp(opts: { + nummern: Record; + dubletten?: number[]; + speichernIgnorieren?: number[]; +}) { + const aufrufe: string[] = []; + const updates: UpdateBody[] = []; + const http = { + get: jest.fn((url: string) => { + aufrufe.push(`GET ${url}`); + const m = /\/kunden\/detail\/(\d+)/.exec(url); + if (m) { + return Promise.resolve({ + data: detail(opts.nummern[Number(m[1])] ?? ''), + }); + } + return Promise.resolve({ data: '' }); + }), + post: jest.fn((url: string, body: string | UpdateBody) => { + aufrufe.push(`POST ${url}`); + if (typeof body === 'string') { + const id = Number(new URLSearchParams(body).get('customerID')); + return Promise.resolve({ + data: opts.dubletten?.includes(id) ? '
Ähnlich
' : '', + }); + } + updates.push(body); + if (!opts.speichernIgnorieren?.includes(body.kundeID)) { + opts.nummern[body.kundeID] = String(body.formData.lieferantennummer); + } + return Promise.resolve({ data: '/kunden/detail/' + body.kundeID }); + }), + }; + return { http: http as unknown as AxiosInstance, aufrufe, updates }; +} + +function service() { + const agrarmonitor = { clearClient: jest.fn() }; + return new LieferantennummernService( + agrarmonitor as unknown as AgrarmonitorService, + ); +} + +describe('LieferantennummernService.vergeben', () => { + it('vergibt fortlaufend, überspringt Vorhandene und verbraucht bei Dubletten keine Nummer', async () => { + const { http, updates, aufrufe } = fakeHttp({ + nummern: { 2: '15550010' }, + dubletten: [3], + }); + const lauf = neuerLauf(4); + await service().vergeben( + http, + [kandidat(1, 'A'), kandidat(2, 'B'), kandidat(3, 'C'), kandidat(4, 'D')], + 15550706, + lauf, + ); + + expect(lauf.eintraege.map((e) => [e.kundeId, e.status, e.nummer])).toEqual([ + [1, 'vergeben', '15550706'], + [2, 'uebersprungen', undefined], + [3, 'pruefen', undefined], + [4, 'vergeben', '15550707'], + ]); + expect(lauf).toMatchObject({ + vergeben: 2, + uebersprungen: 1, + zuPruefen: 1, + fehler: 0, + abgebrochen: false, + }); + expect(updates[0].kundeID).toBe(1); + expect(updates[0].formData).toMatchObject({ + kundennummer: '21207', + lieferantennummer: '15550706', + ist_kunde: '', + ist_lieferant: '1', + }); + expect(aufrufe[aufrufe.length - 1]).toContain('itemsperpage=25'); + expect(lauf.beendetAm).not.toBeNull(); + }); + + it('bricht ab, wenn die Nummer nach dem Speichern nicht übernommen wurde', async () => { + const { http, updates, aufrufe } = fakeHttp({ + nummern: {}, + speichernIgnorieren: [1], + }); + const lauf = neuerLauf(2); + await service().vergeben( + http, + [kandidat(1, 'A'), kandidat(2, 'B')], + 15550706, + lauf, + ); + + expect(updates).toHaveLength(1); + expect(lauf).toMatchObject({ vergeben: 0, fehler: 1, abgebrochen: true }); + expect(lauf.eintraege[0].hinweis).toContain('15550706'); + expect(aufrufe[aufrufe.length - 1]).toContain('itemsperpage=25'); + }); +}); diff --git a/paperless-backend/src/agrarmonitor/lieferantennummern.service.ts b/paperless-backend/src/agrarmonitor/lieferantennummern.service.ts new file mode 100644 index 0000000..9c165b7 --- /dev/null +++ b/paperless-backend/src/agrarmonitor/lieferantennummern.service.ts @@ -0,0 +1,376 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from '@nestjs/common'; +import type { AxiosInstance } from 'axios'; +import { AgrarmonitorService } from './agrarmonitor.service'; +import { fehlerText } from './agrarmonitor-polling.service'; +import { + anzeigeName, + baueFormData, + bestimmeKandidaten, + hoechsteLieferantennummer, + lieferantennummerAusDetail, + LIEFERANTENNUMMER_SCHEMA, + parseLieferantenListe, + type FormDataWert, + type ListenLieferant, +} from './lieferantennummern.parser'; + +const AJAX_HEADERS = { + 'X-Requested-With': 'XMLHttpRequest', + Accept: '*/*', +}; +/** Die Liste mit allen ~700 Lieferanten braucht länger als der Standard-Timeout des Clients. */ +const LISTE_TIMEOUT_MS = 60_000; + +/** kunden_aktiv: 0 = nur aktive, -1 = aktive und inaktive, 2 = nur inaktive */ +function listenUrl(kundenAktiv: number, itemsPerPage: number): string { + return ( + '/module/kunden/livesearch.php?lieferanten=1&suchstring=' + + `&kunden_aktiv=${kundenAktiv}&kundengruppe=0&itemsperpage=${itemsPerPage}&seite=1` + ); +} + +export interface LieferantennummerKandidat { + kundeId: number; + name: string; + neueNummer: string; +} + +export interface LieferantennummerVorschau { + maximum: number; + naechsteNummer: number; + kandidaten: LieferantennummerKandidat[]; +} + +export type LieferantennummerEintragStatus = + | 'vergeben' + | 'uebersprungen' + | 'pruefen' + | 'fehler'; + +export interface LieferantennummerEintrag { + kundeId: number; + name: string; + status: LieferantennummerEintragStatus; + nummer?: string; + hinweis?: string; +} + +export interface LieferantennummerLaufErgebnis { + modus: 'alle' | 'einzeln'; + gestartetAm: string; + beendetAm: string | null; + gesamt: number; + vergeben: number; + uebersprungen: number; + zuPruefen: number; + fehler: number; + /** Lauf wurde wegen eines Fehlers vor dem Ende abgebrochen. */ + abgebrochen: boolean; + eintraege: LieferantennummerEintrag[]; +} + +export interface LieferantennummerStatus { + aktiv: boolean; + lauf: LieferantennummerLaufErgebnis | null; +} + +function alsText(wert: FormDataWert | undefined): string { + return typeof wert === 'string' ? wert : ''; +} + +@Injectable() +export class LieferantennummernService { + private readonly logger = new Logger(LieferantennummernService.name); + private aktiv = false; + private lauf: LieferantennummerLaufErgebnis | null = null; + + constructor(private readonly agrarmonitorService: AgrarmonitorService) {} + + getStatus(): LieferantennummerStatus { + return { aktiv: this.aktiv, lauf: this.lauf }; + } + + async vorschau(): Promise { + this.sperren(); + try { + const http = await this.http(); + try { + const { maximum, kandidaten } = await this.ermittleStand(http); + return { + maximum, + naechsteNummer: maximum + 1, + kandidaten: kandidaten.map((l, i) => ({ + kundeId: l.kundeId, + name: anzeigeName(l), + neueNummer: String(maximum + 1 + i), + })), + }; + } finally { + await this.filterZuruecksetzen(http); + } + } catch (err) { + this.agrarmonitorService.clearClient(); + throw err; + } finally { + this.aktiv = false; + } + } + + /** + * Prüft den Stand gegen die Vorschau und startet die Vergabe im Hintergrund. + * Mit nurKundeId wird nur dieser eine Lieferant nummeriert (Testmodus). + */ + async starten( + startNummer: number, + nurKundeId?: number, + ): Promise { + this.sperren(); + let http: AxiosInstance; + let kandidaten: ListenLieferant[]; + try { + http = await this.http(); + try { + const stand = await this.ermittleStand(http); + if (stand.maximum + 1 !== startNummer) { + throw new ConflictException( + `Die nächste freie Nummer ist jetzt ${stand.maximum + 1} statt ${startNummer}. Bitte die Vorschau neu laden.`, + ); + } + kandidaten = stand.kandidaten; + if (nurKundeId !== undefined) { + kandidaten = kandidaten.filter((l) => l.kundeId === nurKundeId); + if (kandidaten.length === 0) { + throw new BadRequestException( + `Lieferant ${nurKundeId} ist kein aktiver Lieferant ohne Lieferantennummer (mehr).`, + ); + } + } + } catch (err) { + await this.filterZuruecksetzen(http); + throw err; + } + } catch (err) { + if ( + !( + err instanceof ConflictException || err instanceof BadRequestException + ) + ) { + this.agrarmonitorService.clearClient(); + } + this.aktiv = false; + throw err; + } + + this.lauf = { + modus: nurKundeId !== undefined ? 'einzeln' : 'alle', + gestartetAm: new Date().toISOString(), + beendetAm: null, + gesamt: kandidaten.length, + vergeben: 0, + uebersprungen: 0, + zuPruefen: 0, + fehler: 0, + abgebrochen: false, + eintraege: [], + }; + this.logger.log( + `Lieferantennummern: Vergabe gestartet (${this.lauf.modus}, ${kandidaten.length} Kandidaten, ab ${startNummer})`, + ); + void this.vergeben(http, kandidaten, startNummer, this.lauf).finally(() => { + this.aktiv = false; + }); + return this.getStatus(); + } + + /** Vergibt die Nummern nacheinander; bricht beim ersten Fehler ab. */ + async vergeben( + http: AxiosInstance, + kandidaten: ListenLieferant[], + startNummer: number, + lauf: LieferantennummerLaufErgebnis, + ): Promise { + let nummer = startNummer; + const eintragen = (eintrag: LieferantennummerEintrag) => { + lauf.eintraege.push(eintrag); + const text = `Lieferantennummern: ${eintrag.name} (${eintrag.kundeId}) → ${eintrag.status}${eintrag.nummer ? ` ${eintrag.nummer}` : ''}${eintrag.hinweis ? `: ${eintrag.hinweis}` : ''}`; + if (eintrag.status === 'fehler') this.logger.error(text); + else if (eintrag.status === 'vergeben') this.logger.log(text); + else this.logger.warn(text); + }; + + try { + for (const kandidat of kandidaten) { + const basis = { + kundeId: kandidat.kundeId, + name: anzeigeName(kandidat), + }; + const neueNummer = String(nummer); + if (!LIEFERANTENNUMMER_SCHEMA.test(neueNummer)) { + lauf.fehler++; + lauf.abgebrochen = true; + eintragen({ + ...basis, + status: 'fehler', + hinweis: `Nummer ${neueNummer} passt nicht mehr ins Schema 1555xxxx`, + }); + return; + } + try { + const detailHtml = await this.ladeDetail(http, kandidat.kundeId); + const vorhandeneNummer = lieferantennummerAusDetail(detailHtml); + if (vorhandeneNummer) { + lauf.uebersprungen++; + eintragen({ + ...basis, + status: 'uebersprungen', + hinweis: `hat bereits die Nummer ${vorhandeneNummer}`, + }); + continue; + } + + const formData = baueFormData(detailHtml, neueNummer); + const aehnliche = await this.aehnlicheKontakte( + http, + kandidat.kundeId, + formData, + ); + if (aehnliche) { + lauf.zuPruefen++; + eintragen({ + ...basis, + status: 'pruefen', + hinweis: + 'Agrarmonitor meldet ähnliche Kontakte (mögliche Dublette)', + }); + continue; + } + + await http.post( + '/module/kunden/api/updateKunde.php', + { kundeID: kandidat.kundeId, formData }, + { + headers: { ...AJAX_HEADERS, 'Content-Type': 'application/json' }, + }, + ); + + const gespeichert = lieferantennummerAusDetail( + await this.ladeDetail(http, kandidat.kundeId), + ); + if (gespeichert !== neueNummer) { + throw new Error( + `Nach dem Speichern steht dort „${gespeichert || 'leer'}“ statt ${neueNummer}`, + ); + } + lauf.vergeben++; + eintragen({ ...basis, status: 'vergeben', nummer: neueNummer }); + nummer++; + } catch (err) { + lauf.fehler++; + lauf.abgebrochen = true; + eintragen({ ...basis, status: 'fehler', hinweis: fehlerText(err) }); + this.agrarmonitorService.clearClient(); + return; + } + } + } finally { + await this.filterZuruecksetzen(http); + lauf.beendetAm = new Date().toISOString(); + this.logger.log( + `Lieferantennummern: Lauf beendet – ${lauf.vergeben} vergeben, ${lauf.uebersprungen} übersprungen, ` + + `${lauf.zuPruefen} zu prüfen, ${lauf.fehler} Fehler${lauf.abgebrochen ? ' (abgebrochen)' : ''}`, + ); + } + } + + private sperren(): void { + if (this.aktiv) { + throw new ConflictException( + 'Es läuft bereits eine Lieferantennummern-Prüfung.', + ); + } + this.aktiv = true; + } + + private async http(): Promise { + return (await this.agrarmonitorService.getClient()).http; + } + + private async ermittleStand( + http: AxiosInstance, + ): Promise<{ maximum: number; kandidaten: ListenLieferant[] }> { + const response = await http.get(listenUrl(-1, 100000), { + headers: AJAX_HEADERS, + responseType: 'text', + timeout: LISTE_TIMEOUT_MS, + }); + const lieferanten = parseLieferantenListe(response.data); + const maximum = hoechsteLieferantennummer(lieferanten); + if (maximum === null) { + throw new Error( + `Keine Lieferantennummer im Schema 1555xxxx gefunden (${lieferanten.length} Lieferanten gelesen)`, + ); + } + return { maximum, kandidaten: bestimmeKandidaten(lieferanten) }; + } + + private async ladeDetail( + http: AxiosInstance, + kundeId: number, + ): Promise { + const response = await http.get(`/kunden/detail/${kundeId}`, { + headers: AJAX_HEADERS, + responseType: 'text', + }); + return response.data; + } + + /** Dublettencheck wie in der Weboberfläche; true, wenn Agrarmonitor ähnliche Kontakte meldet. */ + private async aehnlicheKontakte( + http: AxiosInstance, + kundeId: number, + formData: Record, + ): Promise { + const body = new URLSearchParams({ + customerNumber: alsText(formData.kundennummer), + supplierNumber: alsText(formData.lieferantennummer), + customerID: String(kundeId), + isSupplier: alsText(formData.ist_lieferant) ? '1' : '0', + isCustomer: alsText(formData.ist_kunde) ? '1' : '0', + company: alsText(formData.firma), + firstName: alsText(formData.vorname), + lastName: alsText(formData.nachname), + street: alsText(formData.strasse), + }); + const response = await http.post( + '/module/kunden/api/vorhanden.php', + body.toString(), + { + headers: { + ...AJAX_HEADERS, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + responseType: 'text', + }, + ); + return String(response.data ?? '').trim() !== ''; + } + + /** Agrarmonitor merkt sich die Listenfilter pro Benutzer – danach wieder auf den Standard setzen. */ + private async filterZuruecksetzen(http: AxiosInstance): Promise { + try { + await http.get(listenUrl(0, 25), { + headers: AJAX_HEADERS, + responseType: 'text', + }); + } catch (err) { + this.logger.warn( + `Lieferantennummern: Listenfilter konnten nicht zurückgesetzt werden: ${fehlerText(err)}`, + ); + } + } +} diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index 5fe4690..420ff16 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -258,6 +258,46 @@ export interface SyncCorrespondentsResult { conflicts: SyncConflict[]; } +export interface LieferantennummerKandidat { + kundeId: number; + name: string; + neueNummer: string; +} + +export interface LieferantennummerVorschau { + maximum: number; + naechsteNummer: number; + kandidaten: LieferantennummerKandidat[]; +} + +export type LieferantennummerEintragStatus = 'vergeben' | 'uebersprungen' | 'pruefen' | 'fehler'; + +export interface LieferantennummerEintrag { + kundeId: number; + name: string; + status: LieferantennummerEintragStatus; + nummer?: string; + hinweis?: string; +} + +export interface LieferantennummerLaufErgebnis { + modus: 'alle' | 'einzeln'; + gestartetAm: string; + beendetAm: string | null; + gesamt: number; + vergeben: number; + uebersprungen: number; + zuPruefen: number; + fehler: number; + abgebrochen: boolean; + eintraege: LieferantennummerEintrag[]; +} + +export interface LieferantennummerStatus { + aktiv: boolean; + lauf: LieferantennummerLaufErgebnis | null; +} + export const agrarmonitorApi = { getStatus: () => api.get('/api/agrarmonitor/status').then((r) => r.data), @@ -277,4 +317,18 @@ export const agrarmonitorApi = { api.post('/api/agrarmonitor/sync-correspondents').then((r) => r.data), mergeCorrespondents: (keepId: number, deleteId: number) => api.post<{ mergedDocuments: number }>('/api/agrarmonitor/merge-correspondents', { keepId, deleteId }).then((r) => r.data), + getLieferantennummernStatus: () => + api.get('/api/agrarmonitor/lieferantennummern/status').then((r) => r.data), + lieferantennummernVorschau: () => + api + .post('/api/agrarmonitor/lieferantennummern/vorschau', {}, { timeout: 120_000 }) + .then((r) => r.data), + lieferantennummernVergeben: (startNummer: number, nurKundeId?: number) => + api + .post( + '/api/agrarmonitor/lieferantennummern/vergeben', + { startNummer, nurKundeId }, + { timeout: 120_000 }, + ) + .then((r) => r.data), }; diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index 0dde5f0..6b07a36 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -23,6 +23,8 @@ import { agrarmonitorApi, type AgrarmonitorStatusData, type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult, type SyncConflict, + type LieferantennummerVorschau, type LieferantennummerStatus, type LieferantennummerEintrag, + type LieferantennummerEintragStatus, type SettingClientIdentifier, type ClientIdentifierTyp, CLIENT_IDENTIFIER_LABELS, } from '../api/settings'; import { clientsApi, type Client } from '../api/inbox'; @@ -2778,6 +2780,8 @@ function AgrarmonitorTab() { + + Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig", @@ -2812,6 +2816,214 @@ function AgrarmonitorTab() { } +const LIEFERANTENNUMMER_STATUS: Record = { + vergeben: { label: 'vergeben', color: 'success' }, + uebersprungen: { label: 'übersprungen', color: 'default' }, + pruefen: { label: 'zu prüfen', color: 'warning' }, + fehler: { label: 'Fehler', color: 'error' }, +}; + +function fehlermeldung(err: unknown, fallback: string): string { + const msg = (err as { response?: { data?: { message?: unknown } } })?.response?.data?.message; + return typeof msg === 'string' && msg ? msg : fallback; +} + +function LieferantennummernCard() { + const [status, setStatus] = useState(null); + const [vorschau, setVorschau] = useState(null); + const [vorschauLaedt, setVorschauLaedt] = useState(false); + const [startet, setStartet] = useState(false); + const [einzelKundeId, setEinzelKundeId] = useState(undefined); + + const ladeStatus = useCallback(async () => { + try { + setStatus(await agrarmonitorApi.getLieferantennummernStatus()); + } catch { + // Status ist nur Anzeige – beim nächsten Intervall erneut versuchen + } + }, []); + + useEffect(() => { + ladeStatus(); + }, [ladeStatus]); + + // Solange ein Lauf aktiv ist, den Fortschritt abfragen + useEffect(() => { + if (!status?.aktiv) return; + const timer = setInterval(ladeStatus, 2000); + return () => clearInterval(timer); + }, [status?.aktiv, ladeStatus]); + + const laufAktiv = !!status?.aktiv || startet; + + const handleVorschau = async () => { + setVorschauLaedt(true); + try { + const data = await agrarmonitorApi.lieferantennummernVorschau(); + setEinzelKundeId(undefined); + setVorschau(data); + } catch (err) { + message.error(fehlermeldung(err, 'Lieferantenliste konnte nicht geladen werden')); + } finally { + setVorschauLaedt(false); + } + }; + + const handleVergeben = async (nurKundeId?: number) => { + if (!vorschau) return; + setStartet(true); + try { + setStatus(await agrarmonitorApi.lieferantennummernVergeben(vorschau.naechsteNummer, nurKundeId)); + setVorschau(null); + } catch (err) { + message.error(fehlermeldung(err, 'Vergabe konnte nicht gestartet werden')); + } finally { + setStartet(false); + } + }; + + const lauf = status?.lauf; + const einzelKandidat = vorschau?.kandidaten.find(k => k.kundeId === einzelKundeId); + + return ( + + + Vergibt allen aktiven Lieferanten in Agrarmonitor ohne Lieferantennummer eine fortlaufende + Nummer (Schema 1555xxxx, ab der höchsten vergebenen Nummer). Vor dem Schreiben wird eine + Vorschau angezeigt. + + + + + {lauf && ( +
+ {status?.aktiv && ( + + )} + {!status?.aktiv && lauf.abgebrochen && ( + + )} +
+ {lauf.vergeben} vergeben + {lauf.uebersprungen} übersprungen + {lauf.zuPruefen} zu prüfen + {lauf.fehler > 0 && {lauf.fehler} Fehler} + + {lauf.modus === 'einzeln' ? 'Einzeltest' : 'Alle'} · {dayjs(lauf.gestartetAm).format('DD.MM.YYYY HH:mm')} + +
+ {lauf.eintraege.length > 0 && ( + + size="small" + rowKey="kundeId" + dataSource={lauf.eintraege} + pagination={lauf.eintraege.length > 10 ? { pageSize: 10, size: 'small' } : false} + columns={[ + { title: 'Lieferant', dataIndex: 'name' }, + { + title: 'Status', + dataIndex: 'status', + width: 110, + render: (st: LieferantennummerEintragStatus) => ( + {LIEFERANTENNUMMER_STATUS[st].label} + ), + }, + { title: 'Nummer', dataIndex: 'nummer', width: 100 }, + { title: 'Hinweis', dataIndex: 'hinweis' }, + ]} + /> + )} +
+ )} +
+ + setVorschau(null)} + footer={[ + , + , + , + ]} + > + {vorschau && ( + + + Höchste vergebene Nummer: {vorschau.maximum} · nächste freie + Nummer: {vorschau.naechsteNummer} · {vorschau.kandidaten.length}{' '} + aktive Lieferanten ohne Nummer + + + Werden Lieferanten übersprungen oder als „zu prüfen“ gemeldet, rücken die folgenden Nummern + nach, damit keine Lücken entstehen. + + 10 ? { pageSize: 10, size: 'small' } : false} + columns={[ + { title: 'Lieferant', dataIndex: 'name' }, + { title: 'Kunden-ID', dataIndex: 'kundeId', width: 100 }, + { title: 'Neue Nummer', dataIndex: 'neueNummer', width: 120 }, + ]} + /> + + Test: nur einen Lieferanten +