fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s
Die tägliche E-Mail-Zusammenfassung zeigte Nutzern Bereiche, für die sie
keine Berechtigung haben. Zwei unabhängige Ursachen:
- Das Backend prüfte weiterhin die Altgruppe PM_Belege. Die Umbenennung
zu PM_Buchhaltung (ca1d371) war nur im Frontend angekommen, weshalb
der Digest "Manuell bearbeiten" und "In Agrarmonitor" anbot, während
die Oberfläche beide Bereiche sperrte.
- Der Cron-Versand wertet die Gruppen aus user_settings aus. Diese Spalte
wurde nur beim Aufruf der Benutzereinstellungen gefüllt; entzogene
Berechtigungen erreichten den Digest daher unter Umständen nie.
Behoben durch Angleichen des Gruppen-Mappings und den neuen
UserIdentitySyncService, der E-Mail, Benutzername und Gruppen bei jedem
authentifizierten Request aus dem Token spiegelt – ohne den Request zu
blockieren und ohne DB-Zugriff, solange sich das Token nicht ändert. Die
doppelte Identitätspflege im UserSettingsService entfällt.
WebDAV wird nicht eingesetzt und ist entfernt; Export-Ziele bieten nur
noch FTP. Damit verschwindet das ESM-Paket webdav, an dem zwei
Jest-Suites bereits beim Parsen scheiterten.
Veraltete Tests instand gesetzt: email.controller und settings.controller
mockten weniger Abhängigkeiten, als die Klassen inzwischen haben;
postprocessing.service.spec beschrieb noch das alte Regelmodell mit
Einzelfeldern statt FilterJson und ist gegen die heutige Filter-Engine
neu geschrieben (AND/OR, verschachtelte Gruppen, Fehlerprotokollierung).
Enthält außerdem den Arbeitsstand der E-Rechnungs-Mandantenzuordnung, da
sich beide Änderungen dieselben Dateien teilen (SettingsPage, package.json).
98 Tests in 12 Suites grün, Backend- und Frontend-Build sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,14 @@ Decorate controllers/handlers with:
|
||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||
```
|
||||
|
||||
Permissions map from OIDC groups (`PM_Admin`, `PM_Belege`, etc.) to the `Permission` enum in `src/auth/permissions.enum.ts`.
|
||||
Permissions map from OIDC groups (`PM_Admin`, `PM_Buchhaltung`, `PM_Posteingang`, etc.) to the
|
||||
`Permission` enum in `src/auth/permissions.enum.ts`. **This mapping is duplicated in
|
||||
`paperless-frontend/src/auth/permissions.ts` and both copies must list the same group names** —
|
||||
otherwise the daily digest offers sections the UI denies. `permissions.enum.spec.ts` guards this.
|
||||
|
||||
`user_settings.UserGroups` mirrors the token's groups so tokenless background jobs (daily digest)
|
||||
can evaluate permissions. `UserIdentitySyncService` refreshes it from `JwtStrategy.validate()` on
|
||||
every authenticated request — do not re-introduce ad-hoc writes to those identity columns.
|
||||
|
||||
Use `@Public()` to bypass auth guards entirely.
|
||||
|
||||
|
||||
Generated
+77
-260
@@ -25,6 +25,7 @@
|
||||
"basic-ftp": "^5.2.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"form-data": "^4.0.5",
|
||||
"imapflow": "^1.3.2",
|
||||
"jsqr": "^1.4.0",
|
||||
@@ -40,8 +41,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.9.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -802,15 +802,6 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@buttercup/fetch": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz",
|
||||
"integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"node-fetch": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -3273,6 +3264,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nuxt/opencollective": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
|
||||
@@ -5039,6 +5042,18 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/app-root-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
|
||||
@@ -5256,12 +5271,6 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
|
||||
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -5478,12 +5487,6 @@
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/byte-length": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz",
|
||||
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -5614,15 +5617,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/charenc": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -6021,15 +6015,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -6043,15 +6028,6 @@
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -6435,18 +6411,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"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/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
@@ -6925,9 +6889,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6936,13 +6900,14 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.11",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz",
|
||||
"integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==",
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
|
||||
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6951,9 +6916,12 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.4.0",
|
||||
"strnum": "^2.2.3"
|
||||
"@nodable/entities": "^3.0.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^2.0.0",
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"strnum": "^2.4.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -6987,29 +6955,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -7229,18 +7174,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
@@ -7629,12 +7562,6 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hot-patcher": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz",
|
||||
"integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
@@ -7908,12 +7835,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -8047,6 +7968,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
@@ -9185,12 +9118,6 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/layerr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz",
|
||||
"integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
@@ -9567,17 +9494,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/md5": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
|
||||
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"charenc": "0.0.2",
|
||||
"crypt": "0.0.2",
|
||||
"is-buffer": "~1.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -9893,12 +9809,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nested-property": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz",
|
||||
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||
@@ -9906,26 +9816,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-emoji": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||
@@ -9936,24 +9826,6 @@
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -10286,9 +10158,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz",
|
||||
"integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==",
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10319,12 +10191,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-posix": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz",
|
||||
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
@@ -11664,16 +11530,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
||||
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.3.5",
|
||||
@@ -12828,15 +12697,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-join": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz",
|
||||
"integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
@@ -12952,64 +12812,6 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webdav": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.9.0.tgz",
|
||||
"integrity": "sha512-OMJ6wtK1WvCO++aOLoQgE96S8KT4e5aaClWHmHXfFU369r4eyELN569B7EqT4OOUb99mmO58GkyuiCv/Ag6J0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@buttercup/fetch": "^0.2.1",
|
||||
"base-64": "^1.0.0",
|
||||
"byte-length": "^1.0.2",
|
||||
"entities": "^6.0.1",
|
||||
"fast-xml-parser": "^5.3.4",
|
||||
"hot-patcher": "^2.0.1",
|
||||
"layerr": "^3.0.0",
|
||||
"md5": "^2.3.0",
|
||||
"minimatch": "^9.0.5",
|
||||
"nested-property": "^4.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"path-posix": "^1.0.0",
|
||||
"url-join": "^5.0.0",
|
||||
"url-parse": "^1.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/webdav/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webdav/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
@@ -13363,6 +13165,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"basic-ftp": "^5.2.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"fast-xml-parser": "^5.11.1",
|
||||
"form-data": "^4.0.5",
|
||||
"imapflow": "^1.3.2",
|
||||
"jsqr": "^1.4.0",
|
||||
@@ -55,8 +56,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.9.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
|
||||
import { FreigabeModule } from './freigabe/freigabe.module';
|
||||
import { ZahlungModule } from './zahlung/zahlung.module';
|
||||
import { DailyDigestModule } from './daily-digest/daily-digest.module';
|
||||
import { ErechnungModule } from './erechnung/erechnung.module';
|
||||
import * as path from 'path';
|
||||
|
||||
@Module({
|
||||
@@ -55,6 +56,7 @@ import * as path from 'path';
|
||||
FreigabeModule,
|
||||
ZahlungModule,
|
||||
DailyDigestModule,
|
||||
ErechnungModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -10,11 +10,13 @@ import { ApiKeyGuard } from './api-key.guard';
|
||||
import { JwtOrApiKeyGuard } from './jwt-or-apikey.guard';
|
||||
import { ApiKey } from '../database/entities/api-key.entity';
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
TypeOrmModule.forFeature([ApiKey]),
|
||||
UserSettingsModule,
|
||||
],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [
|
||||
|
||||
@@ -4,10 +4,14 @@ import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { passportJwtSecret } from 'jwks-rsa';
|
||||
import { mapGroupsToPermissions } from './permissions.enum';
|
||||
import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly identitySync: UserIdentitySyncService,
|
||||
) {
|
||||
const issuer = configService.get<string>('OIDC_ISSUER', '');
|
||||
|
||||
super({
|
||||
@@ -33,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
permissions: any[];
|
||||
} {
|
||||
const groups = payload.groups || [];
|
||||
return {
|
||||
const user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name || payload.preferred_username,
|
||||
@@ -41,5 +45,11 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
groups: groups,
|
||||
permissions: mapGroupsToPermissions(groups),
|
||||
};
|
||||
|
||||
// Gruppen aus dem Token in die DB spiegeln, damit tokenlose Hintergrund-
|
||||
// jobs (z. B. der tägliche Digest) mit aktuellen Berechtigungen arbeiten.
|
||||
this.identitySync.syncInBackground(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { mapGroupsToPermissions, Permission } from './permissions.enum';
|
||||
|
||||
describe('mapGroupsToPermissions', () => {
|
||||
it('gibt für fehlende oder leere Gruppen keine Berechtigungen zurück', () => {
|
||||
expect(mapGroupsToPermissions(undefined)).toEqual([]);
|
||||
expect(mapGroupsToPermissions(null)).toEqual([]);
|
||||
expect(mapGroupsToPermissions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('gibt PM_Admin alle Berechtigungen', () => {
|
||||
const permissions = mapGroupsToPermissions(['PM_Admin']);
|
||||
expect(permissions).toEqual(
|
||||
expect.arrayContaining(Object.values(Permission)),
|
||||
);
|
||||
});
|
||||
|
||||
it('mappt PM_Buchhaltung auf PROCESS_MANUALLY', () => {
|
||||
expect(mapGroupsToPermissions(['PM_Buchhaltung'])).toEqual([
|
||||
Permission.PROCESS_MANUALLY,
|
||||
]);
|
||||
});
|
||||
|
||||
it('erkennt die umbenannte Altgruppe PM_Belege nicht mehr', () => {
|
||||
expect(mapGroupsToPermissions(['PM_Belege'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('vergibt keine Berechtigung für unbekannte Gruppen', () => {
|
||||
expect(mapGroupsToPermissions(['irgendwas'])).toEqual([]);
|
||||
});
|
||||
|
||||
// Läuft das Gruppen-Mapping zwischen Backend und Frontend auseinander, sieht
|
||||
// ein Nutzer in der Tagesübersicht Bereiche, die ihm die Oberfläche verwehrt.
|
||||
it('verwendet dieselben Gruppennamen wie das Frontend', () => {
|
||||
const frontendFile = join(
|
||||
__dirname,
|
||||
'../../../paperless-frontend/src/auth/permissions.ts',
|
||||
);
|
||||
if (!existsSync(frontendFile)) {
|
||||
// Im Backend-Container liegt das Frontend nicht vor.
|
||||
return;
|
||||
}
|
||||
const groupNames = (source: string) =>
|
||||
[...source.matchAll(/groups\.includes\('([^']+)'\)/g)]
|
||||
.map((m) => m[1])
|
||||
.sort();
|
||||
|
||||
const backend = groupNames(
|
||||
readFileSync(join(__dirname, 'permissions.enum.ts'), 'utf8'),
|
||||
);
|
||||
const frontend = groupNames(readFileSync(frontendFile, 'utf8'));
|
||||
expect(backend).toEqual(frontend);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,9 @@ export function mapGroupsToPermissions(
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
if (groups.includes('PM_Belege'))
|
||||
// Gruppe wurde in Authentik von 'PM_Belege' zu 'PM_Buchhaltung' umbenannt.
|
||||
// Muss identisch zu paperless-frontend/src/auth/permissions.ts bleiben.
|
||||
if (groups.includes('PM_Buchhaltung'))
|
||||
permissions.add(Permission.PROCESS_MANUALLY);
|
||||
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
|
||||
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Kontonummer,
|
||||
Document,
|
||||
UserClient,
|
||||
ClientIdentifier,
|
||||
Email,
|
||||
Attachment,
|
||||
Content,
|
||||
@@ -47,6 +48,7 @@ export const entities = [
|
||||
Kontonummer,
|
||||
Document,
|
||||
UserClient,
|
||||
ClientIdentifier,
|
||||
Email,
|
||||
Attachment,
|
||||
Content,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Kennungsarten, über die ein Mandant in einer E-Rechnung erkannt werden kann.
|
||||
* Bewusst nur "starke" Kennungen – Name und E-Mail sind zu unscharf, um sie
|
||||
* dauerhaft festzuschreiben.
|
||||
*/
|
||||
export type ClientIdentifierTyp = 'ustid' | 'leitwegid' | 'kaeuferkennung';
|
||||
|
||||
export const CLIENT_IDENTIFIER_TYPEN: ClientIdentifierTyp[] = [
|
||||
'ustid',
|
||||
'leitwegid',
|
||||
'kaeuferkennung',
|
||||
];
|
||||
|
||||
/** Zuordnung einer Käufer-Kennung aus dem E-Rechnungs-XML zu einem Mandanten. */
|
||||
@Entity('ClientIdentifiers')
|
||||
@Index('IX_ClientIdentifiers_TypWert', ['Typ', 'Wert'], { unique: true })
|
||||
export class ClientIdentifier {
|
||||
@PrimaryGeneratedColumn()
|
||||
Id!: number;
|
||||
|
||||
@Index('IX_ClientIdentifiers_ClientId')
|
||||
@Column({ type: 'int' })
|
||||
ClientId!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
Typ!: ClientIdentifierTyp;
|
||||
|
||||
/** Normalisiert gespeichert: Großschreibung, ohne Leer- und Trennzeichen. */
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
Wert!: string;
|
||||
}
|
||||
@@ -19,4 +19,12 @@ export class DocumentType {
|
||||
|
||||
@Column({ type: 'tinyint', width: 1, nullable: true, default: null })
|
||||
FreigabeErforderlich!: boolean | null;
|
||||
|
||||
/**
|
||||
* Rechnungstyp-Codes (BT-3) einer E-Rechnung, die auf diese Belegart
|
||||
* abgebildet werden – kommagetrennt, z. B. "380,384" für Rechnung, "381"
|
||||
* für Gutschrift. Leer = diese Belegart wird nie vorgeschlagen.
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 100, nullable: true, default: null })
|
||||
ERechnungTypeCodes!: string | null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ExportTarget {
|
||||
Name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
Protocol!: string; // 'ftp' | 'webdav'
|
||||
Protocol!: string; // 'ftp'
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
Host!: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ export { Setting } from './setting.entity';
|
||||
export { Kontonummer } from './kontonummer.entity';
|
||||
export { Document } from './document.entity';
|
||||
export { UserClient } from './user-client.entity';
|
||||
export { ClientIdentifier } from './client-identifier.entity';
|
||||
export { Email } from './email.entity';
|
||||
export { Attachment } from './attachment.entity';
|
||||
export { Content } from './content.entity';
|
||||
|
||||
@@ -10,7 +10,7 @@ export class PostprocessingAction {
|
||||
|
||||
@Column({ type: 'int' })
|
||||
ActionType!: number;
|
||||
// 1 = Export (FTP/WebDAV)
|
||||
// 1 = Export (FTP)
|
||||
// 2 = Mail
|
||||
// 3 = Tag setzen/entfernen
|
||||
// 4 = Custom Field setzen
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Grundlage für den Mandanten-Vorschlag aus E-Rechnungen:
|
||||
*
|
||||
* - `ClientIdentifiers` ordnet Käufer-Kennungen aus dem Rechnungs-XML
|
||||
* (USt-IdNr., Leitweg-ID, Käufer-Kennung) einem Mandanten zu. Der eindeutige
|
||||
* Index über (Typ, Wert) verhindert, dass eine Kennung auf mehrere Mandanten
|
||||
* zeigt.
|
||||
* - `DocumentTypes.ERechnungTypeCodes` bildet den Rechnungstyp (BT-3) auf eine
|
||||
* Belegart ab, z. B. "380,384" für Rechnung und "381" für Gutschrift.
|
||||
*/
|
||||
export class ERechnungMandantenzuordnung1787000000000 implements MigrationInterface {
|
||||
name = 'ERechnungMandantenzuordnung1787000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`ClientIdentifiers\` (
|
||||
\`Id\` int NOT NULL AUTO_INCREMENT,
|
||||
\`ClientId\` int NOT NULL,
|
||||
\`Typ\` varchar(20) NOT NULL,
|
||||
\`Wert\` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (\`Id\`),
|
||||
UNIQUE INDEX \`IX_ClientIdentifiers_TypWert\` (\`Typ\`, \`Wert\`),
|
||||
INDEX \`IX_ClientIdentifiers_ClientId\` (\`ClientId\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`DocumentTypes\`
|
||||
ADD \`ERechnungTypeCodes\` varchar(100) NULL DEFAULT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`DocumentTypes\` DROP COLUMN \`ERechnungTypeCodes\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`ClientIdentifiers\``);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { EmailController } from './email.controller';
|
||||
import { Email } from '../database/entities/email.entity';
|
||||
import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
|
||||
const mockEmails: Partial<Email>[] = [
|
||||
{
|
||||
@@ -26,25 +30,56 @@ const mockEmails: Partial<Email>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockQueryBuilder = {
|
||||
const mockAttachments: Partial<Attachment>[] = [
|
||||
{ Id: 10, EmailMessageId: 1, FileName: 'rechnung.pdf' },
|
||||
];
|
||||
|
||||
describe('EmailController', () => {
|
||||
let controller: EmailController;
|
||||
let queryBuilder: {
|
||||
leftJoinAndSelect: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
take: jest.Mock;
|
||||
where: jest.Mock;
|
||||
getMany: jest.Mock;
|
||||
};
|
||||
let emailRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
findOneOrFail: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let attachmentRepo: { find: jest.Mock; findOne: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
queryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(mockEmails),
|
||||
};
|
||||
|
||||
const mockRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||
findOneByOrFail: jest.fn().mockResolvedValue(mockEmails[0]),
|
||||
emailRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
|
||||
findOneOrFail: jest.fn().mockResolvedValue({ ...mockEmails[0] }),
|
||||
save: jest.fn().mockImplementation((e: Email) => Promise.resolve(e)),
|
||||
};
|
||||
attachmentRepo = {
|
||||
find: jest.fn().mockResolvedValue(mockAttachments),
|
||||
findOne: jest.fn().mockResolvedValue(mockAttachments[0]),
|
||||
};
|
||||
|
||||
describe('EmailController', () => {
|
||||
let controller: EmailController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [EmailController],
|
||||
providers: [{ provide: getRepositoryToken(Email), useValue: mockRepo }],
|
||||
providers: [
|
||||
{ provide: getRepositoryToken(Email), useValue: emailRepo },
|
||||
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
|
||||
{
|
||||
provide: getRepositoryToken(Content),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{ provide: PaperlessService, useValue: {} },
|
||||
{ provide: ImapFolderService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<EmailController>(EmailController);
|
||||
@@ -57,19 +92,52 @@ describe('EmailController', () => {
|
||||
it('getEmails returns list', async () => {
|
||||
const result = await controller.getEmails();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('e.Date', 'DESC');
|
||||
expect(queryBuilder.orderBy).toHaveBeenCalledWith('e.Date', 'DESC');
|
||||
expect(queryBuilder.leftJoinAndSelect).toHaveBeenCalledWith(
|
||||
'e.Attachments',
|
||||
'a',
|
||||
);
|
||||
});
|
||||
|
||||
it('getEmails begrenzt ohne Angabe auf 50 Einträge', async () => {
|
||||
await controller.getEmails();
|
||||
expect(queryBuilder.take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('getEmails filters by status', async () => {
|
||||
await controller.getEmails('1');
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('e.Status = :status', {
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('e.Status = :status', {
|
||||
status: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('getEmails filtert nicht ohne Status', async () => {
|
||||
await controller.getEmails();
|
||||
expect(queryBuilder.where).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getEmail returns single item', async () => {
|
||||
const result = await controller.getEmail('1');
|
||||
expect(result).toEqual(mockEmails[0]);
|
||||
expect(mockRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 });
|
||||
expect(emailRepo.findOneOrFail).toHaveBeenCalledWith({
|
||||
where: { Id: 1 },
|
||||
relations: ['Attachments'],
|
||||
});
|
||||
});
|
||||
|
||||
it('getAttachments lädt die Anhänge einer E-Mail', async () => {
|
||||
const result = await controller.getAttachments('1');
|
||||
expect(result).toEqual(mockAttachments);
|
||||
expect(attachmentRepo.find).toHaveBeenCalledWith({
|
||||
where: { EmailMessageId: 1 },
|
||||
order: { Id: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updateStatus speichert den neuen Status', async () => {
|
||||
await controller.updateStatus('1', 2);
|
||||
expect(emailRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ Id: 1, Status: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { parseERechnung } from './erechnung-parser';
|
||||
|
||||
const CII = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>RE-2024-0815</ram:ID>
|
||||
<ram:TypeCode>380</ram:TypeCode>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20240315</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:BuyerReference>04011000-12345-06</ram:BuyerReference>
|
||||
<ram:SellerTradeParty>
|
||||
<ram:Name>Saatgut Nord GmbH</ram:Name>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="VA">DE111111111</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:SellerTradeParty>
|
||||
<ram:BuyerTradeParty>
|
||||
<ram:ID>K-4711</ram:ID>
|
||||
<ram:Name>Hof Pöttker GbR</ram:Name>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="FC">123/456/789</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="VA">DE987654321</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
<ram:URIUniversalCommunication>
|
||||
<ram:URIID schemeID="EM">buchhaltung@hof-poettker.de</ram:URIID>
|
||||
</ram:URIUniversalCommunication>
|
||||
</ram:BuyerTradeParty>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:GrandTotalAmount>1190.00</ram:GrandTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
const UBL = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:ID>2024-4711</cbc:ID>
|
||||
<cbc:IssueDate>2024-03-15</cbc:IssueDate>
|
||||
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
||||
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cbc:BuyerReference>04011000-12345-06</cbc:BuyerReference>
|
||||
<cac:AccountingSupplierParty>
|
||||
<cac:Party>
|
||||
<cac:PartyLegalEntity><cbc:RegistrationName>Saatgut Nord GmbH</cbc:RegistrationName></cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingSupplierParty>
|
||||
<cac:AccountingCustomerParty>
|
||||
<cac:Party>
|
||||
<cbc:EndpointID schemeID="EM">buchhaltung@hof-poettker.de</cbc:EndpointID>
|
||||
<cac:PartyIdentification><cbc:ID>K-4711</cbc:ID></cac:PartyIdentification>
|
||||
<cac:PartyName><cbc:Name>Hof Pöttker</cbc:Name></cac:PartyName>
|
||||
<cac:PartyTaxScheme>
|
||||
<cbc:CompanyID>DE987654321</cbc:CompanyID>
|
||||
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||
</cac:PartyTaxScheme>
|
||||
<cac:PartyLegalEntity><cbc:RegistrationName>Hof Pöttker GbR</cbc:RegistrationName></cac:PartyLegalEntity>
|
||||
</cac:Party>
|
||||
</cac:AccountingCustomerParty>
|
||||
<cac:LegalMonetaryTotal>
|
||||
<cbc:TaxInclusiveAmount currencyID="EUR">1190.00</cbc:TaxInclusiveAmount>
|
||||
<cbc:PayableAmount currencyID="EUR">1190.00</cbc:PayableAmount>
|
||||
</cac:LegalMonetaryTotal>
|
||||
</Invoice>`;
|
||||
|
||||
const UBL_GUTSCHRIFT = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CreditNote xmlns="urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
|
||||
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:ID>GS-1</cbc:ID>
|
||||
<cbc:CreditNoteTypeCode>381</cbc:CreditNoteTypeCode>
|
||||
</CreditNote>`;
|
||||
|
||||
describe('parseERechnung', () => {
|
||||
it('liest eine CII-Rechnung (ZUGFeRD/Factur-X) vollständig aus', () => {
|
||||
const daten = parseERechnung(CII);
|
||||
|
||||
expect(daten).not.toBeNull();
|
||||
expect(daten!.syntax).toBe('CII');
|
||||
expect(daten!.rechnungsnummer).toBe('RE-2024-0815');
|
||||
expect(daten!.rechnungsdatum).toBe('2024-03-15');
|
||||
expect(daten!.typeCode).toBe('380');
|
||||
expect(daten!.waehrung).toBe('EUR');
|
||||
expect(daten!.gesamtbetrag).toBe(1190);
|
||||
expect(daten!.leitwegId).toBe('04011000-12345-06');
|
||||
expect(daten!.verkaeufer.name).toBe('Saatgut Nord GmbH');
|
||||
expect(daten!.kaeufer.name).toBe('Hof Pöttker GbR');
|
||||
expect(daten!.kaeufer.kennung).toBe('K-4711');
|
||||
expect(daten!.kaeufer.email).toBe('buchhaltung@hof-poettker.de');
|
||||
});
|
||||
|
||||
it('nimmt bei mehreren Steuerregistrierungen die mit schemeID "VA"', () => {
|
||||
// Die Steuernummer (FC) steht im Beispiel vor der USt-IdNr. (VA).
|
||||
expect(parseERechnung(CII)!.kaeufer.ustId).toBe('DE987654321');
|
||||
});
|
||||
|
||||
it('liest eine UBL-Rechnung (XRechnung) aus', () => {
|
||||
const daten = parseERechnung(UBL);
|
||||
|
||||
expect(daten).not.toBeNull();
|
||||
expect(daten!.syntax).toBe('UBL');
|
||||
expect(daten!.rechnungsnummer).toBe('2024-4711');
|
||||
expect(daten!.rechnungsdatum).toBe('2024-03-15');
|
||||
expect(daten!.typeCode).toBe('380');
|
||||
expect(daten!.gesamtbetrag).toBe(1190);
|
||||
expect(daten!.kaeufer.ustId).toBe('DE987654321');
|
||||
expect(daten!.kaeufer.kennung).toBe('K-4711');
|
||||
// BT-44 ist der eingetragene Name, nicht der Handelsname aus PartyName.
|
||||
expect(daten!.kaeufer.name).toBe('Hof Pöttker GbR');
|
||||
});
|
||||
|
||||
it('liest den Typ-Code einer UBL-Gutschrift', () => {
|
||||
expect(parseERechnung(UBL_GUTSCHRIFT)!.typeCode).toBe('381');
|
||||
});
|
||||
|
||||
it('verarbeitet CII und UBL zur selben Käufer-Identität', () => {
|
||||
const cii = parseERechnung(CII)!;
|
||||
const ubl = parseERechnung(UBL)!;
|
||||
expect(cii.kaeufer.ustId).toBe(ubl.kaeufer.ustId);
|
||||
expect(cii.kaeufer.kennung).toBe(ubl.kaeufer.kennung);
|
||||
});
|
||||
|
||||
it('gibt null zurück bei fremdem XML oder kaputtem Inhalt', () => {
|
||||
expect(
|
||||
parseERechnung('<Lieferschein><ID>1</ID></Lieferschein>'),
|
||||
).toBeNull();
|
||||
expect(parseERechnung('kein xml')).toBeNull();
|
||||
expect(parseERechnung(Buffer.from([0x00, 0x01, 0x02]))).toBeNull();
|
||||
});
|
||||
|
||||
it('akzeptiert das XML auch als Buffer', () => {
|
||||
expect(parseERechnung(Buffer.from(CII, 'utf8'))!.rechnungsnummer).toBe(
|
||||
'RE-2024-0815',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export type ERechnungSyntax = 'CII' | 'UBL';
|
||||
|
||||
export interface ERechnungPartei {
|
||||
name: string | null;
|
||||
/** USt-IdNr. (schemeID "VA" bzw. TaxScheme "VAT") */
|
||||
ustId: string | null;
|
||||
/** Kennung der Partei (BT-46 beim Käufer, BT-29 beim Verkäufer) */
|
||||
kennung: string | null;
|
||||
/** Elektronische Adresse (BT-49 bzw. BT-34) */
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ERechnungDaten {
|
||||
syntax: ERechnungSyntax;
|
||||
/** Rechnungsnummer (BT-1) */
|
||||
rechnungsnummer: string | null;
|
||||
/** Rechnungsdatum (BT-2) als ISO-Datum */
|
||||
rechnungsdatum: string | null;
|
||||
/** Rechnungstyp (BT-3), z. B. "380" = Rechnung, "381" = Gutschrift */
|
||||
typeCode: string | null;
|
||||
/** Währung (BT-5) */
|
||||
waehrung: string | null;
|
||||
/** Bruttogesamtbetrag (BT-112) */
|
||||
gesamtbetrag: number | null;
|
||||
/** Käuferreferenz (BT-10) – im deutschen B2G die Leitweg-ID */
|
||||
leitwegId: string | null;
|
||||
kaeufer: ERechnungPartei;
|
||||
verkaeufer: ERechnungPartei;
|
||||
}
|
||||
|
||||
// removeNSPrefix schneidet die frei wählbaren Namespace-Präfixe (ram:, rsm:,
|
||||
// cac:, cbc:, …) ab – ohne das wären die Pfade je nach Erzeuger andere.
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
parseTagValue: false,
|
||||
parseAttributeValue: false,
|
||||
trimValues: true,
|
||||
// Ohne htmlEntities bleiben numerische Referenzen (ö) als Text stehen –
|
||||
// ein so verstümmelter Käufername würde den Namensvergleich zerstören.
|
||||
htmlEntities: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Parst ein E-Rechnungs-XML in der Syntax CII (ZUGFeRD/Factur-X/XRechnung-CII)
|
||||
* oder UBL (XRechnung-UBL). Gibt `null` zurück, wenn das XML keine der beiden
|
||||
* Wurzeln trägt oder gar nicht lesbar ist.
|
||||
*/
|
||||
export function parseERechnung(xml: Buffer | string): ERechnungDaten | null {
|
||||
let wurzel: unknown;
|
||||
try {
|
||||
wurzel = parser.parse(Buffer.isBuffer(xml) ? xml.toString('utf8') : xml);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cii = kind(wurzel, 'CrossIndustryInvoice');
|
||||
if (cii) return parseCii(cii);
|
||||
|
||||
const ubl = kind(wurzel, 'Invoice');
|
||||
if (ubl) return parseUbl(ubl, 'Invoice');
|
||||
|
||||
const gutschrift = kind(wurzel, 'CreditNote');
|
||||
if (gutschrift) return parseUbl(gutschrift, 'CreditNote');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// === CII (ZUGFeRD / Factur-X / XRechnung-CII) ===
|
||||
|
||||
function parseCii(inv: unknown): ERechnungDaten {
|
||||
const kopf = kind(inv, 'ExchangedDocument');
|
||||
const transaktion = kind(inv, 'SupplyChainTradeTransaction');
|
||||
const vereinbarung = kind(transaktion, 'ApplicableHeaderTradeAgreement');
|
||||
const abrechnung = kind(transaktion, 'ApplicableHeaderTradeSettlement');
|
||||
const summen = kind(
|
||||
abrechnung,
|
||||
'SpecifiedTradeSettlementHeaderMonetarySummation',
|
||||
);
|
||||
const datum = kind(kopf, 'IssueDateTime');
|
||||
|
||||
return {
|
||||
syntax: 'CII',
|
||||
rechnungsnummer: text(kind(kopf, 'ID')),
|
||||
rechnungsdatum: normalisiereDatum(
|
||||
text(kind(datum, 'DateTimeString')) ?? text(datum),
|
||||
),
|
||||
typeCode: text(kind(kopf, 'TypeCode')),
|
||||
waehrung: text(kind(abrechnung, 'InvoiceCurrencyCode')),
|
||||
gesamtbetrag: zuBetrag(
|
||||
text(kind(summen, 'GrandTotalAmount')) ??
|
||||
text(kind(summen, 'DuePayableAmount')),
|
||||
),
|
||||
leitwegId: text(kind(vereinbarung, 'BuyerReference')),
|
||||
kaeufer: parseCiiPartei(kind(vereinbarung, 'BuyerTradeParty')),
|
||||
verkaeufer: parseCiiPartei(kind(vereinbarung, 'SellerTradeParty')),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCiiPartei(partei: unknown): ERechnungPartei {
|
||||
if (!partei) return leerePartei();
|
||||
|
||||
const ustId = liste(kind(partei, 'SpecifiedTaxRegistration'))
|
||||
.map((r) => kind(r, 'ID'))
|
||||
.find((id) => attribut(id, 'schemeID') === 'VA');
|
||||
|
||||
const kontaktMail = liste(kind(partei, 'DefinedTradeContact'))
|
||||
.map((k) => text(kind(kind(k, 'EmailURIUniversalCommunication'), 'URIID')))
|
||||
.find((m): m is string => !!m);
|
||||
|
||||
return {
|
||||
name: text(kind(partei, 'Name')),
|
||||
ustId: text(ustId),
|
||||
kennung: text(kind(partei, 'ID')) ?? text(kind(partei, 'GlobalID')),
|
||||
email:
|
||||
text(kind(kind(partei, 'URIUniversalCommunication'), 'URIID')) ??
|
||||
kontaktMail ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
// === UBL (XRechnung-UBL) ===
|
||||
|
||||
function parseUbl(
|
||||
inv: unknown,
|
||||
wurzelName: 'Invoice' | 'CreditNote',
|
||||
): ERechnungDaten {
|
||||
const summen = kind(inv, 'LegalMonetaryTotal');
|
||||
|
||||
return {
|
||||
syntax: 'UBL',
|
||||
rechnungsnummer: text(kind(inv, 'ID')),
|
||||
rechnungsdatum: normalisiereDatum(text(kind(inv, 'IssueDate'))),
|
||||
typeCode: text(
|
||||
kind(
|
||||
inv,
|
||||
wurzelName === 'CreditNote' ? 'CreditNoteTypeCode' : 'InvoiceTypeCode',
|
||||
),
|
||||
),
|
||||
waehrung: text(kind(inv, 'DocumentCurrencyCode')),
|
||||
gesamtbetrag: zuBetrag(
|
||||
text(kind(summen, 'TaxInclusiveAmount')) ??
|
||||
text(kind(summen, 'PayableAmount')),
|
||||
),
|
||||
leitwegId: text(kind(inv, 'BuyerReference')),
|
||||
kaeufer: parseUblPartei(
|
||||
kind(kind(inv, 'AccountingCustomerParty'), 'Party'),
|
||||
),
|
||||
verkaeufer: parseUblPartei(
|
||||
kind(kind(inv, 'AccountingSupplierParty'), 'Party'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseUblPartei(partei: unknown): ERechnungPartei {
|
||||
if (!partei) return leerePartei();
|
||||
|
||||
const ustId = liste(kind(partei, 'PartyTaxScheme'))
|
||||
.filter(
|
||||
(s) =>
|
||||
(text(kind(kind(s, 'TaxScheme'), 'ID')) ?? '').toUpperCase() === 'VAT',
|
||||
)
|
||||
.map((s) => kind(s, 'CompanyID'))[0];
|
||||
|
||||
const rechtseinheit = kind(partei, 'PartyLegalEntity');
|
||||
|
||||
return {
|
||||
// BT-44/BT-27 ist der eingetragene Name, BT-45/BT-28 nur der Handelsname.
|
||||
name:
|
||||
text(kind(rechtseinheit, 'RegistrationName')) ??
|
||||
text(kind(kind(partei, 'PartyName'), 'Name')),
|
||||
ustId: text(ustId),
|
||||
kennung:
|
||||
text(kind(kind(partei, 'PartyIdentification'), 'ID')) ??
|
||||
text(kind(rechtseinheit, 'CompanyID')),
|
||||
email:
|
||||
text(kind(partei, 'EndpointID')) ??
|
||||
text(kind(kind(partei, 'Contact'), 'ElectronicMail')),
|
||||
};
|
||||
}
|
||||
|
||||
// === Hilfsfunktionen ===
|
||||
|
||||
function leerePartei(): ERechnungPartei {
|
||||
return { name: null, ustId: null, kennung: null, email: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Steigt einen Schritt in den Parse-Baum ab. Wiederholte Elemente liefert der
|
||||
* Parser als Array – hier zählt dann das erste Vorkommen.
|
||||
*/
|
||||
function kind(knoten: unknown, name: string): unknown {
|
||||
const erstes: unknown = Array.isArray(knoten)
|
||||
? (knoten as unknown[])[0]
|
||||
: knoten;
|
||||
if (!erstes || typeof erstes !== 'object') return undefined;
|
||||
return (erstes as Record<string, unknown>)[name];
|
||||
}
|
||||
|
||||
/** Liest den Textinhalt eines Knotens – auch wenn er Attribute trägt. */
|
||||
function text(knoten: unknown): string | null {
|
||||
if (knoten === null || knoten === undefined) return null;
|
||||
if (typeof knoten === 'string') return knoten.trim() || null;
|
||||
if (typeof knoten === 'number' || typeof knoten === 'boolean') {
|
||||
return String(knoten);
|
||||
}
|
||||
if (Array.isArray(knoten)) return text((knoten as unknown[])[0]);
|
||||
if (typeof knoten === 'object') {
|
||||
return text((knoten as Record<string, unknown>)['#text']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attribut(knoten: unknown, name: string): string | null {
|
||||
const wert = kind(knoten, `@_${name}`);
|
||||
return typeof wert === 'string' ? wert : null;
|
||||
}
|
||||
|
||||
function liste(knoten: unknown): unknown[] {
|
||||
if (knoten === null || knoten === undefined) return [];
|
||||
return Array.isArray(knoten) ? (knoten as unknown[]) : [knoten];
|
||||
}
|
||||
|
||||
function zuBetrag(wert: string | null): number | null {
|
||||
if (!wert) return null;
|
||||
const zahl = Number(wert);
|
||||
return Number.isFinite(zahl) ? zahl : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CII liefert das Datum als "20240315" (format="102"), UBL bereits als
|
||||
* ISO-Datum. Alles andere wird unverändert durchgereicht.
|
||||
*/
|
||||
function normalisiereDatum(wert: string | null): string | null {
|
||||
if (!wert) return null;
|
||||
if (/^\d{8}$/.test(wert)) {
|
||||
return `${wert.slice(0, 4)}-${wert.slice(4, 6)}-${wert.slice(6, 8)}`;
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(wert)) return wert.slice(0, 10);
|
||||
return wert;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import { ClientIdentifier } from '../database/entities/client-identifier.entity';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import { UserClient } from '../database/entities/user-client.entity';
|
||||
import { PaperlessModule } from '../paperless/paperless.module';
|
||||
import { ErechnungService } from './erechnung.service';
|
||||
import { MandantMatcherService } from './mandant-matcher.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Client,
|
||||
ClientIdentifier,
|
||||
DocumentType,
|
||||
UserClient,
|
||||
]),
|
||||
forwardRef(() => PaperlessModule),
|
||||
],
|
||||
providers: [ErechnungService, MandantMatcherService],
|
||||
exports: [ErechnungService, MandantMatcherService],
|
||||
})
|
||||
export class ErechnungModule {}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import { UserClient } from '../database/entities/user-client.entity';
|
||||
import { extrahiereEingebetteteDateien } from './pdf-attachment.util';
|
||||
import { ERechnungDaten, parseERechnung } from './erechnung-parser';
|
||||
import { MandantMatcherService } from './mandant-matcher.service';
|
||||
|
||||
export interface ERechnungVorschlag {
|
||||
/** Ob das PDF überhaupt eine auswertbare E-Rechnung enthält. */
|
||||
erechnung: boolean;
|
||||
mandantId: number | null;
|
||||
belegartId: number | null;
|
||||
/** Kurztext für das UI, wenn kein Vorschlag möglich war. */
|
||||
hinweis: string | null;
|
||||
rechnung: {
|
||||
rechnungsnummer: string | null;
|
||||
rechnungsdatum: string | null;
|
||||
gesamtbetrag: number | null;
|
||||
waehrung: string | null;
|
||||
verkaeufer: string | null;
|
||||
typeCode: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Wie lange geparste Rechnungsdaten für das anschließende Speichern vorgehalten werden. */
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const CACHE_MAX = 200;
|
||||
|
||||
@Injectable()
|
||||
export class ErechnungService {
|
||||
private readonly logger = new Logger(ErechnungService.name);
|
||||
|
||||
/**
|
||||
* Zwischenspeicher zwischen Öffnen und Speichern eines Dokuments: das
|
||||
* Auto-Lernen beim PUT braucht dieselben Rechnungsdaten und soll dafür nicht
|
||||
* ein zweites Mal das PDF laden.
|
||||
*/
|
||||
private readonly cache = new Map<
|
||||
number,
|
||||
{ daten: ERechnungDaten | null; ts: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => PaperlessService))
|
||||
private readonly paperlessService: PaperlessService,
|
||||
private readonly matcher: MandantMatcherService,
|
||||
@InjectRepository(Client)
|
||||
private readonly clientRepo: Repository<Client>,
|
||||
@InjectRepository(UserClient)
|
||||
private readonly userClientRepo: Repository<UserClient>,
|
||||
) {}
|
||||
|
||||
/** Wertet die E-Rechnung eines Paperless-Dokuments aus und schlägt Felder vor. */
|
||||
async analysiereDokument(
|
||||
documentId: number,
|
||||
userId: string,
|
||||
): Promise<ERechnungVorschlag> {
|
||||
const daten = await this.ladeDaten(documentId);
|
||||
if (!daten) {
|
||||
return {
|
||||
erechnung: false,
|
||||
mandantId: null,
|
||||
belegartId: null,
|
||||
hinweis: null,
|
||||
rechnung: null,
|
||||
};
|
||||
}
|
||||
|
||||
const rechnung = {
|
||||
rechnungsnummer: daten.rechnungsnummer,
|
||||
rechnungsdatum: daten.rechnungsdatum,
|
||||
gesamtbetrag: daten.gesamtbetrag,
|
||||
waehrung: daten.waehrung,
|
||||
verkaeufer: daten.verkaeufer.name,
|
||||
typeCode: daten.typeCode,
|
||||
};
|
||||
|
||||
const belegartId = await this.matcher.belegart(daten.typeCode);
|
||||
const treffer = await this.matcher.match(daten);
|
||||
|
||||
if (treffer.mehrdeutig) {
|
||||
return {
|
||||
erechnung: true,
|
||||
mandantId: null,
|
||||
belegartId,
|
||||
hinweis:
|
||||
'Mehrere Mandanten passen zu dieser Rechnung – bitte manuell wählen.',
|
||||
rechnung,
|
||||
};
|
||||
}
|
||||
|
||||
if (treffer.clientId === null) {
|
||||
return {
|
||||
erechnung: true,
|
||||
mandantId: null,
|
||||
belegartId,
|
||||
hinweis: 'Kein Mandant zu dieser Rechnung gefunden.',
|
||||
rechnung,
|
||||
};
|
||||
}
|
||||
|
||||
// Vorschlag nie auf einen Mandanten setzen, den der Benutzer gar nicht
|
||||
// auswählen darf – das Select stünde sonst auf einer rohen ID. Der Name
|
||||
// wird bewusst nicht genannt.
|
||||
const erlaubt = await this.erlaubteClientIds(userId);
|
||||
if (!erlaubt.has(treffer.clientId)) {
|
||||
return {
|
||||
erechnung: true,
|
||||
mandantId: null,
|
||||
belegartId,
|
||||
hinweis: 'Zugehöriger Mandant ist dir nicht zugeordnet.',
|
||||
rechnung,
|
||||
};
|
||||
}
|
||||
|
||||
const client = await this.clientRepo.findOneBy({ Id: treffer.clientId });
|
||||
return {
|
||||
erechnung: true,
|
||||
mandantId: client?.PaperlessUserId ?? null,
|
||||
belegartId,
|
||||
hinweis: client ? null : 'Kein Mandant zu dieser Rechnung gefunden.',
|
||||
rechnung,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lernt die Käufer-Kennungen der Rechnung auf den gespeicherten Mandanten.
|
||||
* Wird nur aufgerufen, wenn das Dokument tatsächlich eine E-Rechnung ist.
|
||||
*/
|
||||
async lerneAusDokument(
|
||||
documentId: number,
|
||||
paperlessUserId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const daten = await this.ladeDaten(documentId);
|
||||
if (!daten) return;
|
||||
|
||||
const client = await this.clientRepo.findOneBy({
|
||||
PaperlessUserId: paperlessUserId,
|
||||
});
|
||||
if (!client) return;
|
||||
|
||||
const anzahl = await this.matcher.lerne(daten, client.Id);
|
||||
if (anzahl > 0) {
|
||||
this.logger.log(
|
||||
`Dokument ${documentId}: ${anzahl} E-Rechnungs-Kennung(en) für Mandant "${client.Name}" gelernt.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Das Lernen darf das Speichern des Dokuments niemals scheitern lassen.
|
||||
this.logger.warn(
|
||||
`E-Rechnungs-Kennungen für Dokument ${documentId} konnten nicht gelernt werden: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lädt und parst die Rechnungsdaten – mit kurzem Zwischenspeicher. */
|
||||
private async ladeDaten(documentId: number): Promise<ERechnungDaten | null> {
|
||||
const zwischenspeicher = this.cache.get(documentId);
|
||||
if (zwischenspeicher && Date.now() - zwischenspeicher.ts < CACHE_TTL_MS) {
|
||||
return zwischenspeicher.daten;
|
||||
}
|
||||
|
||||
let daten: ERechnungDaten | null = null;
|
||||
try {
|
||||
const pdf = await this.paperlessService.downloadDocument(
|
||||
documentId,
|
||||
'original',
|
||||
);
|
||||
daten = await this.leseAusPdf(pdf);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`E-Rechnung von Dokument ${documentId} konnte nicht gelesen werden: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.merke(documentId, daten);
|
||||
return daten;
|
||||
}
|
||||
|
||||
/** Sucht im PDF das erste eingebettete XML, das sich als E-Rechnung lesen lässt. */
|
||||
private async leseAusPdf(pdf: Buffer): Promise<ERechnungDaten | null> {
|
||||
const dateien = await extrahiereEingebetteteDateien(pdf);
|
||||
const kandidaten = dateien.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().endsWith('.xml') ||
|
||||
d.inhalt
|
||||
.subarray(0, 512)
|
||||
.toString('latin1')
|
||||
.trimStart()
|
||||
.startsWith('<'),
|
||||
);
|
||||
|
||||
for (const datei of kandidaten) {
|
||||
const daten = parseERechnung(datei.inhalt);
|
||||
if (daten) return daten;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private merke(documentId: number, daten: ERechnungDaten | null): void {
|
||||
if (this.cache.size >= CACHE_MAX) {
|
||||
const aeltester = this.cache.keys().next();
|
||||
if (!aeltester.done) this.cache.delete(aeltester.value);
|
||||
}
|
||||
this.cache.set(documentId, { daten, ts: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandanten, die der Benutzer auswählen darf – identisch zur Logik hinter
|
||||
* `GET /api/clients`, damit Vorschlag und Auswahlliste nie auseinanderlaufen.
|
||||
*/
|
||||
private async erlaubteClientIds(userId: string): Promise<Set<number>> {
|
||||
const zuordnungen = await this.userClientRepo.find({
|
||||
where: { UserId: userId },
|
||||
});
|
||||
if (zuordnungen.length > 0) {
|
||||
return new Set(zuordnungen.map((z) => z.ClientId));
|
||||
}
|
||||
const alle = await this.clientRepo.find();
|
||||
return new Set(alle.map((c) => c.Id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import {
|
||||
ClientIdentifier,
|
||||
ClientIdentifierTyp,
|
||||
} from '../database/entities/client-identifier.entity';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import type { ERechnungDaten } from './erechnung-parser';
|
||||
|
||||
export interface KennungsKandidat {
|
||||
Typ: ClientIdentifierTyp;
|
||||
Wert: string;
|
||||
}
|
||||
|
||||
export interface MandantTreffer {
|
||||
clientId: number | null;
|
||||
/** Woher der Treffer stammt – `null`, wenn keiner gefunden wurde. */
|
||||
quelle: 'kennung' | 'name' | null;
|
||||
/** Mehrere Mandanten passten; es wird bewusst keiner vorgeschlagen. */
|
||||
mehrdeutig: boolean;
|
||||
}
|
||||
|
||||
/** Maximale Länge einer Kennung – entspricht ClientIdentifier.Wert. */
|
||||
const MAX_KENNUNG_LAENGE = 100;
|
||||
|
||||
/**
|
||||
* Grobstruktur einer Leitweg-ID: Grobadressierung, optionale Feinadressierung,
|
||||
* zweistellige Prüfziffer. BT-10 ist ein Freitextfeld – im B2B steht dort oft
|
||||
* eine Bestellnummer. Nur strukturell passende Werte werden gelernt, sonst
|
||||
* würde jede Rechnung eine wertlose Kennung anlegen.
|
||||
*/
|
||||
const LEITWEG_ID_MUSTER = /^\d{2,12}(-[0-9A-Za-z]{1,30})?-\d{2}$/;
|
||||
|
||||
/** Rechtsformen und Zusätze, die für den Namensvergleich irrelevant sind. */
|
||||
const RECHTSFORMEN =
|
||||
/\b(gmbh\s*&\s*co\.?\s*kg|gmbh|mbh|kgaa|kg|ohg|gbr|ug\s*\(haftungsbeschraenkt\)|ug|ag|se|partg\s*mbb|partg|mbb|e\.?\s*k\.?|e\.?\s*v\.?|e\.?\s*g\.?|&\s*co\.?)\b/g;
|
||||
|
||||
/**
|
||||
* Normalisiert einen Firmennamen für den exakten Vergleich: Kleinschreibung,
|
||||
* deutsche Umlaut-Transliteration, Rechtsformen und Sonderzeichen entfernt.
|
||||
* Bewusst kein Ähnlichkeitsmaß – ein Fuzzy-Treffer würde bei ähnlich benannten
|
||||
* Mandanten genau die gefährlichen Verwechslungen erzeugen.
|
||||
*/
|
||||
export function normalisiereFirmenname(name: string | null): string {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(RECHTSFORMEN, ' ')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/** Vereinheitlicht eine Kennung: Großschreibung, ohne Leer- und Trennzeichen. */
|
||||
export function normalisiereKennung(wert: string | null): string {
|
||||
if (!wert) return '';
|
||||
return wert.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt die Käufer-Kennungen einer E-Rechnung, gegen die gematcht wird.
|
||||
* `nurLernbare` filtert zusätzlich auf das, was dauerhaft gespeichert werden
|
||||
* darf – eine BT-10-Bestellnummer gehört nicht in die Zuordnungstabelle.
|
||||
*/
|
||||
export function sammleKennungen(
|
||||
daten: ERechnungDaten,
|
||||
nurLernbare = false,
|
||||
): KennungsKandidat[] {
|
||||
const kandidaten: KennungsKandidat[] = [];
|
||||
|
||||
const hinzu = (Typ: ClientIdentifierTyp, roh: string | null) => {
|
||||
const Wert = normalisiereKennung(roh);
|
||||
if (!Wert || Wert.length > MAX_KENNUNG_LAENGE) return;
|
||||
if (kandidaten.some((k) => k.Typ === Typ && k.Wert === Wert)) return;
|
||||
kandidaten.push({ Typ, Wert });
|
||||
};
|
||||
|
||||
hinzu('ustid', daten.kaeufer.ustId);
|
||||
hinzu('kaeuferkennung', daten.kaeufer.kennung);
|
||||
|
||||
const leitweg = daten.leitwegId?.trim() ?? '';
|
||||
if (leitweg && (!nurLernbare || LEITWEG_ID_MUSTER.test(leitweg))) {
|
||||
hinzu('leitwegid', leitweg);
|
||||
}
|
||||
|
||||
return kandidaten;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt den Mandanten zu einer E-Rechnung: zuerst über die gepflegten
|
||||
* Kennungen, danach über einen exakten Namensvergleich. Passen mehrere
|
||||
* Mandanten, gibt es keinen Vorschlag.
|
||||
*/
|
||||
export function findeMandant(
|
||||
daten: ERechnungDaten,
|
||||
clients: Client[],
|
||||
identifiers: ClientIdentifier[],
|
||||
): MandantTreffer {
|
||||
const kandidaten = sammleKennungen(daten);
|
||||
const perKennung = new Set(
|
||||
identifiers
|
||||
.filter((i) =>
|
||||
kandidaten.some((k) => k.Typ === i.Typ && k.Wert === i.Wert),
|
||||
)
|
||||
.map((i) => i.ClientId),
|
||||
);
|
||||
|
||||
if (perKennung.size === 1) {
|
||||
return {
|
||||
clientId: [...perKennung][0],
|
||||
quelle: 'kennung',
|
||||
mehrdeutig: false,
|
||||
};
|
||||
}
|
||||
if (perKennung.size > 1) {
|
||||
return { clientId: null, quelle: null, mehrdeutig: true };
|
||||
}
|
||||
|
||||
const gesuchterName = normalisiereFirmenname(daten.kaeufer.name);
|
||||
if (!gesuchterName) {
|
||||
return { clientId: null, quelle: null, mehrdeutig: false };
|
||||
}
|
||||
|
||||
const perName = clients.filter(
|
||||
(c) => normalisiereFirmenname(c.Name) === gesuchterName,
|
||||
);
|
||||
if (perName.length === 1) {
|
||||
return { clientId: perName[0].Id, quelle: 'name', mehrdeutig: false };
|
||||
}
|
||||
return { clientId: null, quelle: null, mehrdeutig: perName.length > 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet den Rechnungstyp (BT-3) auf eine Belegart ab. Passt kein oder mehr als
|
||||
* ein Eintrag, wird nichts vorgeschlagen – geraten wird nicht.
|
||||
*/
|
||||
export function findeBelegart(
|
||||
typeCode: string | null,
|
||||
docTypes: DocumentType[],
|
||||
): number | null {
|
||||
if (!typeCode) return null;
|
||||
const gesucht = typeCode.trim();
|
||||
|
||||
const treffer = docTypes.filter((dt) =>
|
||||
(dt.ERechnungTypeCodes ?? '')
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean)
|
||||
.includes(gesucht),
|
||||
);
|
||||
return treffer.length === 1 ? treffer[0].DocumentTypeId : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MandantMatcherService {
|
||||
constructor(
|
||||
@InjectRepository(Client)
|
||||
private readonly clientRepo: Repository<Client>,
|
||||
@InjectRepository(ClientIdentifier)
|
||||
private readonly identifierRepo: Repository<ClientIdentifier>,
|
||||
@InjectRepository(DocumentType)
|
||||
private readonly docTypeRepo: Repository<DocumentType>,
|
||||
) {}
|
||||
|
||||
async match(daten: ERechnungDaten): Promise<MandantTreffer> {
|
||||
const kandidaten = sammleKennungen(daten);
|
||||
const identifiers = kandidaten.length
|
||||
? await this.identifierRepo.find({
|
||||
where: { Wert: In(kandidaten.map((k) => k.Wert)) },
|
||||
})
|
||||
: [];
|
||||
const clients = await this.clientRepo.find();
|
||||
return findeMandant(daten, clients, identifiers);
|
||||
}
|
||||
|
||||
async belegart(typeCode: string | null): Promise<number | null> {
|
||||
if (!typeCode) return null;
|
||||
return findeBelegart(typeCode, await this.docTypeRepo.find());
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die starken Käufer-Kennungen der Rechnung auf den Mandanten fort,
|
||||
* den der Benutzer tatsächlich gespeichert hat. Bereits vergebene Kennungen
|
||||
* bleiben unangetastet – eine bestehende Zuordnung wird nie still umgebogen.
|
||||
*/
|
||||
async lerne(daten: ERechnungDaten, clientId: number): Promise<number> {
|
||||
const kandidaten = sammleKennungen(daten, true);
|
||||
if (!kandidaten.length) return 0;
|
||||
|
||||
const vorhanden = await this.identifierRepo.find({
|
||||
where: { Wert: In(kandidaten.map((k) => k.Wert)) },
|
||||
});
|
||||
|
||||
const neu = kandidaten
|
||||
.filter(
|
||||
(k) => !vorhanden.some((v) => v.Typ === k.Typ && v.Wert === k.Wert),
|
||||
)
|
||||
.map((k) => this.identifierRepo.create({ ...k, ClientId: clientId }));
|
||||
|
||||
if (!neu.length) return 0;
|
||||
await this.identifierRepo.save(neu);
|
||||
return neu.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
findeBelegart,
|
||||
findeMandant,
|
||||
normalisiereFirmenname,
|
||||
normalisiereKennung,
|
||||
sammleKennungen,
|
||||
} from './mandant-matcher.service';
|
||||
import type { ERechnungDaten } from './erechnung-parser';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import { ClientIdentifier } from '../database/entities/client-identifier.entity';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
|
||||
function daten(overrides: Partial<ERechnungDaten> = {}): ERechnungDaten {
|
||||
return {
|
||||
syntax: 'CII',
|
||||
rechnungsnummer: 'RE-1',
|
||||
rechnungsdatum: '2024-03-15',
|
||||
typeCode: '380',
|
||||
waehrung: 'EUR',
|
||||
gesamtbetrag: 100,
|
||||
leitwegId: null,
|
||||
kaeufer: { name: null, ustId: null, kennung: null, email: null },
|
||||
verkaeufer: { name: null, ustId: null, kennung: null, email: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function client(Id: number, Name: string): Client {
|
||||
return { Id, Name, PaperlessUserId: Id + 100, AgrarmonitorBetriebId: null };
|
||||
}
|
||||
|
||||
function kennung(
|
||||
Id: number,
|
||||
ClientId: number,
|
||||
Typ: ClientIdentifier['Typ'],
|
||||
Wert: string,
|
||||
): ClientIdentifier {
|
||||
return { Id, ClientId, Typ, Wert };
|
||||
}
|
||||
|
||||
describe('normalisiereFirmenname', () => {
|
||||
it('macht Schreibweise, Umlaute und Rechtsform irrelevant', () => {
|
||||
expect(normalisiereFirmenname('Hof Pöttker GbR')).toBe(
|
||||
normalisiereFirmenname('hof poettker'),
|
||||
);
|
||||
expect(normalisiereFirmenname('Müller & Söhne GmbH & Co. KG')).toBe(
|
||||
normalisiereFirmenname('Mueller & Soehne'),
|
||||
);
|
||||
expect(normalisiereFirmenname('Meier-Schulze e.K.')).toBe(
|
||||
normalisiereFirmenname('Meier Schulze'),
|
||||
);
|
||||
expect(normalisiereFirmenname('Weiß AG')).toBe('weiss');
|
||||
});
|
||||
|
||||
it('trennt weiterhin unterschiedliche Namen', () => {
|
||||
expect(normalisiereFirmenname('Hof Pöttker')).not.toBe(
|
||||
normalisiereFirmenname('Hof Pöttker-Holtrup'),
|
||||
);
|
||||
});
|
||||
|
||||
it('lässt Namensbestandteile stehen, die nur wie eine Rechtsform aussehen', () => {
|
||||
// "E. Krause" darf nicht als "e.K." verschluckt werden.
|
||||
expect(normalisiereFirmenname('E. Krause')).toBe('ekrause');
|
||||
});
|
||||
|
||||
it('liefert leeren String für fehlende Namen', () => {
|
||||
expect(normalisiereFirmenname(null)).toBe('');
|
||||
expect(normalisiereFirmenname(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalisiereKennung', () => {
|
||||
it('vereinheitlicht Schreibweise und Trennzeichen', () => {
|
||||
expect(normalisiereKennung('de 987 654 321')).toBe('DE987654321');
|
||||
expect(normalisiereKennung('04011000-12345-06')).toBe('040110001234506');
|
||||
expect(normalisiereKennung(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sammleKennungen', () => {
|
||||
it('sammelt USt-IdNr., Käuferkennung und Leitweg-ID', () => {
|
||||
const k = sammleKennungen(
|
||||
daten({
|
||||
leitwegId: '04011000-12345-06',
|
||||
kaeufer: {
|
||||
name: null,
|
||||
ustId: 'DE987654321',
|
||||
kennung: 'K-4711',
|
||||
email: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(k).toEqual([
|
||||
{ Typ: 'ustid', Wert: 'DE987654321' },
|
||||
{ Typ: 'kaeuferkennung', Wert: 'K4711' },
|
||||
{ Typ: 'leitwegid', Wert: '040110001234506' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lernt aus BT-10 nur strukturell gültige Leitweg-IDs', () => {
|
||||
const freitext = daten({ leitwegId: 'Bestellung 4711' });
|
||||
|
||||
// Zum Matchen wird der Wert benutzt …
|
||||
expect(sammleKennungen(freitext)).toEqual([
|
||||
{ Typ: 'leitwegid', Wert: 'BESTELLUNG4711' },
|
||||
]);
|
||||
// … gelernt wird er nicht, sonst entstünde je Rechnung eine Karteileiche.
|
||||
expect(sammleKennungen(freitext, true)).toEqual([]);
|
||||
|
||||
const leitweg = daten({ leitwegId: '04011000-12345-06' });
|
||||
expect(sammleKennungen(leitweg, true)).toEqual([
|
||||
{ Typ: 'leitwegid', Wert: '040110001234506' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignoriert leere Kennungen', () => {
|
||||
expect(sammleKennungen(daten())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findeMandant', () => {
|
||||
const clients = [client(1, 'Hof Pöttker GbR'), client(2, 'Hof Holtrup')];
|
||||
|
||||
it('trifft über eine gepflegte Kennung', () => {
|
||||
const treffer = findeMandant(
|
||||
daten({
|
||||
kaeufer: {
|
||||
name: 'Ganz anderer Name',
|
||||
ustId: 'DE987654321',
|
||||
kennung: null,
|
||||
email: null,
|
||||
},
|
||||
}),
|
||||
clients,
|
||||
[kennung(1, 2, 'ustid', 'DE987654321')],
|
||||
);
|
||||
|
||||
expect(treffer).toEqual({
|
||||
clientId: 2,
|
||||
quelle: 'kennung',
|
||||
mehrdeutig: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fällt auf den normalisierten Namensvergleich zurück', () => {
|
||||
const treffer = findeMandant(
|
||||
daten({
|
||||
kaeufer: {
|
||||
name: 'hof poettker',
|
||||
ustId: null,
|
||||
kennung: null,
|
||||
email: null,
|
||||
},
|
||||
}),
|
||||
clients,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(treffer).toEqual({ clientId: 1, quelle: 'name', mehrdeutig: false });
|
||||
});
|
||||
|
||||
it('schlägt nichts vor, wenn mehrere Kennungen auf verschiedene Mandanten zeigen', () => {
|
||||
const treffer = findeMandant(
|
||||
daten({
|
||||
leitwegId: '04011000-12345-06',
|
||||
kaeufer: {
|
||||
name: 'Hof Pöttker GbR',
|
||||
ustId: 'DE987654321',
|
||||
kennung: null,
|
||||
email: null,
|
||||
},
|
||||
}),
|
||||
clients,
|
||||
[
|
||||
kennung(1, 1, 'ustid', 'DE987654321'),
|
||||
kennung(2, 2, 'leitwegid', '040110001234506'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(treffer).toEqual({ clientId: null, quelle: null, mehrdeutig: true });
|
||||
});
|
||||
|
||||
it('schlägt nichts vor, wenn mehrere Mandanten denselben Namen tragen', () => {
|
||||
const treffer = findeMandant(
|
||||
daten({
|
||||
kaeufer: { name: 'Hof Test', ustId: null, kennung: null, email: null },
|
||||
}),
|
||||
[client(1, 'Hof Test'), client(2, 'Hof Test GmbH')],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(treffer).toEqual({ clientId: null, quelle: null, mehrdeutig: true });
|
||||
});
|
||||
|
||||
it('schlägt nichts vor, wenn nichts passt', () => {
|
||||
const treffer = findeMandant(
|
||||
daten({
|
||||
kaeufer: { name: 'Unbekannt', ustId: null, kennung: null, email: null },
|
||||
}),
|
||||
clients,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(treffer).toEqual({
|
||||
clientId: null,
|
||||
quelle: null,
|
||||
mehrdeutig: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignoriert Kennungen, die zwar existieren, aber zu einer anderen Art gehören', () => {
|
||||
const treffer = findeMandant(
|
||||
daten({
|
||||
kaeufer: {
|
||||
name: null,
|
||||
ustId: 'DE987654321',
|
||||
kennung: null,
|
||||
email: null,
|
||||
},
|
||||
}),
|
||||
clients,
|
||||
[kennung(1, 2, 'kaeuferkennung', 'DE987654321')],
|
||||
);
|
||||
|
||||
expect(treffer.clientId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findeBelegart', () => {
|
||||
const docType = (
|
||||
DocumentTypeId: number,
|
||||
ERechnungTypeCodes: string | null,
|
||||
): DocumentType => ({
|
||||
Id: DocumentTypeId,
|
||||
DocumentTypeId,
|
||||
TitelTemplate: '',
|
||||
TagNotReady: null,
|
||||
TagReady: null,
|
||||
FreigabeErforderlich: null,
|
||||
ERechnungTypeCodes,
|
||||
});
|
||||
|
||||
const typen = [docType(3, '380, 384'), docType(7, '381'), docType(9, null)];
|
||||
|
||||
it('bildet den Typ-Code auf die konfigurierte Belegart ab', () => {
|
||||
expect(findeBelegart('380', typen)).toBe(3);
|
||||
expect(findeBelegart('384', typen)).toBe(3);
|
||||
expect(findeBelegart('381', typen)).toBe(7);
|
||||
});
|
||||
|
||||
it('rät nicht bei unbekanntem oder fehlendem Code', () => {
|
||||
expect(findeBelegart('875', typen)).toBeNull();
|
||||
expect(findeBelegart(null, typen)).toBeNull();
|
||||
});
|
||||
|
||||
it('schlägt nichts vor, wenn ein Code doppelt konfiguriert ist', () => {
|
||||
expect(
|
||||
findeBelegart('380', [docType(3, '380'), docType(4, '380')]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { extrahiereEingebetteteDateien } from './pdf-attachment.util';
|
||||
|
||||
const XML = '<?xml version="1.0"?><rsm:CrossIndustryInvoice/>';
|
||||
|
||||
async function pdfMitAnhang(
|
||||
...anhaenge: { name: string; inhalt: string }[]
|
||||
): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
for (const a of anhaenge) {
|
||||
await doc.attach(Buffer.from(a.inhalt, 'utf8'), a.name, {
|
||||
mimeType: 'text/xml',
|
||||
});
|
||||
}
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
|
||||
describe('extrahiereEingebetteteDateien', () => {
|
||||
it('liest einen eingebetteten ZUGFeRD-Anhang aus', async () => {
|
||||
const pdf = await pdfMitAnhang({ name: 'factur-x.xml', inhalt: XML });
|
||||
|
||||
const dateien = await extrahiereEingebetteteDateien(pdf);
|
||||
|
||||
expect(dateien).toHaveLength(1);
|
||||
expect(dateien[0].name).toBe('factur-x.xml');
|
||||
expect(dateien[0].inhalt.toString('utf8')).toBe(XML);
|
||||
});
|
||||
|
||||
it('liefert jeden Anhang nur einmal, obwohl er in /Names und /AF steht', async () => {
|
||||
const pdf = await pdfMitAnhang(
|
||||
{ name: 'xrechnung.xml', inhalt: XML },
|
||||
{ name: 'anhang.txt', inhalt: 'Lieferschein' },
|
||||
);
|
||||
|
||||
const dateien = await extrahiereEingebetteteDateien(pdf);
|
||||
|
||||
expect(dateien.map((d) => d.name).sort()).toEqual([
|
||||
'anhang.txt',
|
||||
'xrechnung.xml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('liefert eine leere Liste für ein PDF ohne Anhänge', async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
const pdf = Buffer.from(await doc.save());
|
||||
|
||||
expect(await extrahiereEingebetteteDateien(pdf)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFRawStream,
|
||||
PDFStream,
|
||||
PDFString,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
|
||||
export interface EingebetteteDatei {
|
||||
name: string;
|
||||
inhalt: Buffer;
|
||||
}
|
||||
|
||||
/** Schutz gegen zyklische oder absurd tiefe Namensbäume. */
|
||||
const MAX_TIEFE = 32;
|
||||
|
||||
/**
|
||||
* Liest die im PDF eingebetteten Dateien aus – also die Anhänge, über die
|
||||
* ZUGFeRD/Factur-X und XRechnung ihr XML transportieren (PDF/A-3).
|
||||
*
|
||||
* pdf-lib bietet dafür keine öffentliche API (nur `attach()` zum Schreiben),
|
||||
* deshalb werden die beiden Stellen, an denen Erzeuger Anhänge ablegen, direkt
|
||||
* aus dem Katalog gelesen: der Namensbaum /Root/Names/EmbeddedFiles und die
|
||||
* Liste zugeordneter Dateien /Root/AF.
|
||||
*/
|
||||
export async function extrahiereEingebetteteDateien(
|
||||
pdf: Buffer,
|
||||
): Promise<EingebetteteDatei[]> {
|
||||
const doc = await PDFDocument.load(pdf, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
updateMetadata: false,
|
||||
});
|
||||
|
||||
const filespecs: PDFDict[] = [];
|
||||
const katalog = doc.catalog;
|
||||
|
||||
const names = katalog.lookupMaybe(PDFName.of('Names'), PDFDict);
|
||||
const embeddedFiles = names?.lookupMaybe(
|
||||
PDFName.of('EmbeddedFiles'),
|
||||
PDFDict,
|
||||
);
|
||||
if (embeddedFiles) sammleAusNamensbaum(embeddedFiles, filespecs, 0);
|
||||
|
||||
const af = katalog.lookupMaybe(PDFName.of('AF'), PDFArray);
|
||||
if (af) {
|
||||
for (let i = 0; i < af.size(); i++) {
|
||||
const spec = af.lookupMaybe(i, PDFDict);
|
||||
if (spec) filespecs.push(spec);
|
||||
}
|
||||
}
|
||||
|
||||
const dateien: EingebetteteDatei[] = [];
|
||||
const gesehen = new Set<string>();
|
||||
|
||||
for (const spec of filespecs) {
|
||||
const name =
|
||||
leseText(spec, 'UF') ?? leseText(spec, 'F') ?? leseText(spec, 'DOS');
|
||||
const inhalt = leseInhalt(spec);
|
||||
if (!inhalt) continue;
|
||||
|
||||
// Derselbe Anhang steht bei PDF/A-3 regelmäßig in /Names *und* /AF.
|
||||
const schluessel = `${name ?? ''}:${inhalt.length}`;
|
||||
if (gesehen.has(schluessel)) continue;
|
||||
gesehen.add(schluessel);
|
||||
|
||||
dateien.push({ name: name ?? 'unbenannt', inhalt });
|
||||
}
|
||||
|
||||
return dateien;
|
||||
}
|
||||
|
||||
function sammleAusNamensbaum(
|
||||
knoten: PDFDict,
|
||||
ziel: PDFDict[],
|
||||
tiefe: number,
|
||||
): void {
|
||||
if (tiefe > MAX_TIEFE) return;
|
||||
|
||||
// Blattknoten: /Names ist ein flaches [Name, Filespec, Name, Filespec, …].
|
||||
const eintraege = knoten.lookupMaybe(PDFName.of('Names'), PDFArray);
|
||||
if (eintraege) {
|
||||
for (let i = 1; i < eintraege.size(); i += 2) {
|
||||
const spec = eintraege.lookupMaybe(i, PDFDict);
|
||||
if (spec) ziel.push(spec);
|
||||
}
|
||||
}
|
||||
|
||||
const kids = knoten.lookupMaybe(PDFName.of('Kids'), PDFArray);
|
||||
if (kids) {
|
||||
for (let i = 0; i < kids.size(); i++) {
|
||||
const kind = kids.lookupMaybe(i, PDFDict);
|
||||
if (kind) sammleAusNamensbaum(kind, ziel, tiefe + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function leseText(dict: PDFDict, schluessel: string): string | null {
|
||||
const wert = dict.lookupMaybe(
|
||||
PDFName.of(schluessel),
|
||||
PDFString,
|
||||
PDFHexString,
|
||||
);
|
||||
return wert ? wert.decodeText() : null;
|
||||
}
|
||||
|
||||
/** Holt den dekomprimierten Inhalt aus /EF (bevorzugt /UF, sonst /F). */
|
||||
function leseInhalt(spec: PDFDict): Buffer | null {
|
||||
const ef = spec.lookupMaybe(PDFName.of('EF'), PDFDict);
|
||||
if (!ef) return null;
|
||||
|
||||
for (const schluessel of ['UF', 'F', 'DOS', 'Mac', 'Unix']) {
|
||||
const stream = ef.lookupMaybe(PDFName.of(schluessel), PDFStream);
|
||||
if (!(stream instanceof PDFRawStream)) continue;
|
||||
try {
|
||||
return Buffer.from(decodePDFRawStream(stream).decode());
|
||||
} catch {
|
||||
// Beschädigter oder unbekannt kodierter Stream – nächsten Schlüssel probieren.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
forwardRef,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
HttpStatus,
|
||||
Res,
|
||||
Query,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
@@ -31,6 +34,7 @@ import { Document } from '../database/entities/document.entity';
|
||||
import { DocumentField } from '../database/entities/document-field.entity';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import { Setting } from '../database/entities/setting.entity';
|
||||
import { ErechnungService } from '../erechnung/erechnung.service';
|
||||
|
||||
/** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */
|
||||
export const STEUERTAG_SETTING_KEY = 'steuertag_ids';
|
||||
@@ -51,6 +55,8 @@ export class PaperlessController {
|
||||
private readonly documentTypeRepo: Repository<DocumentType>,
|
||||
@InjectRepository(Setting)
|
||||
private readonly settingRepo: Repository<Setting>,
|
||||
@Inject(forwardRef(() => ErechnungService))
|
||||
private readonly erechnungService: ErechnungService,
|
||||
) {}
|
||||
|
||||
/** Liest die als Steuertags markierten Tag-IDs aus den Einstellungen. */
|
||||
@@ -318,6 +324,20 @@ export class PaperlessController {
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertet eine im Original-PDF eingebettete E-Rechnung aus und schlägt
|
||||
* Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument
|
||||
* keine auswertbare E-Rechnung enthält.
|
||||
*/
|
||||
@Get('inbox/:id/erechnung')
|
||||
@RequirePermissions(Permission.VIEW_INBOX)
|
||||
async getInboxErechnung(@Param('id') id: string, @Request() req: any) {
|
||||
return this.erechnungService.analysiereDokument(
|
||||
parseInt(id, 10),
|
||||
req.user?.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('inbox/:id')
|
||||
@RequirePermissions(Permission.VIEW_INBOX)
|
||||
async putInboxDocument(@Param('id') id: string, @Body() body: any) {
|
||||
@@ -474,6 +494,17 @@ export class PaperlessController {
|
||||
delete oldDocument.created_date;
|
||||
|
||||
await this.paperlessService.updateDocument(documentId, oldDocument);
|
||||
|
||||
// Kennungen der E-Rechnung auf den tatsächlich gespeicherten Mandanten
|
||||
// fortschreiben, damit die nächste Rechnung desselben Käufers automatisch
|
||||
// zugeordnet wird. Fehler hierbei bleiben ohne Folgen für das Speichern.
|
||||
if (body.erechnung === true && oldDocument.owner) {
|
||||
await this.erechnungService.lerneAusDokument(
|
||||
documentId,
|
||||
Number(oldDocument.owner),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Setting } from '../database/entities/setting.entity';
|
||||
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ErechnungModule } from '../erechnung/erechnung.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +25,7 @@ import { AuthModule } from '../auth/auth.module';
|
||||
Setting,
|
||||
]),
|
||||
forwardRef(() => PostprocessingModule),
|
||||
forwardRef(() => ErechnungModule),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [PaperlessController],
|
||||
|
||||
@@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ExportTarget } from '../database/entities/export-target.entity';
|
||||
import * as ftp from 'basic-ftp';
|
||||
import { createClient, type WebDAVClient } from 'webdav';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
@@ -29,9 +28,6 @@ export class ExportService {
|
||||
case 'ftp':
|
||||
await this.uploadFtp(target, filename, content);
|
||||
break;
|
||||
case 'webdav':
|
||||
await this.uploadWebDav(target, filename, content);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`);
|
||||
}
|
||||
@@ -47,9 +43,6 @@ export class ExportService {
|
||||
case 'ftp':
|
||||
await this.testFtp(target);
|
||||
break;
|
||||
case 'webdav':
|
||||
await this.testWebDav(target);
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
@@ -57,8 +50,11 @@ export class ExportService {
|
||||
};
|
||||
}
|
||||
return { success: true, message: 'Verbindung erfolgreich.' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
} catch (err: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,31 +98,4 @@ export class ExportService {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadWebDav(
|
||||
target: ExportTarget,
|
||||
filename: string,
|
||||
content: Buffer,
|
||||
): Promise<void> {
|
||||
const client = this.createWebDavClient(target);
|
||||
const remotePath = `${target.RemotePath || '/'}/${filename}`;
|
||||
await client.putFileContents(remotePath, content);
|
||||
this.logger.log(`WebDAV Upload: ${remotePath} → ${target.Name}`);
|
||||
}
|
||||
|
||||
private async testWebDav(target: ExportTarget): Promise<void> {
|
||||
const client = this.createWebDavClient(target);
|
||||
await client.getDirectoryContents(target.RemotePath || '/');
|
||||
}
|
||||
|
||||
private createWebDavClient(target: ExportTarget): WebDAVClient {
|
||||
const protocol = target.Port === 443 ? 'https' : 'http';
|
||||
const port = target.Port ? `:${target.Port}` : '';
|
||||
const url = `${protocol}://${target.Host}${port}`;
|
||||
|
||||
return createClient(url, {
|
||||
username: target.Username || undefined,
|
||||
password: target.Password || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,68 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { PostprocessingService } from './postprocessing.service';
|
||||
import { Postprocessing } from '../database/entities/postprocessing.entity';
|
||||
import {
|
||||
Postprocessing,
|
||||
type FilterGroup,
|
||||
} from '../database/entities/postprocessing.entity';
|
||||
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
|
||||
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { MailService } from './mail.service';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
const mockRules: Partial<Postprocessing>[] = [
|
||||
{
|
||||
Id: 1,
|
||||
Name: 'Rule1',
|
||||
DocumentTypeId: 5,
|
||||
CorrespondentId: null,
|
||||
OwnerId: null,
|
||||
TagId: null,
|
||||
/** Bedingung „Dokumenttyp == <id>" als Filterbaum. */
|
||||
const documentTypeIs = (id: number): FilterGroup => ({
|
||||
combinator: 'AND',
|
||||
rules: [{ field: 'document_type', operator: 'equals', value: id }],
|
||||
});
|
||||
|
||||
/** Aktion „Tag 99 setzen" (ActionType 3). */
|
||||
const addTagAction = (
|
||||
postprocessingId: number,
|
||||
): Partial<PostprocessingAction> => ({
|
||||
Id: postprocessingId * 10,
|
||||
PostprocessingId: postprocessingId,
|
||||
ActionType: 3,
|
||||
Content: { addTags: [99] },
|
||||
Order: 1,
|
||||
IsActive: true,
|
||||
});
|
||||
|
||||
const rule = (over: Partial<Postprocessing>): Partial<Postprocessing> => ({
|
||||
Name: `Regel ${over.Id}`,
|
||||
FilterJson: documentTypeIs(5),
|
||||
Order: over.Id ?? 1,
|
||||
IsActive: true,
|
||||
NoFurther: false,
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Name: 'StopRule',
|
||||
DocumentTypeId: null,
|
||||
CorrespondentId: null,
|
||||
OwnerId: null,
|
||||
TagId: null,
|
||||
Order: 2,
|
||||
IsActive: true,
|
||||
NoFurther: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockActions: Partial<PostprocessingAction>[] = [
|
||||
{
|
||||
Id: 1,
|
||||
PostprocessingId: 1,
|
||||
ActionType: 2,
|
||||
Content: '99',
|
||||
Order: 1,
|
||||
IsActive: true,
|
||||
},
|
||||
];
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('PostprocessingService', () => {
|
||||
let service: PostprocessingService;
|
||||
let ppRepo: any;
|
||||
let ppActionRepo: any;
|
||||
let paperlessService: any;
|
||||
let ppRepo: { find: jest.Mock };
|
||||
let actionRepo: { find: jest.Mock };
|
||||
let logRepo: { create: jest.Mock; save: jest.Mock };
|
||||
let paperlessService: {
|
||||
updateDocument: jest.Mock;
|
||||
getCorrespondents: jest.Mock;
|
||||
getDocumentTypes: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
ppRepo = { find: jest.fn().mockResolvedValue(mockRules) };
|
||||
ppActionRepo = { find: jest.fn().mockResolvedValue(mockActions) };
|
||||
paperlessService = { updateDocument: jest.fn().mockResolvedValue({}) };
|
||||
/** Baut den Service; `errorTagId` steuert POSTPROCESSING_ERROR_TAG. */
|
||||
async function createService(errorTagId = 0) {
|
||||
ppRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
actionRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
logRepo = {
|
||||
create: jest.fn().mockImplementation((d: unknown) => d),
|
||||
save: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
paperlessService = {
|
||||
updateDocument: jest.fn().mockResolvedValue({}),
|
||||
getCorrespondents: jest.fn().mockResolvedValue({ results: [] }),
|
||||
getDocumentTypes: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -58,21 +70,41 @@ describe('PostprocessingService', () => {
|
||||
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
|
||||
{
|
||||
provide: getRepositoryToken(PostprocessingAction),
|
||||
useValue: ppActionRepo,
|
||||
useValue: actionRepo,
|
||||
},
|
||||
{ provide: getRepositoryToken(PostprocessingLog), useValue: logRepo },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { get: jest.fn().mockReturnValue(errorTagId) },
|
||||
},
|
||||
{ provide: PaperlessService, useValue: paperlessService },
|
||||
{ provide: MailService, useValue: { sendMail: jest.fn() } },
|
||||
{ provide: ExportService, useValue: { exportFile: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PostprocessingService>(PostprocessingService);
|
||||
return module.get<PostprocessingService>(PostprocessingService);
|
||||
}
|
||||
|
||||
const doc = (over: Record<string, any> = {}) => ({
|
||||
id: 100,
|
||||
document_type: 5,
|
||||
correspondent: null,
|
||||
tags: [],
|
||||
title: 'Rechnung Mai',
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
service = await createService();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('evaluate loads active rules in order', async () => {
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
|
||||
it('lädt nur aktive Regeln in Reihenfolge', async () => {
|
||||
await service.evaluate(doc());
|
||||
|
||||
expect(ppRepo.find).toHaveBeenCalledWith({
|
||||
where: { IsActive: true },
|
||||
@@ -80,41 +112,150 @@ describe('PostprocessingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluate executes matching actions', async () => {
|
||||
// Rule1 matches documentTypeId=5 → action adds tag
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
|
||||
it('führt die Aktionen einer passenden Regel aus', async () => {
|
||||
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
|
||||
actionRepo.find.mockResolvedValue([addTagAction(1)]);
|
||||
|
||||
expect(ppActionRepo.find).toHaveBeenCalledWith({
|
||||
await service.evaluate(doc({ tags: [7] }));
|
||||
|
||||
expect(actionRepo.find).toHaveBeenCalledWith({
|
||||
where: { PostprocessingId: 1, IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, {
|
||||
tags: [99],
|
||||
tags: [7, 99],
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluate stops at NoFurther rule', async () => {
|
||||
// Rule1 matches, Rule2 also matches (no filters) + NoFurther → stops
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
|
||||
it('überspringt Regeln, deren Filter nicht zutrifft', async () => {
|
||||
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
|
||||
|
||||
// Actions loaded for rule 1 and rule 2, but no rule after rule 2
|
||||
expect(ppActionRepo.find).toHaveBeenCalledTimes(2);
|
||||
await service.evaluate(doc({ document_type: 999 }));
|
||||
|
||||
expect(actionRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evaluate skips non-matching rules', async () => {
|
||||
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
|
||||
ppActionRepo.find.mockResolvedValue([]);
|
||||
await service.evaluate({
|
||||
documentId: 100,
|
||||
documentTypeId: 999,
|
||||
tagIds: [],
|
||||
it('überspringt Regeln ohne Bedingungen', async () => {
|
||||
ppRepo.find.mockResolvedValue([
|
||||
rule({ Id: 1, FilterJson: { combinator: 'AND', rules: [] } }),
|
||||
]);
|
||||
|
||||
await service.evaluate(doc());
|
||||
|
||||
expect(actionRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Rule1 skipped, Rule2 matched → only 1 action lookup
|
||||
expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(ppActionRepo.find).toHaveBeenCalledWith({
|
||||
where: { PostprocessingId: 2, IsActive: true },
|
||||
it('bricht nach einer Regel mit NoFurther ab', async () => {
|
||||
ppRepo.find.mockResolvedValue([
|
||||
rule({ Id: 1, NoFurther: true }),
|
||||
rule({ Id: 2 }),
|
||||
]);
|
||||
|
||||
await service.evaluate(doc());
|
||||
|
||||
expect(actionRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(actionRepo.find).toHaveBeenCalledWith({
|
||||
where: { PostprocessingId: 1, IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('verlangt bei AND alle Bedingungen', async () => {
|
||||
ppRepo.find.mockResolvedValue([
|
||||
rule({
|
||||
Id: 1,
|
||||
FilterJson: {
|
||||
combinator: 'AND',
|
||||
rules: [
|
||||
{ field: 'document_type', operator: 'equals', value: 5 },
|
||||
{ field: 'title', operator: 'contains', value: 'Gutschrift' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.evaluate(doc());
|
||||
|
||||
expect(actionRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('genügt bei OR einer Bedingung', async () => {
|
||||
ppRepo.find.mockResolvedValue([
|
||||
rule({
|
||||
Id: 1,
|
||||
FilterJson: {
|
||||
combinator: 'OR',
|
||||
rules: [
|
||||
{ field: 'document_type', operator: 'equals', value: 999 },
|
||||
{ field: 'title', operator: 'contains', value: 'rechnung' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.evaluate(doc());
|
||||
|
||||
expect(actionRepo.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wertet verschachtelte Gruppen aus', async () => {
|
||||
ppRepo.find.mockResolvedValue([
|
||||
rule({
|
||||
Id: 1,
|
||||
FilterJson: {
|
||||
combinator: 'AND',
|
||||
rules: [
|
||||
{ field: 'document_type', operator: 'equals', value: 5 },
|
||||
{
|
||||
combinator: 'OR',
|
||||
rules: [
|
||||
{ field: 'tag', operator: 'contains', value: 42 },
|
||||
{ field: 'tag', operator: 'contains', value: 7 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.evaluate(doc({ tags: [7] }));
|
||||
|
||||
expect(actionRepo.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('protokolliert erfolgreiche Aktionen', async () => {
|
||||
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
|
||||
actionRepo.find.mockResolvedValue([addTagAction(1)]);
|
||||
|
||||
await service.evaluate(doc());
|
||||
|
||||
expect(logRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
PostprocessingId: 1,
|
||||
DocumentId: 100,
|
||||
Status: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('setzt bei fehlgeschlagener Aktion den Fehler-Tag und protokolliert ihn', async () => {
|
||||
service = await createService(500);
|
||||
ppRepo.find.mockResolvedValue([rule({ Id: 1 })]);
|
||||
actionRepo.find.mockResolvedValue([addTagAction(1)]);
|
||||
paperlessService.updateDocument.mockRejectedValueOnce(
|
||||
new Error('Paperless nicht erreichbar'),
|
||||
);
|
||||
|
||||
await service.evaluate(doc({ tags: [7] }));
|
||||
|
||||
expect(logRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Status: 'error',
|
||||
Message: 'Paperless nicht erreichbar',
|
||||
}),
|
||||
);
|
||||
expect(paperlessService.updateDocument).toHaveBeenLastCalledWith(100, {
|
||||
tags: [7, 500],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,14 @@ import { PostprocessingAction } from '../database/entities/postprocessing-action
|
||||
import { UserClient } from '../database/entities/user-client.entity';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import { Setting } from '../database/entities/setting.entity';
|
||||
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
|
||||
import { ExportTarget } from '../database/entities/export-target.entity';
|
||||
import { ClientIdentifier } from '../database/entities/client-identifier.entity';
|
||||
import { DocumentField } from '../database/entities/document-field.entity';
|
||||
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
|
||||
import { InboxPostprocessingAction } from '../database/entities/inbox-postprocessing-action.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { ExportService } from '../postprocessing/export.service';
|
||||
|
||||
const makeRepo = (data: any[] = []) => ({
|
||||
find: jest.fn().mockResolvedValue(data),
|
||||
@@ -23,8 +31,10 @@ describe('SettingsController', () => {
|
||||
let ppRepo: ReturnType<typeof makeRepo>;
|
||||
let ppActionRepo: ReturnType<typeof makeRepo>;
|
||||
let userClientRepo: ReturnType<typeof makeRepo>;
|
||||
let paperlessService: { getDocumentTypes: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
paperlessService = { getDocumentTypes: jest.fn().mockResolvedValue([]) };
|
||||
docTypeRepo = makeRepo([{ Id: 1, DocumentTypeId: 10, TitelTemplate: 'T' }]);
|
||||
ppRepo = makeRepo([{ Id: 1, Name: 'Rule1', Order: 1, IsActive: true }]);
|
||||
ppActionRepo = makeRepo([]);
|
||||
@@ -45,6 +55,23 @@ describe('SettingsController', () => {
|
||||
provide: getRepositoryToken(Setting),
|
||||
useValue: makeRepo([{ ID: 1, Typ: 1, Wert: 'v' }]),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(PostprocessingLog),
|
||||
useValue: makeRepo(),
|
||||
},
|
||||
{ provide: getRepositoryToken(ExportTarget), useValue: makeRepo() },
|
||||
{ provide: getRepositoryToken(ClientIdentifier), useValue: makeRepo() },
|
||||
{ provide: getRepositoryToken(DocumentField), useValue: makeRepo() },
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondentSetting),
|
||||
useValue: makeRepo(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(InboxPostprocessingAction),
|
||||
useValue: makeRepo(),
|
||||
},
|
||||
{ provide: PaperlessService, useValue: paperlessService },
|
||||
{ provide: ExportService, useValue: { testConnection: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -62,6 +89,30 @@ describe('SettingsController', () => {
|
||||
expect(docTypeRepo.find).toHaveBeenCalledWith({ order: { Id: 'ASC' } });
|
||||
});
|
||||
|
||||
it('getDocumentTypes legt in Paperless neu angelegte Typen an', async () => {
|
||||
paperlessService.getDocumentTypes.mockResolvedValue([
|
||||
{ id: 10, name: 'Bekannt' },
|
||||
{ id: 11, name: 'Neu' },
|
||||
]);
|
||||
|
||||
await controller.getDocumentTypes();
|
||||
|
||||
expect(docTypeRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(docTypeRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ DocumentTypeId: 11 }),
|
||||
);
|
||||
expect(docTypeRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getDocumentTypes liefert die Liste auch bei Paperless-Ausfall', async () => {
|
||||
paperlessService.getDocumentTypes.mockRejectedValue(new Error('down'));
|
||||
|
||||
const result = await controller.getDocumentTypes();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(docTypeRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateDocumentType calls update + findOneByOrFail', async () => {
|
||||
await controller.updateDocumentType('1', { TitelTemplate: 'New' });
|
||||
expect(docTypeRepo.update).toHaveBeenCalledWith(1, {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Body,
|
||||
Query,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -20,6 +22,11 @@ import { ExportTarget } from '../database/entities/export-target.entity';
|
||||
import { UserClient } from '../database/entities/user-client.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import {
|
||||
ClientIdentifier,
|
||||
ClientIdentifierTyp,
|
||||
CLIENT_IDENTIFIER_TYPEN,
|
||||
} from '../database/entities/client-identifier.entity';
|
||||
import { Setting } from '../database/entities/setting.entity';
|
||||
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
|
||||
import {
|
||||
@@ -29,6 +36,7 @@ import {
|
||||
import { ExportService } from '../postprocessing/export.service';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
import { normalisiereKennung } from '../erechnung/mandant-matcher.service';
|
||||
|
||||
@Controller('api/settings')
|
||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||
@@ -49,6 +57,8 @@ export class SettingsController {
|
||||
@InjectRepository(UserClient)
|
||||
private readonly userClientRepo: Repository<UserClient>,
|
||||
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
||||
@InjectRepository(ClientIdentifier)
|
||||
private readonly clientIdentifierRepo: Repository<ClientIdentifier>,
|
||||
@InjectRepository(Setting)
|
||||
private readonly settingRepo: Repository<Setting>,
|
||||
@InjectRepository(DocumentField)
|
||||
@@ -520,4 +530,56 @@ export class SettingsController {
|
||||
});
|
||||
return this.clientRepo.findOneByOrFail({ Id: clientId });
|
||||
}
|
||||
|
||||
// === E-Rechnungs-Kennungen der Mandanten ===
|
||||
@Get('client-identifiers')
|
||||
async getClientIdentifiers() {
|
||||
return this.clientIdentifierRepo.find({
|
||||
order: { ClientId: 'ASC', Typ: 'ASC', Wert: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
@Post('client-identifiers')
|
||||
async createClientIdentifier(
|
||||
@Body() body: { ClientId: number; Typ: ClientIdentifierTyp; Wert: string },
|
||||
) {
|
||||
const clientId = Number(body.ClientId);
|
||||
if (!clientId || isNaN(clientId)) {
|
||||
throw new BadRequestException('Mandant ist erforderlich');
|
||||
}
|
||||
if (!CLIENT_IDENTIFIER_TYPEN.includes(body.Typ)) {
|
||||
throw new BadRequestException('Unbekannte Kennungsart');
|
||||
}
|
||||
|
||||
// Gleiche Normalisierung wie beim Matching, sonst greift die Zuordnung nie.
|
||||
const wert = normalisiereKennung(body.Wert);
|
||||
if (!wert) throw new BadRequestException('Kennung ist erforderlich');
|
||||
if (wert.length > 100) {
|
||||
throw new BadRequestException('Kennung ist zu lang (max. 100 Zeichen)');
|
||||
}
|
||||
|
||||
const vorhanden = await this.clientIdentifierRepo.findOneBy({
|
||||
Typ: body.Typ,
|
||||
Wert: wert,
|
||||
});
|
||||
if (vorhanden) {
|
||||
throw new ConflictException(
|
||||
'Diese Kennung ist bereits einem Mandanten zugeordnet.',
|
||||
);
|
||||
}
|
||||
|
||||
return this.clientIdentifierRepo.save(
|
||||
this.clientIdentifierRepo.create({
|
||||
ClientId: clientId,
|
||||
Typ: body.Typ,
|
||||
Wert: wert,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('client-identifiers/:id')
|
||||
async deleteClientIdentifier(@Param('id') id: string) {
|
||||
await this.clientIdentifierRepo.delete(parseInt(id, 10));
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PostprocessingLog } from '../database/entities/postprocessing-log.entit
|
||||
import { ExportTarget } from '../database/entities/export-target.entity';
|
||||
import { UserClient } from '../database/entities/user-client.entity';
|
||||
import { Client } from '../database/entities/client.entity';
|
||||
import { ClientIdentifier } from '../database/entities/client-identifier.entity';
|
||||
import { Setting } from '../database/entities/setting.entity';
|
||||
import { DocumentField } from '../database/entities/document-field.entity';
|
||||
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
|
||||
@@ -25,6 +26,7 @@ import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
||||
ExportTarget,
|
||||
UserClient,
|
||||
Client,
|
||||
ClientIdentifier,
|
||||
Setting,
|
||||
DocumentField,
|
||||
CorrespondentSetting,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { UserSettings } from '../database/entities/user-settings.entity';
|
||||
import { UserIdentitySyncService } from './user-identity-sync.service';
|
||||
|
||||
describe('UserIdentitySyncService', () => {
|
||||
let service: UserIdentitySyncService;
|
||||
let repo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
repo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((data: Partial<UserSettings>) => ({ ...data })),
|
||||
save: jest.fn((e: UserSettings) => Promise.resolve(e)),
|
||||
};
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserIdentitySyncService,
|
||||
{ provide: getRepositoryToken(UserSettings), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(UserIdentitySyncService);
|
||||
});
|
||||
|
||||
const identity = {
|
||||
userId: 'sub-1',
|
||||
email: 'nutzer@example.com',
|
||||
preferredUsername: 'nutzer',
|
||||
groups: ['PM_Posteingang'],
|
||||
};
|
||||
|
||||
it('legt eine Zeile an, wenn der Benutzer noch keine Einstellungen hat', async () => {
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
|
||||
await service.sync(identity);
|
||||
|
||||
expect(repo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
UserId: 'sub-1',
|
||||
UserEmail: 'nutzer@example.com',
|
||||
UserPreferredUsername: 'nutzer',
|
||||
UserGroups: ['PM_Posteingang'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schreibt entzogene Gruppen in die Datenbank', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
UserId: 'sub-1',
|
||||
UserEmail: 'nutzer@example.com',
|
||||
UserPreferredUsername: 'nutzer',
|
||||
UserGroups: ['PM_Posteingang', 'PM_Buchhaltung'],
|
||||
} as UserSettings);
|
||||
|
||||
await service.sync(identity);
|
||||
|
||||
expect(repo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ UserGroups: ['PM_Posteingang'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('schreibt nicht, wenn sich nichts geändert hat', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
UserId: 'sub-1',
|
||||
UserEmail: 'nutzer@example.com',
|
||||
UserPreferredUsername: 'nutzer',
|
||||
UserGroups: ['PM_Posteingang'],
|
||||
} as UserSettings);
|
||||
|
||||
await service.sync(identity);
|
||||
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fragt die Datenbank bei unverändertem Token nicht erneut ab', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
UserId: 'sub-1',
|
||||
UserEmail: 'nutzer@example.com',
|
||||
UserPreferredUsername: 'nutzer',
|
||||
UserGroups: ['PM_Posteingang'],
|
||||
} as UserSettings);
|
||||
|
||||
await service.sync(identity);
|
||||
await service.sync(identity);
|
||||
await service.sync({ ...identity, groups: ['PM_Posteingang'] });
|
||||
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('erkennt eine Gruppenänderung trotz Cache', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
UserId: 'sub-1',
|
||||
UserEmail: 'nutzer@example.com',
|
||||
UserPreferredUsername: 'nutzer',
|
||||
UserGroups: ['PM_Posteingang'],
|
||||
} as UserSettings);
|
||||
|
||||
await service.sync(identity);
|
||||
await service.sync({ ...identity, groups: [] });
|
||||
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(repo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ UserGroups: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignoriert Requests ohne Benutzer-ID', async () => {
|
||||
await service.sync({ userId: '' });
|
||||
expect(repo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lässt Fehler beim Hintergrund-Abgleich den Request nicht abbrechen', () => {
|
||||
repo.findOne.mockRejectedValue(new Error('DB weg'));
|
||||
expect(() => service.syncInBackground(identity)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserSettings } from '../database/entities/user-settings.entity';
|
||||
|
||||
export interface TokenIdentity {
|
||||
userId: string;
|
||||
email?: string | null;
|
||||
preferredUsername?: string | null;
|
||||
groups?: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält E-Mail, Benutzername und Gruppen in `user_settings` aktuell.
|
||||
*
|
||||
* Der tägliche Digest läuft ohne Token und liest die Gruppen aus der Datenbank.
|
||||
* Ohne diesen Abgleich bliebe dort der Stand stehen, den der Nutzer zuletzt
|
||||
* beim Öffnen der Benutzereinstellungen hinterlassen hat – entzogene
|
||||
* Berechtigungen würden weiter Kacheln in der Tagesübersicht erzeugen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserIdentitySyncService {
|
||||
private readonly logger = new Logger(UserIdentitySyncService.name);
|
||||
|
||||
/** userId → zuletzt geschriebener Stand, spart Schreibzugriffe pro Request. */
|
||||
private readonly lastSynced = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserSettings)
|
||||
private readonly repo: Repository<UserSettings>,
|
||||
) {}
|
||||
|
||||
/** Stößt den Abgleich an, ohne den Request zu blockieren. */
|
||||
syncInBackground(identity: TokenIdentity): void {
|
||||
void this.sync(identity).catch((err: unknown) =>
|
||||
this.logger.warn(
|
||||
`Identität für ${identity.userId} konnte nicht abgeglichen werden: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async sync(identity: TokenIdentity): Promise<void> {
|
||||
const { userId } = identity;
|
||||
if (!userId) return;
|
||||
|
||||
const email = identity.email ?? null;
|
||||
const preferredUsername = identity.preferredUsername ?? null;
|
||||
const groups = Array.isArray(identity.groups) ? identity.groups : null;
|
||||
|
||||
const fingerprint = JSON.stringify([
|
||||
email,
|
||||
preferredUsername,
|
||||
groups ? [...groups].sort() : null,
|
||||
]);
|
||||
if (this.lastSynced.get(userId) === fingerprint) return;
|
||||
|
||||
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||
if (!entity) {
|
||||
entity = this.repo.create({ UserId: userId });
|
||||
}
|
||||
|
||||
const unchanged =
|
||||
entity.UserEmail === email &&
|
||||
entity.UserPreferredUsername === preferredUsername &&
|
||||
JSON.stringify(entity.UserGroups ?? null) === JSON.stringify(groups);
|
||||
|
||||
if (!unchanged) {
|
||||
const vorher = entity.UserGroups;
|
||||
entity.UserEmail = email;
|
||||
entity.UserPreferredUsername = preferredUsername;
|
||||
entity.UserGroups = groups;
|
||||
await this.repo.save(entity);
|
||||
this.logger.log(
|
||||
`Identität aktualisiert für ${email ?? userId}: Gruppen ${JSON.stringify(vorher)} → ${JSON.stringify(groups)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.lastSynced.set(userId, fingerprint);
|
||||
}
|
||||
}
|
||||
@@ -17,23 +17,12 @@ export class UserSettingsController {
|
||||
|
||||
@Get()
|
||||
async getSettings(@Request() req: any) {
|
||||
return this.userSettingsService.getSettings(
|
||||
req.user.userId,
|
||||
req.user.email,
|
||||
req.user.preferredUsername,
|
||||
req.user.groups,
|
||||
);
|
||||
return this.userSettingsService.getSettings(req.user.userId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
async updateSettings(@Request() req: any, @Body() body: any) {
|
||||
return this.userSettingsService.updateSettings(
|
||||
req.user.userId,
|
||||
body,
|
||||
req.user.email,
|
||||
req.user.preferredUsername,
|
||||
req.user.groups,
|
||||
);
|
||||
return this.userSettingsService.updateSettings(req.user.userId, body);
|
||||
}
|
||||
|
||||
@Get('senders')
|
||||
|
||||
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserSettings } from '../database/entities/user-settings.entity';
|
||||
import { UserSettingsService } from './user-settings.service';
|
||||
import { UserSettingsController } from './user-settings.controller';
|
||||
import { UserIdentitySyncService } from './user-identity-sync.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserSettings])],
|
||||
providers: [UserSettingsService],
|
||||
providers: [UserSettingsService, UserIdentitySyncService],
|
||||
controllers: [UserSettingsController],
|
||||
exports: [UserSettingsService],
|
||||
exports: [UserSettingsService, UserIdentitySyncService],
|
||||
})
|
||||
export class UserSettingsModule {}
|
||||
|
||||
@@ -71,22 +71,10 @@ export class UserSettingsService {
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
async getSettings(
|
||||
userId: string,
|
||||
email?: string,
|
||||
preferredUsername?: string,
|
||||
groups?: string[],
|
||||
): Promise<UserSettingsDto> {
|
||||
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||
if (email || preferredUsername || groups) {
|
||||
if (!entity) {
|
||||
entity = this.repo.create({ UserId: userId });
|
||||
}
|
||||
if (email) entity.UserEmail = email;
|
||||
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
|
||||
if (groups) entity.UserGroups = groups;
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
// E-Mail, Benutzername und Gruppen werden zentral vom
|
||||
// UserIdentitySyncService aus dem Token gepflegt, nicht hier.
|
||||
async getSettings(userId: string): Promise<UserSettingsDto> {
|
||||
const entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||
return this.toDto(entity);
|
||||
}
|
||||
|
||||
@@ -105,9 +93,6 @@ export class UserSettingsService {
|
||||
emailRecipientHistory?: string[] | null;
|
||||
dailyDigestEnabled?: boolean;
|
||||
},
|
||||
email?: string,
|
||||
preferredUsername?: string,
|
||||
groups?: string[],
|
||||
): Promise<UserSettingsDto> {
|
||||
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||
if (!entity) {
|
||||
@@ -136,10 +121,6 @@ export class UserSettingsService {
|
||||
entity.EmailRecipientHistory = data.emailRecipientHistory;
|
||||
if (data.dailyDigestEnabled !== undefined)
|
||||
entity.DailyDigestEnabled = data.dailyDigestEnabled;
|
||||
if (email) entity.UserEmail = email;
|
||||
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
|
||||
if (groups) entity.UserGroups = groups;
|
||||
|
||||
await this.repo.save(entity);
|
||||
return this.toDto(entity);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
// PaperlessProcessorService zieht über die Postprocessing-Kette das ESM-Paket
|
||||
// "webdav" nach, das Jest nicht transformiert. Für diesen Unit-Test ersetzen wir
|
||||
// das Modul durch eine Dummy-Klasse – der Service erhält seine Abhängigkeiten
|
||||
// ohnehin als Mocks injiziert.
|
||||
jest.mock('../paperless/paperless-processor.service', () => ({
|
||||
PaperlessProcessorService: class {},
|
||||
}));
|
||||
|
||||
import { WebhookQueueService } from './webhook-queue.service';
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,23 @@ export interface DocumentRequirement {
|
||||
fieldOptions?: { id: string | number; label: string }[];
|
||||
}
|
||||
|
||||
export interface ERechnungVorschlag {
|
||||
/** Ob das Dokument überhaupt eine auswertbare E-Rechnung enthält. */
|
||||
erechnung: boolean;
|
||||
mandantId: number | null;
|
||||
belegartId: number | null;
|
||||
/** Kurztext, wenn kein Vorschlag möglich war. */
|
||||
hinweis: string | null;
|
||||
rechnung: {
|
||||
rechnungsnummer: string | null;
|
||||
rechnungsdatum: string | null;
|
||||
gesamtbetrag: number | null;
|
||||
waehrung: string | null;
|
||||
verkaeufer: string | null;
|
||||
typeCode: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Kontonummer {
|
||||
KontonummerId?: number;
|
||||
CorrespondentId: number;
|
||||
@@ -54,4 +71,7 @@ export const posteingangApi = {
|
||||
|
||||
createKontonummer: (data: { correspondentId: number; nummer: string }) =>
|
||||
api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data),
|
||||
|
||||
getErechnung: (id: number) =>
|
||||
api.get<ERechnungVorschlag>(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface SettingDocType {
|
||||
TagNotReady: number | null;
|
||||
TagReady: number | null;
|
||||
FreigabeErforderlich?: boolean | null;
|
||||
/** Rechnungstyp-Codes (BT-3) einer E-Rechnung, kommagetrennt (z. B. "380,384") */
|
||||
ERechnungTypeCodes?: string | null;
|
||||
}
|
||||
|
||||
export interface SettingDocField {
|
||||
@@ -53,7 +55,7 @@ export interface SettingPostprocessingAction {
|
||||
export interface SettingExportTarget {
|
||||
Id: number;
|
||||
Name: string;
|
||||
Protocol: string; // 'ftp' | 'webdav'
|
||||
Protocol: string; // 'ftp'
|
||||
Host: string;
|
||||
Port: number | null;
|
||||
Username: string | null;
|
||||
@@ -86,6 +88,22 @@ export interface SettingClient {
|
||||
AgrarmonitorBetriebId: number | null;
|
||||
}
|
||||
|
||||
/** Kennungsarten, über die ein Mandant in einer E-Rechnung erkannt wird. */
|
||||
export type ClientIdentifierTyp = 'ustid' | 'leitwegid' | 'kaeuferkennung';
|
||||
|
||||
export interface SettingClientIdentifier {
|
||||
Id: number;
|
||||
ClientId: number;
|
||||
Typ: ClientIdentifierTyp;
|
||||
Wert: string;
|
||||
}
|
||||
|
||||
export const CLIENT_IDENTIFIER_LABELS: Record<ClientIdentifierTyp, string> = {
|
||||
ustid: 'USt-IdNr.',
|
||||
leitwegid: 'Leitweg-ID',
|
||||
kaeuferkennung: 'Käufer-Kennung',
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
// Dokumenttypen
|
||||
getDocTypes: () => api.get<SettingDocType[]>('/api/settings/document-types').then(r => r.data),
|
||||
@@ -162,6 +180,14 @@ export const settingsApi = {
|
||||
updateClient: (id: number, AgrarmonitorBetriebId: number | null) =>
|
||||
api.put<SettingClient>(`/api/settings/clients/${id}`, { AgrarmonitorBetriebId }).then(r => r.data),
|
||||
|
||||
// E-Rechnungs-Kennungen der Mandanten
|
||||
getClientIdentifiers: () =>
|
||||
api.get<SettingClientIdentifier[]>('/api/settings/client-identifiers').then(r => r.data),
|
||||
createClientIdentifier: (data: { ClientId: number; Typ: ClientIdentifierTyp; Wert: string }) =>
|
||||
api.post<SettingClientIdentifier>('/api/settings/client-identifiers', data).then(r => r.data),
|
||||
deleteClientIdentifier: (id: number) =>
|
||||
api.delete(`/api/settings/client-identifiers/${id}`).then(r => r.data),
|
||||
|
||||
// Inbox-Postprozessor (global, deprecated)
|
||||
listInboxActions: () =>
|
||||
api.get<InboxAction[]>('/api/settings/inbox-actions').then((r) => r.data),
|
||||
@@ -191,7 +217,7 @@ export interface InboxAction {
|
||||
|
||||
export const INBOX_ACTION_LABELS: Record<InboxActionType, string> = {
|
||||
MAIL: 'Per E-Mail senden',
|
||||
EXPORT: 'Export (FTP/WebDAV)',
|
||||
EXPORT: 'Export (FTP)',
|
||||
PAPERLESS: 'In Paperless importieren',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
|
||||
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag, Alert } from 'antd';
|
||||
import { PlusOutlined, EyeOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { posteingangApi } from '../api/posteingang';
|
||||
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
|
||||
import type { DocumentRequirement, PosteingangDocument, Kontonummer, ERechnungVorschlag } from '../api/posteingang';
|
||||
import { clientsApi } from '../api/inbox';
|
||||
import type { Client } from '../api/inbox';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
@@ -42,6 +42,10 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
const [steuertagIds, setSteuertagIds] = useState<number[]>([]);
|
||||
const contentTags = allTags.filter(t => !steuertagIds.includes(t.id));
|
||||
|
||||
// E-Rechnung: ausgelesener Vorschlag und die Felder, die daraus befüllt wurden
|
||||
const [erechnung, setErechnung] = useState<ERechnungVorschlag | null>(null);
|
||||
const [ausERechnung, setAusERechnung] = useState<{ mandant: boolean, documentType: boolean }>({ mandant: false, documentType: false });
|
||||
|
||||
const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null);
|
||||
const [docTitles, setDocTitles] = useState<Record<number, string>>({});
|
||||
const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null);
|
||||
@@ -117,6 +121,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
setKontonummerMissing(null);
|
||||
setKontonummern([]);
|
||||
setNewKontonummer('');
|
||||
setErechnung(null);
|
||||
setAusERechnung({ mandant: false, documentType: false });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -155,6 +161,41 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
}
|
||||
}, [document, open, steuertagIds, form]);
|
||||
|
||||
// E-Rechnung auswerten und Mandant/Belegart vorschlagen. Es werden nur leere
|
||||
// Felder befüllt – ein bereits gesetzter Wert stammt vom Benutzer oder einer
|
||||
// Regel und wird nicht überschrieben.
|
||||
useEffect(() => {
|
||||
setErechnung(null);
|
||||
setAusERechnung({ mandant: false, documentType: false });
|
||||
if (!open || !isPosteingang || !documentId) return;
|
||||
|
||||
let abgebrochen = false;
|
||||
(async () => {
|
||||
try {
|
||||
const vorschlag = await posteingangApi.getErechnung(documentId);
|
||||
if (abgebrochen || !vorschlag.erechnung) return;
|
||||
setErechnung(vorschlag);
|
||||
|
||||
const uebernommen = { mandant: false, documentType: false };
|
||||
if (vorschlag.mandantId && !form.getFieldValue('mandant')) {
|
||||
form.setFieldValue('mandant', vorschlag.mandantId);
|
||||
uebernommen.mandant = true;
|
||||
}
|
||||
if (vorschlag.belegartId && !form.getFieldValue('documentType')) {
|
||||
form.setFieldValue('documentType', vorschlag.belegartId);
|
||||
uebernommen.documentType = true;
|
||||
fetchRequirements(vorschlag.belegartId);
|
||||
}
|
||||
setAusERechnung(uebernommen);
|
||||
} catch {
|
||||
// Die Auswertung ist eine Hilfestellung – ein Fehler darf das
|
||||
// Bearbeiten nicht stören und wird deshalb nicht gemeldet.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { abgebrochen = true; };
|
||||
}, [open, isPosteingang, documentId]);
|
||||
|
||||
const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => {
|
||||
if (!correspondentId) return;
|
||||
|
||||
@@ -252,9 +293,33 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
};
|
||||
|
||||
const handleDocumentTypeChange = (value: number) => {
|
||||
setAusERechnung(prev => ({ ...prev, documentType: false }));
|
||||
fetchRequirements(value);
|
||||
};
|
||||
|
||||
/** Kurzbeschreibung der erkannten Rechnung zur Sichtkontrolle. */
|
||||
const beschreibeRechnung = (v: ERechnungVorschlag) => {
|
||||
const r = v.rechnung;
|
||||
const teile: string[] = [];
|
||||
if (r?.verkaeufer) teile.push(r.verkaeufer);
|
||||
if (r?.rechnungsnummer) teile.push(`Nr. ${r.rechnungsnummer}`);
|
||||
if (r?.rechnungsdatum) teile.push(dayjs(r.rechnungsdatum).format('DD.MM.YYYY'));
|
||||
if (r?.gesamtbetrag !== null && r?.gesamtbetrag !== undefined) {
|
||||
const waehrung = /^[A-Z]{3}$/.test(r.waehrung || '') ? r.waehrung! : 'EUR';
|
||||
teile.push(new Intl.NumberFormat('de-DE', { style: 'currency', currency: waehrung }).format(r.gesamtbetrag));
|
||||
}
|
||||
return teile.length ? teile.join(' \u00b7 ') : 'Keine weiteren Angaben im Rechnungs-XML.';
|
||||
};
|
||||
|
||||
/** Label mit Chip, solange der Wert aus der E-Rechnung stammt. */
|
||||
const labelMitHerkunft = (text: string, ausRechnung: boolean) =>
|
||||
ausRechnung ? (
|
||||
<Space size={4}>
|
||||
{text}
|
||||
<Tag color="blue" style={{ marginInlineEnd: 0 }}>aus E-Rechnung</Tag>
|
||||
</Space>
|
||||
) : text;
|
||||
|
||||
const handleSaveDocument = async (values: any, isNext: boolean = false) => {
|
||||
// Collect custom fields into an array
|
||||
const customFieldsObj: any = {};
|
||||
@@ -279,6 +344,9 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null,
|
||||
tags: values.tags || [],
|
||||
customFields: customFieldsObj,
|
||||
// Signal fürs Backend, die Käufer-Kennungen auf den gespeicherten
|
||||
// Mandanten fortzuschreiben.
|
||||
erechnung: erechnung?.erechnung === true,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
@@ -394,17 +462,37 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
lg={10}
|
||||
style={isMobile ? {} : { overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}
|
||||
>
|
||||
{erechnung?.erechnung && (
|
||||
<Alert
|
||||
type={erechnung.hinweis ? 'warning' : 'info'}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="E-Rechnung erkannt"
|
||||
description={
|
||||
<>
|
||||
<div>{beschreibeRechnung(erechnung)}</div>
|
||||
{erechnung.hinweis && <div style={{ marginTop: 4 }}>{erechnung.hinweis}</div>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form form={form} layout="vertical" disabled={saving}>
|
||||
|
||||
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
|
||||
<Select showSearch optionFilterProp="children" allowClear>
|
||||
<Form.Item name="mandant" label={labelMitHerkunft('Mandant', ausERechnung.mandant)} rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
allowClear
|
||||
onChange={() => setAusERechnung(prev => ({ ...prev, mandant: false }))}
|
||||
>
|
||||
{clients.map(c => (
|
||||
<Option key={c.Id} value={c.PaperlessUserId}>{c.Name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="documentType" label="Dokumentart" rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
|
||||
<Form.Item name="documentType" label={labelMitHerkunft('Dokumentart', ausERechnung.documentType)} rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
|
||||
<Select showSearch optionFilterProp="children" onChange={handleDocumentTypeChange}>
|
||||
{documentTypes.map(d => (
|
||||
<Option key={d.id} value={d.id}>{d.name}</Option>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
agrarmonitorApi, type AgrarmonitorStatusData,
|
||||
type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult,
|
||||
type SyncConflict,
|
||||
type SettingClientIdentifier, type ClientIdentifierTyp, CLIENT_IDENTIFIER_LABELS,
|
||||
} from '../api/settings';
|
||||
import { clientsApi, type Client } from '../api/inbox';
|
||||
import { apiKeysApi, type ApiKey } from '../api/api-keys';
|
||||
@@ -223,6 +224,12 @@ function UserClientsTab() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// E-Rechnungs-Kennungen der Mandanten
|
||||
const [identifiers, setIdentifiers] = useState<SettingClientIdentifier[]>([]);
|
||||
const [identLoading, setIdentLoading] = useState(false);
|
||||
const [identModalOpen, setIdentModalOpen] = useState(false);
|
||||
const [identForm] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -235,6 +242,13 @@ function UserClientsTab() {
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadIdentifiers = useCallback(async () => {
|
||||
setIdentLoading(true);
|
||||
try {
|
||||
setIdentifiers(await settingsApi.getClientIdentifiers());
|
||||
} finally { setIdentLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadAllClients = useCallback(async () => {
|
||||
setClientsLoading(true);
|
||||
try {
|
||||
@@ -243,7 +257,7 @@ function UserClientsTab() {
|
||||
} finally { setClientsLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); loadAllClients(); }, [load, loadAllClients]);
|
||||
useEffect(() => { load(); loadAllClients(); loadIdentifiers(); }, [load, loadAllClients, loadIdentifiers]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const values = await form.validateFields();
|
||||
@@ -269,6 +283,52 @@ function UserClientsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddIdentifier = async () => {
|
||||
const values = await identForm.validateFields();
|
||||
try {
|
||||
await settingsApi.createClientIdentifier(values);
|
||||
message.success('Kennung hinzugefügt');
|
||||
setIdentModalOpen(false);
|
||||
identForm.resetFields();
|
||||
loadIdentifiers();
|
||||
} catch (e) {
|
||||
const meldung = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
message.error(meldung ?? 'Kennung konnte nicht angelegt werden');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteIdentifier = async (id: number) => {
|
||||
await settingsApi.deleteClientIdentifier(id);
|
||||
message.success('Gelöscht');
|
||||
loadIdentifiers();
|
||||
};
|
||||
|
||||
const identifierColumns: ColumnsType<SettingClientIdentifier> = [
|
||||
{
|
||||
title: 'Betrieb',
|
||||
key: 'client',
|
||||
render: (_, r) => allClients.find(c => c.Id === r.ClientId)?.Name ?? r.ClientId,
|
||||
},
|
||||
{
|
||||
title: 'Art',
|
||||
dataIndex: 'Typ',
|
||||
key: 'typ',
|
||||
width: 160,
|
||||
render: (t: ClientIdentifierTyp) => <Tag>{CLIENT_IDENTIFIER_LABELS[t] ?? t}</Tag>,
|
||||
},
|
||||
{ title: 'Kennung', dataIndex: 'Wert', key: 'wert' },
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Popconfirm title="Kennung wirklich löschen?" onConfirm={() => handleDeleteIdentifier(record.Id)}>
|
||||
<Button danger icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const allClientColumns: ColumnsType<SettingClient> = [
|
||||
{ title: 'Name', dataIndex: 'Name', key: 'name' },
|
||||
{
|
||||
@@ -338,6 +398,62 @@ function UserClientsTab() {
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe — E-Rechnungs-Kennungen</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Über diese Käufer-Kennungen wird der Mandant aus einer E-Rechnung erkannt. Neue Kennungen
|
||||
werden beim Speichern eines Belegs automatisch gelernt — hier lassen sie sich prüfen,
|
||||
ergänzen und korrigieren. Jede Kennung darf nur einem Betrieb gehören.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIdentModalOpen(true)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
Kennung hinzufügen
|
||||
</Button>
|
||||
<Table
|
||||
dataSource={identifiers}
|
||||
columns={identifierColumns}
|
||||
loading={identLoading}
|
||||
rowKey="Id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: 'Noch keine Kennungen — die erste entsteht, sobald ein Beleg mit E-Rechnung gespeichert wird.' }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Neue E-Rechnungs-Kennung"
|
||||
open={identModalOpen}
|
||||
onOk={handleAddIdentifier}
|
||||
onCancel={() => { setIdentModalOpen(false); identForm.resetFields(); }}
|
||||
>
|
||||
<Form form={identForm} layout="vertical">
|
||||
<Form.Item name="ClientId" label="Betrieb" rules={[{ required: true, message: 'Betrieb wählen' }]}>
|
||||
<Select showSearch optionFilterProp="children">
|
||||
{allClients.map(c => <Select.Option key={c.Id} value={c.Id}>{c.Name}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="Typ" label="Art der Kennung" rules={[{ required: true, message: 'Art wählen' }]}>
|
||||
<Select>
|
||||
{(Object.keys(CLIENT_IDENTIFIER_LABELS) as ClientIdentifierTyp[]).map(t => (
|
||||
<Select.Option key={t} value={t}>{CLIENT_IDENTIFIER_LABELS[t]}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="Wert"
|
||||
label="Kennung"
|
||||
rules={[{ required: true, message: 'Kennung eingeben' }]}
|
||||
tooltip="Groß-/Kleinschreibung sowie Leer- und Trennzeichen sind egal — sie werden beim Speichern vereinheitlicht."
|
||||
>
|
||||
<Input placeholder="z.B. DE123456789" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="UserId" label="User ID (Authentik)" rules={[{ required: true }]}>
|
||||
@@ -588,6 +704,12 @@ function DocTypesTab() {
|
||||
key: 'freigabe',
|
||||
render: (v: boolean | null) => v ? <Tag color="blue">Ja</Tag> : '—',
|
||||
},
|
||||
{
|
||||
title: 'E-Rechnungs-Codes',
|
||||
dataIndex: 'ERechnungTypeCodes',
|
||||
key: 'erechnung',
|
||||
render: (v: string | null) => v ? <Tag>{v}</Tag> : '—',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
@@ -637,6 +759,13 @@ function DocTypesTab() {
|
||||
<Form.Item name="FreigabeErforderlich" valuePropName="checked" label="Freigabe erforderlich">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="ERechnungTypeCodes"
|
||||
label="E-Rechnungs-Codes (BT-3)"
|
||||
tooltip="Rechnungstypen, die auf diese Belegart abgebildet werden – kommagetrennt. Üblich: 380 = Rechnung, 384 = korrigierte Rechnung, 381 = Gutschrift, 389 = Gutschriftverfahren. Leer lassen, wenn diese Belegart nie vorgeschlagen werden soll."
|
||||
>
|
||||
<Input placeholder="z.B. 380,384" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{editing && (
|
||||
@@ -655,7 +784,7 @@ function DocTypesTab() {
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
const ACTION_TYPE_LABELS: Record<number, string> = {
|
||||
1: 'Export (FTP/WebDAV)',
|
||||
1: 'Export (FTP)',
|
||||
2: 'Mail versenden',
|
||||
3: 'Tags setzen/entfernen',
|
||||
4: 'Custom Field setzen',
|
||||
@@ -1191,7 +1320,6 @@ function ExportTargetsTab() {
|
||||
<Form.Item name="Protocol" label="Protokoll" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Select.Option value="ftp">FTP</Select.Option>
|
||||
<Select.Option value="webdav">WebDAV</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="Host" label="Host" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user