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 1 | 12345 | Ort | ` +
+ ` | ${aktiv ? AKTIV : ''} |
`
+ );
+}
+
+function liste(...zeilen: string[]): string {
+ return (
+ '| Nr |
' +
+ zeilen.join('') +
+ '
'
+ );
+}
+
+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
+