fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
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:
2026-09-01 00:06:50 +02:00
co-authored by Claude Opus 5
parent 32ba772e4e
commit b7b65c64ae
41 changed files with 2462 additions and 443 deletions
+8 -1
View File
@@ -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.
+77 -260
View File
@@ -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",
+2 -2
View File
@@ -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",
+2
View File
@@ -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: [
+12 -2
View File
@@ -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
@@ -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&#246;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&#246;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&#246;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 (&#246;) 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';
/**
+20
View File
@@ -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),
};
+28 -2
View File
@@ -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>
+131 -3
View File
@@ -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>