feat(agrarmonitor): fehlende Lieferantennummern vergeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 1m1s
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:
1 parent
029009182a
commit
3d4125b29a
11 files changed
+1507
-3
No files matched your search
Generated
+285
@@ -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",
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in new issue
Block a user