feat(agrarmonitor): fehlende Lieferantennummern vergeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 1m1s

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 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-09-28 08:51:13 +02:00
1 parent 029009182a
commit 3d4125b29a
11 files changed
+1507 -3

No files matched your search

+285
View File
@@ -23,6 +23,7 @@
"agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git", "agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git",
"axios": "^1.14.0", "axios": "^1.14.0",
"basic-ftp": "^5.2.1", "basic-ftp": "^5.2.1",
"cheerio": "^1.2.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"fast-xml-parser": "^5.11.1", "fast-xml-parser": "^5.11.1",
@@ -5543,6 +5544,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/brace-expansion": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -5802,6 +5809,109 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -6200,6 +6310,22 @@
"node": ">= 8" "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": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "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": "^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": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -6582,6 +6720,31 @@
"node": ">=8.10.0" "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": { "node_modules/enhanced-resolve": {
"version": "5.20.1", "version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -6596,6 +6759,18 @@
"node": ">=10.13.0" "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": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -10057,6 +10232,18 @@
"node": ">=8" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "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" "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": { "node_modules/parse5/node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -13239,6 +13499,31 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/whatwg-mimetype": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+1
View File
@@ -38,6 +38,7 @@
"agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git", "agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git",
"axios": "^1.14.0", "axios": "^1.14.0",
"basic-ftp": "^5.2.1", "basic-ftp": "^5.2.1",
"cheerio": "^1.2.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"fast-xml-parser": "^5.11.1", "fast-xml-parser": "^5.11.1",
@@ -16,7 +16,7 @@ const DOCS_PAGE_SIZE = 500;
const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de'; const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de';
/** Fehlermeldung inkl. aufgerufener Seite (Methode + URL), falls es ein HTTP-Fehler ist. */ /** 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); const message = err instanceof Error ? err.message : String(err);
if (!axios.isAxiosError(err) || !err.config) return message; if (!axios.isAxiosError(err) || !err.config) return message;
const method = (err.config.method ?? 'get').toUpperCase(); const method = (err.config.method ?? 'get').toUpperCase();
@@ -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 { AgrarmonitorService } from './agrarmonitor.service';
import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; import { AgrarmonitorPollingService } from './agrarmonitor-polling.service';
import { LieferantennummernService } from './lieferantennummern.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -9,6 +18,7 @@ export class AgrarmonitorController {
constructor( constructor(
private readonly service: AgrarmonitorService, private readonly service: AgrarmonitorService,
private readonly pollingService: AgrarmonitorPollingService, private readonly pollingService: AgrarmonitorPollingService,
private readonly lieferantennummernService: LieferantennummernService,
) {} ) {}
@Get('status') @Get('status')
@@ -86,4 +96,37 @@ export class AgrarmonitorController {
) { ) {
return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId); 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);
}
} }
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorService } from './agrarmonitor.service';
import { AgrarmonitorPollingService } from './agrarmonitor-polling.service'; import { AgrarmonitorPollingService } from './agrarmonitor-polling.service';
import { AgrarmonitorController } from './agrarmonitor.controller'; import { AgrarmonitorController } from './agrarmonitor.controller';
import { LieferantennummernService } from './lieferantennummern.service';
import { PaperlessModule } from '../paperless/paperless.module'; import { PaperlessModule } from '../paperless/paperless.module';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity'; import { Client } from '../database/entities/client.entity';
@@ -13,7 +14,11 @@ import { CorrespondentSetting } from '../database/entities/correspondent-setting
TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]), TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]),
PaperlessModule, PaperlessModule,
], ],
providers: [AgrarmonitorService, AgrarmonitorPollingService], providers: [
AgrarmonitorService,
AgrarmonitorPollingService,
LieferantennummernService,
],
controllers: [AgrarmonitorController], controllers: [AgrarmonitorController],
exports: [AgrarmonitorService], exports: [AgrarmonitorService],
}) })
@@ -0,0 +1,196 @@
import {
baueFormData,
bestimmeKandidaten,
hoechsteLieferantennummer,
lieferantennummerAusDetail,
parseLieferantenListe,
} from './lieferantennummern.parser';
const AKTIV = '<i class="fa fa-check fa-lg"></i>';
function zeile(
id: number,
nummer: string,
firma: string,
name: string,
aktiv: boolean,
): string {
return (
`<tr id="${id}" data-id="${id}">` +
`<td>${nummer}</td><td>${firma}</td><td>${name}</td>` +
`<td>Straße 1</td><td>12345</td><td>Ort</td>` +
`<td><input type="checkbox"></td><td>${aktiv ? AKTIV : ''}</td></tr>`
);
}
function liste(...zeilen: string[]): string {
return (
'<div><table id="kunden_table"><thead><tr><th>Nr</th></tr></thead><tbody>' +
zeilen.join('') +
'</tbody></table></div>'
);
}
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('<html><body>Login</body></html>'),
).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 = `
<html><body>
<form id="form_detail">
<input type="text" name="kundennummer" id="kundennummer" value="21207">
<input type="text" name="lieferantennummer" id="lieferantennummer" value="">
<input type="hidden" name="action_on_save" value="dieser_kunde">
<input type="number" name="debitorennummer" value="0">
<input type="password" name="geheim" value="x">
<input type="text" name="deaktiviert" value="nein" disabled>
<input name="ohne_typ" value="nein">
<input type="button" name="knopf" value="nein">
<input type="email" name="email_feld" value="nein">
<input type="checkbox" name="ist_kunde" id="ist_kunde" value="1">
<input type="checkbox" name="ist_lieferant" id="ist_lieferant" value="1" checked>
<input type="checkbox" name="gesperrt" value="1" checked disabled>
<input type="checkbox" name="kundengruppen_auswahl" value="3" checked>
<input type="checkbox" name="kundengruppen_auswahl" value="4">
<input type="checkbox" name="kundengruppen_auswahl" value="7" checked>
<input type="checkbox" name="lieferantengruppen_auswahl" value="9" checked>
<input type="checkbox" name="farben" value="rot" checked>
<input type="checkbox" name="farben" value="blau">
<textarea name="notiz">Zeile 1
Zeile 2</textarea>
<textarea name="alt" disabled>nein</textarea>
<select name="land_iso_3166">
<option value="AT">Österreich</option>
<option value="DE" selected>Deutschland</option>
</select>
<select name="zahlungsbedingung">
<option value="0">Keine</option>
<option value="5">5 Tage</option>
</select>
<table id="eigenschaftenTable"><tbody>
<tr><td><input id="eig_12" value="abc"></td></tr>
<tr><td><input id="eig_13" value=""></td></tr>
</tbody></table>
</form>
</body></html>`;
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 =
'<form id="form_detail"><input type="text" name="lieferantennummer" id="lieferantennummer" value=""></form>';
expect(baueFormData(html, '15550706')).toEqual({
lieferantennummer: '15550706',
kundengruppen_auswahl: [],
lieferantengruppen_auswahl: [],
arbeitsgebiet_id: [],
eigenschaften: {},
});
});
it('wirft, wenn das Formular fehlt', () => {
expect(() => baueFormData('<html></html>', '1')).toThrow(/form_detail/);
});
});
describe('lieferantennummerAusDetail', () => {
it('liest #lieferantennummer getrimmt', () => {
const html =
'<form id="form_detail"><input id="lieferantennummer" name="lieferantennummer" value=" 15550706 "></form>';
expect(lieferantennummerAusDetail(html)).toBe('15550706');
});
it('wirft, wenn das Feld fehlt', () => {
expect(() => lieferantennummerAusDetail('<html></html>')).toThrow(
/lieferantennummer/,
);
});
});
@@ -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<string, string>;
export type KundeFormData = Record<string, FormDataWert>;
/** Agrarmonitor zeigt leere Zellen als "-". */
function zellText(td: Cheerio<Element>): 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<ListenLieferant, 'firma' | 'name'>,
): 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/<id>. */
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<Element>): boolean {
return el.attr('disabled') !== undefined;
}
function istChecked(el: Cheerio<Element>): 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<string, Cheerio<Element>[]>();
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<string, string> = {};
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;
}
@@ -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<string, unknown>;
}
function detail(nummer: string): string {
return (
'<form id="form_detail">' +
'<input type="text" name="kundennummer" id="kundennummer" value="21207">' +
`<input type="text" name="lieferantennummer" id="lieferantennummer" value="${nummer}">` +
'<input type="text" name="firma" value="Firma">' +
'<input type="checkbox" name="ist_kunde" value="1">' +
'<input type="checkbox" name="ist_lieferant" value="1" checked>' +
'</form>'
);
}
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<number, string>;
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) ? '<div>Ähnlich</div>' : '',
});
}
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');
});
});
@@ -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<LieferantennummerVorschau> {
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<LieferantennummerStatus> {
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<void> {
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<AxiosInstance> {
return (await this.agrarmonitorService.getClient()).http;
}
private async ermittleStand(
http: AxiosInstance,
): Promise<{ maximum: number; kandidaten: ListenLieferant[] }> {
const response = await http.get<string>(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<string> {
const response = await http.get<string>(`/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<string, FormDataWert>,
): Promise<boolean> {
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<string>(
'/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<void> {
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)}`,
);
}
}
}
+54
View File
@@ -258,6 +258,46 @@ export interface SyncCorrespondentsResult {
conflicts: SyncConflict[]; 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 = { export const agrarmonitorApi = {
getStatus: () => getStatus: () =>
api.get<AgrarmonitorStatusData>('/api/agrarmonitor/status').then((r) => r.data), api.get<AgrarmonitorStatusData>('/api/agrarmonitor/status').then((r) => r.data),
@@ -277,4 +317,18 @@ export const agrarmonitorApi = {
api.post<SyncCorrespondentsResult>('/api/agrarmonitor/sync-correspondents').then((r) => r.data), api.post<SyncCorrespondentsResult>('/api/agrarmonitor/sync-correspondents').then((r) => r.data),
mergeCorrespondents: (keepId: number, deleteId: number) => mergeCorrespondents: (keepId: number, deleteId: number) =>
api.post<{ mergedDocuments: number }>('/api/agrarmonitor/merge-correspondents', { keepId, deleteId }).then((r) => r.data), api.post<{ mergedDocuments: number }>('/api/agrarmonitor/merge-correspondents', { keepId, deleteId }).then((r) => r.data),
getLieferantennummernStatus: () =>
api.get<LieferantennummerStatus>('/api/agrarmonitor/lieferantennummern/status').then((r) => r.data),
lieferantennummernVorschau: () =>
api
.post<LieferantennummerVorschau>('/api/agrarmonitor/lieferantennummern/vorschau', {}, { timeout: 120_000 })
.then((r) => r.data),
lieferantennummernVergeben: (startNummer: number, nurKundeId?: number) =>
api
.post<LieferantennummerStatus>(
'/api/agrarmonitor/lieferantennummern/vergeben',
{ startNummer, nurKundeId },
{ timeout: 120_000 },
)
.then((r) => r.data),
}; };
@@ -23,6 +23,8 @@ import {
agrarmonitorApi, type AgrarmonitorStatusData, agrarmonitorApi, type AgrarmonitorStatusData,
type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult, type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult,
type SyncConflict, type SyncConflict,
type LieferantennummerVorschau, type LieferantennummerStatus, type LieferantennummerEintrag,
type LieferantennummerEintragStatus,
type SettingClientIdentifier, type ClientIdentifierTyp, CLIENT_IDENTIFIER_LABELS, type SettingClientIdentifier, type ClientIdentifierTyp, CLIENT_IDENTIFIER_LABELS,
} from '../api/settings'; } from '../api/settings';
import { clientsApi, type Client } from '../api/inbox'; import { clientsApi, type Client } from '../api/inbox';
@@ -2778,6 +2780,8 @@ function AgrarmonitorTab() {
</Space> </Space>
</Card> </Card>
<LieferantennummernCard />
<Card size="small" title="Dokumenten-Verarbeitung"> <Card size="small" title="Dokumenten-Verarbeitung">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig", Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig",
@@ -2812,6 +2816,214 @@ function AgrarmonitorTab() {
} }
const LIEFERANTENNUMMER_STATUS: Record<LieferantennummerEintragStatus, { label: string; color: string }> = {
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<LieferantennummerStatus | null>(null);
const [vorschau, setVorschau] = useState<LieferantennummerVorschau | null>(null);
const [vorschauLaedt, setVorschauLaedt] = useState(false);
const [startet, setStartet] = useState(false);
const [einzelKundeId, setEinzelKundeId] = useState<number | undefined>(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 (
<Card size="small" title="Lieferantennummern überprüfen">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
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.
</Typography.Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={vorschauLaedt} disabled={laufAktiv} onClick={handleVorschau}>
Fehlende Lieferantennummern vergeben
</Button>
{lauf && (
<div>
{status?.aktiv && (
<Alert
type="info"
showIcon
style={{ marginBottom: 8 }}
message={`Vergabe läuft … ${lauf.eintraege.length} von ${lauf.gesamt} bearbeitet`}
/>
)}
{!status?.aktiv && lauf.abgebrochen && (
<Alert
type="error"
showIcon
style={{ marginBottom: 8 }}
message="Lauf wegen eines Fehlers abgebrochen"
/>
)}
<div style={{ marginBottom: 8 }}>
<Tag color="success">{lauf.vergeben} vergeben</Tag>
<Tag>{lauf.uebersprungen} übersprungen</Tag>
<Tag color="warning">{lauf.zuPruefen} zu prüfen</Tag>
{lauf.fehler > 0 && <Tag color="error">{lauf.fehler} Fehler</Tag>}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{lauf.modus === 'einzeln' ? 'Einzeltest' : 'Alle'} · {dayjs(lauf.gestartetAm).format('DD.MM.YYYY HH:mm')}
</Typography.Text>
</div>
{lauf.eintraege.length > 0 && (
<Table<LieferantennummerEintrag>
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) => (
<Tag color={LIEFERANTENNUMMER_STATUS[st].color}>{LIEFERANTENNUMMER_STATUS[st].label}</Tag>
),
},
{ title: 'Nummer', dataIndex: 'nummer', width: 100 },
{ title: 'Hinweis', dataIndex: 'hinweis' },
]}
/>
)}
</div>
)}
</Space>
<Modal
open={!!vorschau}
title="Lieferantennummern vergeben"
width={720}
onCancel={() => setVorschau(null)}
footer={[
<Button key="abbrechen" onClick={() => setVorschau(null)}>
Abbrechen
</Button>,
<Button
key="einzeln"
disabled={!einzelKandidat}
loading={startet}
onClick={() => handleVergeben(einzelKundeId)}
>
Nur diesen vergeben
</Button>,
<Button
key="alle"
type="primary"
disabled={!vorschau?.kandidaten.length}
loading={startet}
onClick={() => handleVergeben()}
>
Jetzt vergeben
</Button>,
]}
>
{vorschau && (
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text>
Höchste vergebene Nummer: <strong>{vorschau.maximum}</strong> · nächste freie
Nummer: <strong>{vorschau.naechsteNummer}</strong> · <strong>{vorschau.kandidaten.length}</strong>{' '}
aktive Lieferanten ohne Nummer
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Werden Lieferanten übersprungen oder als „zu prüfen“ gemeldet, rücken die folgenden Nummern
nach, damit keine Lücken entstehen.
</Typography.Text>
<Table
size="small"
rowKey="kundeId"
dataSource={vorschau.kandidaten}
pagination={vorschau.kandidaten.length > 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 },
]}
/>
<Divider style={{ margin: '4px 0' }} />
<Typography.Text strong>Test: nur einen Lieferanten</Typography.Text>
<Select
showSearch
allowClear
placeholder="Lieferant auswählen"
style={{ width: '100%' }}
value={einzelKundeId}
onChange={setEinzelKundeId}
optionFilterProp="label"
options={vorschau.kandidaten.map(k => ({ value: k.kundeId, label: `${k.name} (${k.kundeId})` }))}
/>
{einzelKandidat && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{einzelKandidat.name} erhält die Nummer {vorschau.naechsteNummer}.
</Typography.Text>
)}
</Space>
)}
</Modal>
</Card>
);
}
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Steuertags Tab // Steuertags Tab
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════