From b7b65c64aed4cdf35321f8299037fc36bfe762f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 1 Sep 2026 00:06:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Berechtigungen=20der=20Tages=C3=BCbersic?= =?UTF-8?q?ht,=20WebDAV-Ausbau,=20Tests=20instand=20gesetzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 9 +- paperless-backend/package-lock.json | 337 ++++-------------- paperless-backend/package.json | 4 +- paperless-backend/src/app.module.ts | 2 + paperless-backend/src/auth/auth.module.ts | 2 + paperless-backend/src/auth/jwt.strategy.ts | 14 +- .../src/auth/permissions.enum.spec.ts | 55 +++ .../src/auth/permissions.enum.ts | 4 +- paperless-backend/src/database/data-source.ts | 2 + .../entities/client-identifier.entity.ts | 33 ++ .../database/entities/document-type.entity.ts | 8 + .../database/entities/export-target.entity.ts | 2 +- .../src/database/entities/index.ts | 1 + .../entities/postprocessing-action.entity.ts | 2 +- ...87000000000-ERechnungMandantenzuordnung.ts | 41 +++ .../src/email/email.controller.spec.ts | 98 ++++- .../src/erechnung/erechnung-parser.spec.ts | 148 ++++++++ .../src/erechnung/erechnung-parser.ts | 244 +++++++++++++ .../src/erechnung/erechnung.module.ts | 24 ++ .../src/erechnung/erechnung.service.ts | 226 ++++++++++++ .../src/erechnung/mandant-matcher.service.ts | 208 +++++++++++ .../src/erechnung/mandant-matcher.spec.ts | 262 ++++++++++++++ .../src/erechnung/pdf-attachment.util.spec.ts | 51 +++ .../src/erechnung/pdf-attachment.util.ts | 126 +++++++ .../src/paperless/paperless.controller.ts | 31 ++ .../src/paperless/paperless.module.ts | 2 + .../src/postprocessing/export.service.ts | 41 +-- .../postprocessing.service.spec.ts | 277 ++++++++++---- .../src/settings/settings.controller.spec.ts | 51 +++ .../src/settings/settings.controller.ts | 62 ++++ .../src/settings/settings.module.ts | 2 + .../user-identity-sync.service.spec.ts | 118 ++++++ .../user-identity-sync.service.ts | 81 +++++ .../user-settings/user-settings.controller.ts | 15 +- .../src/user-settings/user-settings.module.ts | 5 +- .../user-settings/user-settings.service.ts | 27 +- .../src/webhook/webhook-queue.service.spec.ts | 8 - paperless-frontend/src/api/posteingang.ts | 20 ++ paperless-frontend/src/api/settings.ts | 30 +- .../src/components/DocumentEditModal.tsx | 98 ++++- paperless-frontend/src/pages/SettingsPage.tsx | 134 ++++++- 41 files changed, 2462 insertions(+), 443 deletions(-) create mode 100644 paperless-backend/src/auth/permissions.enum.spec.ts create mode 100644 paperless-backend/src/database/entities/client-identifier.entity.ts create mode 100644 paperless-backend/src/database/migrations/1787000000000-ERechnungMandantenzuordnung.ts create mode 100644 paperless-backend/src/erechnung/erechnung-parser.spec.ts create mode 100644 paperless-backend/src/erechnung/erechnung-parser.ts create mode 100644 paperless-backend/src/erechnung/erechnung.module.ts create mode 100644 paperless-backend/src/erechnung/erechnung.service.ts create mode 100644 paperless-backend/src/erechnung/mandant-matcher.service.ts create mode 100644 paperless-backend/src/erechnung/mandant-matcher.spec.ts create mode 100644 paperless-backend/src/erechnung/pdf-attachment.util.spec.ts create mode 100644 paperless-backend/src/erechnung/pdf-attachment.util.ts create mode 100644 paperless-backend/src/user-settings/user-identity-sync.service.spec.ts create mode 100644 paperless-backend/src/user-settings/user-identity-sync.service.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5b7b70d..1c51f73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 004178f..92ceec8 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -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", diff --git a/paperless-backend/package.json b/paperless-backend/package.json index 9ec86d9..6251a33 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -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", diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index a70dfeb..2745ca0 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -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 {} diff --git a/paperless-backend/src/auth/auth.module.ts b/paperless-backend/src/auth/auth.module.ts index 47af530..35d51e2 100644 --- a/paperless-backend/src/auth/auth.module.ts +++ b/paperless-backend/src/auth/auth.module.ts @@ -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: [ diff --git a/paperless-backend/src/auth/jwt.strategy.ts b/paperless-backend/src/auth/jwt.strategy.ts index 1153c05..5c47830 100644 --- a/paperless-backend/src/auth/jwt.strategy.ts +++ b/paperless-backend/src/auth/jwt.strategy.ts @@ -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('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; } } diff --git a/paperless-backend/src/auth/permissions.enum.spec.ts b/paperless-backend/src/auth/permissions.enum.spec.ts new file mode 100644 index 0000000..e53cf84 --- /dev/null +++ b/paperless-backend/src/auth/permissions.enum.spec.ts @@ -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); + }); +}); diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index ac28159..d7239d1 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -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); diff --git a/paperless-backend/src/database/data-source.ts b/paperless-backend/src/database/data-source.ts index 952bf6b..4708bac 100644 --- a/paperless-backend/src/database/data-source.ts +++ b/paperless-backend/src/database/data-source.ts @@ -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, diff --git a/paperless-backend/src/database/entities/client-identifier.entity.ts b/paperless-backend/src/database/entities/client-identifier.entity.ts new file mode 100644 index 0000000..499d332 --- /dev/null +++ b/paperless-backend/src/database/entities/client-identifier.entity.ts @@ -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; +} diff --git a/paperless-backend/src/database/entities/document-type.entity.ts b/paperless-backend/src/database/entities/document-type.entity.ts index 53551d1..fca6450 100644 --- a/paperless-backend/src/database/entities/document-type.entity.ts +++ b/paperless-backend/src/database/entities/document-type.entity.ts @@ -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; } diff --git a/paperless-backend/src/database/entities/export-target.entity.ts b/paperless-backend/src/database/entities/export-target.entity.ts index 59bc71b..8472e7d 100644 --- a/paperless-backend/src/database/entities/export-target.entity.ts +++ b/paperless-backend/src/database/entities/export-target.entity.ts @@ -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; diff --git a/paperless-backend/src/database/entities/index.ts b/paperless-backend/src/database/entities/index.ts index 75d7d18..035ab50 100644 --- a/paperless-backend/src/database/entities/index.ts +++ b/paperless-backend/src/database/entities/index.ts @@ -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'; diff --git a/paperless-backend/src/database/entities/postprocessing-action.entity.ts b/paperless-backend/src/database/entities/postprocessing-action.entity.ts index 0dc085c..2b6047c 100644 --- a/paperless-backend/src/database/entities/postprocessing-action.entity.ts +++ b/paperless-backend/src/database/entities/postprocessing-action.entity.ts @@ -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 diff --git a/paperless-backend/src/database/migrations/1787000000000-ERechnungMandantenzuordnung.ts b/paperless-backend/src/database/migrations/1787000000000-ERechnungMandantenzuordnung.ts new file mode 100644 index 0000000..a5f7f81 --- /dev/null +++ b/paperless-backend/src/database/migrations/1787000000000-ERechnungMandantenzuordnung.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE \`DocumentTypes\` DROP COLUMN \`ERechnungTypeCodes\``, + ); + await queryRunner.query(`DROP TABLE \`ClientIdentifiers\``); + } +} diff --git a/paperless-backend/src/email/email.controller.spec.ts b/paperless-backend/src/email/email.controller.spec.ts index 79c5945..3792d90 100644 --- a/paperless-backend/src/email/email.controller.spec.ts +++ b/paperless-backend/src/email/email.controller.spec.ts @@ -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[] = [ { @@ -26,25 +30,56 @@ const mockEmails: Partial[] = [ }, ]; -const mockQueryBuilder = { - 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]), -}; +const mockAttachments: Partial[] = [ + { 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), + }; + 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]), + }; + 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); @@ -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 }), + ); }); }); diff --git a/paperless-backend/src/erechnung/erechnung-parser.spec.ts b/paperless-backend/src/erechnung/erechnung-parser.spec.ts new file mode 100644 index 0000000..d7fd844 --- /dev/null +++ b/paperless-backend/src/erechnung/erechnung-parser.spec.ts @@ -0,0 +1,148 @@ +import { parseERechnung } from './erechnung-parser'; + +const CII = ` + + + RE-2024-0815 + 380 + + 20240315 + + + + + 04011000-12345-06 + + Saatgut Nord GmbH + + DE111111111 + + + + K-4711 + Hof Pöttker GbR + + 123/456/789 + + + DE987654321 + + + buchhaltung@hof-poettker.de + + + + + EUR + + 1190.00 + + + +`; + +const UBL = ` + + 2024-4711 + 2024-03-15 + 380 + EUR + 04011000-12345-06 + + + Saatgut Nord GmbH + + + + + buchhaltung@hof-poettker.de + K-4711 + Hof Pöttker + + DE987654321 + VAT + + Hof Pöttker GbR + + + + 1190.00 + 1190.00 + +`; + +const UBL_GUTSCHRIFT = ` + + GS-1 + 381 +`; + +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('1'), + ).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', + ); + }); +}); diff --git a/paperless-backend/src/erechnung/erechnung-parser.ts b/paperless-backend/src/erechnung/erechnung-parser.ts new file mode 100644 index 0000000..6f5c36c --- /dev/null +++ b/paperless-backend/src/erechnung/erechnung-parser.ts @@ -0,0 +1,244 @@ +import { XMLParser } from 'fast-xml-parser'; + +export type ERechnungSyntax = 'CII' | 'UBL'; + +export interface ERechnungPartei { + name: string | null; + /** USt-IdNr. (schemeID "VA" bzw. TaxScheme "VAT") */ + ustId: string | null; + /** Kennung der Partei (BT-46 beim Käufer, BT-29 beim Verkäufer) */ + kennung: string | null; + /** Elektronische Adresse (BT-49 bzw. BT-34) */ + email: string | null; +} + +export interface ERechnungDaten { + syntax: ERechnungSyntax; + /** Rechnungsnummer (BT-1) */ + rechnungsnummer: string | null; + /** Rechnungsdatum (BT-2) als ISO-Datum */ + rechnungsdatum: string | null; + /** Rechnungstyp (BT-3), z. B. "380" = Rechnung, "381" = Gutschrift */ + typeCode: string | null; + /** Währung (BT-5) */ + waehrung: string | null; + /** Bruttogesamtbetrag (BT-112) */ + gesamtbetrag: number | null; + /** Käuferreferenz (BT-10) – im deutschen B2G die Leitweg-ID */ + leitwegId: string | null; + kaeufer: ERechnungPartei; + verkaeufer: ERechnungPartei; +} + +// removeNSPrefix schneidet die frei wählbaren Namespace-Präfixe (ram:, rsm:, +// cac:, cbc:, …) ab – ohne das wären die Pfade je nach Erzeuger andere. +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + removeNSPrefix: true, + parseTagValue: false, + parseAttributeValue: false, + trimValues: true, + // Ohne htmlEntities bleiben numerische Referenzen (ö) als Text stehen – + // ein so verstümmelter Käufername würde den Namensvergleich zerstören. + htmlEntities: true, +}); + +/** + * Parst ein E-Rechnungs-XML in der Syntax CII (ZUGFeRD/Factur-X/XRechnung-CII) + * oder UBL (XRechnung-UBL). Gibt `null` zurück, wenn das XML keine der beiden + * Wurzeln trägt oder gar nicht lesbar ist. + */ +export function parseERechnung(xml: Buffer | string): ERechnungDaten | null { + let wurzel: unknown; + try { + wurzel = parser.parse(Buffer.isBuffer(xml) ? xml.toString('utf8') : xml); + } catch { + return null; + } + + const cii = kind(wurzel, 'CrossIndustryInvoice'); + if (cii) return parseCii(cii); + + const ubl = kind(wurzel, 'Invoice'); + if (ubl) return parseUbl(ubl, 'Invoice'); + + const gutschrift = kind(wurzel, 'CreditNote'); + if (gutschrift) return parseUbl(gutschrift, 'CreditNote'); + + return null; +} + +// === CII (ZUGFeRD / Factur-X / XRechnung-CII) === + +function parseCii(inv: unknown): ERechnungDaten { + const kopf = kind(inv, 'ExchangedDocument'); + const transaktion = kind(inv, 'SupplyChainTradeTransaction'); + const vereinbarung = kind(transaktion, 'ApplicableHeaderTradeAgreement'); + const abrechnung = kind(transaktion, 'ApplicableHeaderTradeSettlement'); + const summen = kind( + abrechnung, + 'SpecifiedTradeSettlementHeaderMonetarySummation', + ); + const datum = kind(kopf, 'IssueDateTime'); + + return { + syntax: 'CII', + rechnungsnummer: text(kind(kopf, 'ID')), + rechnungsdatum: normalisiereDatum( + text(kind(datum, 'DateTimeString')) ?? text(datum), + ), + typeCode: text(kind(kopf, 'TypeCode')), + waehrung: text(kind(abrechnung, 'InvoiceCurrencyCode')), + gesamtbetrag: zuBetrag( + text(kind(summen, 'GrandTotalAmount')) ?? + text(kind(summen, 'DuePayableAmount')), + ), + leitwegId: text(kind(vereinbarung, 'BuyerReference')), + kaeufer: parseCiiPartei(kind(vereinbarung, 'BuyerTradeParty')), + verkaeufer: parseCiiPartei(kind(vereinbarung, 'SellerTradeParty')), + }; +} + +function parseCiiPartei(partei: unknown): ERechnungPartei { + if (!partei) return leerePartei(); + + const ustId = liste(kind(partei, 'SpecifiedTaxRegistration')) + .map((r) => kind(r, 'ID')) + .find((id) => attribut(id, 'schemeID') === 'VA'); + + const kontaktMail = liste(kind(partei, 'DefinedTradeContact')) + .map((k) => text(kind(kind(k, 'EmailURIUniversalCommunication'), 'URIID'))) + .find((m): m is string => !!m); + + return { + name: text(kind(partei, 'Name')), + ustId: text(ustId), + kennung: text(kind(partei, 'ID')) ?? text(kind(partei, 'GlobalID')), + email: + text(kind(kind(partei, 'URIUniversalCommunication'), 'URIID')) ?? + kontaktMail ?? + null, + }; +} + +// === UBL (XRechnung-UBL) === + +function parseUbl( + inv: unknown, + wurzelName: 'Invoice' | 'CreditNote', +): ERechnungDaten { + const summen = kind(inv, 'LegalMonetaryTotal'); + + return { + syntax: 'UBL', + rechnungsnummer: text(kind(inv, 'ID')), + rechnungsdatum: normalisiereDatum(text(kind(inv, 'IssueDate'))), + typeCode: text( + kind( + inv, + wurzelName === 'CreditNote' ? 'CreditNoteTypeCode' : 'InvoiceTypeCode', + ), + ), + waehrung: text(kind(inv, 'DocumentCurrencyCode')), + gesamtbetrag: zuBetrag( + text(kind(summen, 'TaxInclusiveAmount')) ?? + text(kind(summen, 'PayableAmount')), + ), + leitwegId: text(kind(inv, 'BuyerReference')), + kaeufer: parseUblPartei( + kind(kind(inv, 'AccountingCustomerParty'), 'Party'), + ), + verkaeufer: parseUblPartei( + kind(kind(inv, 'AccountingSupplierParty'), 'Party'), + ), + }; +} + +function parseUblPartei(partei: unknown): ERechnungPartei { + if (!partei) return leerePartei(); + + const ustId = liste(kind(partei, 'PartyTaxScheme')) + .filter( + (s) => + (text(kind(kind(s, 'TaxScheme'), 'ID')) ?? '').toUpperCase() === 'VAT', + ) + .map((s) => kind(s, 'CompanyID'))[0]; + + const rechtseinheit = kind(partei, 'PartyLegalEntity'); + + return { + // BT-44/BT-27 ist der eingetragene Name, BT-45/BT-28 nur der Handelsname. + name: + text(kind(rechtseinheit, 'RegistrationName')) ?? + text(kind(kind(partei, 'PartyName'), 'Name')), + ustId: text(ustId), + kennung: + text(kind(kind(partei, 'PartyIdentification'), 'ID')) ?? + text(kind(rechtseinheit, 'CompanyID')), + email: + text(kind(partei, 'EndpointID')) ?? + text(kind(kind(partei, 'Contact'), 'ElectronicMail')), + }; +} + +// === Hilfsfunktionen === + +function leerePartei(): ERechnungPartei { + return { name: null, ustId: null, kennung: null, email: null }; +} + +/** + * Steigt einen Schritt in den Parse-Baum ab. Wiederholte Elemente liefert der + * Parser als Array – hier zählt dann das erste Vorkommen. + */ +function kind(knoten: unknown, name: string): unknown { + const erstes: unknown = Array.isArray(knoten) + ? (knoten as unknown[])[0] + : knoten; + if (!erstes || typeof erstes !== 'object') return undefined; + return (erstes as Record)[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)['#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; +} diff --git a/paperless-backend/src/erechnung/erechnung.module.ts b/paperless-backend/src/erechnung/erechnung.module.ts new file mode 100644 index 0000000..91fd7b4 --- /dev/null +++ b/paperless-backend/src/erechnung/erechnung.module.ts @@ -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 {} diff --git a/paperless-backend/src/erechnung/erechnung.service.ts b/paperless-backend/src/erechnung/erechnung.service.ts new file mode 100644 index 0000000..f435bf3 --- /dev/null +++ b/paperless-backend/src/erechnung/erechnung.service.ts @@ -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, + @InjectRepository(UserClient) + private readonly userClientRepo: Repository, + ) {} + + /** Wertet die E-Rechnung eines Paperless-Dokuments aus und schlägt Felder vor. */ + async analysiereDokument( + documentId: number, + userId: string, + ): Promise { + 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 { + 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 { + 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 { + 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> { + 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)); + } +} diff --git a/paperless-backend/src/erechnung/mandant-matcher.service.ts b/paperless-backend/src/erechnung/mandant-matcher.service.ts new file mode 100644 index 0000000..f42b01d --- /dev/null +++ b/paperless-backend/src/erechnung/mandant-matcher.service.ts @@ -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, + @InjectRepository(ClientIdentifier) + private readonly identifierRepo: Repository, + @InjectRepository(DocumentType) + private readonly docTypeRepo: Repository, + ) {} + + async match(daten: ERechnungDaten): Promise { + 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 { + 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 { + 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; + } +} diff --git a/paperless-backend/src/erechnung/mandant-matcher.spec.ts b/paperless-backend/src/erechnung/mandant-matcher.spec.ts new file mode 100644 index 0000000..56d38fa --- /dev/null +++ b/paperless-backend/src/erechnung/mandant-matcher.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts b/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts new file mode 100644 index 0000000..d104611 --- /dev/null +++ b/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts @@ -0,0 +1,51 @@ +import { PDFDocument } from 'pdf-lib'; +import { extrahiereEingebetteteDateien } from './pdf-attachment.util'; + +const XML = ''; + +async function pdfMitAnhang( + ...anhaenge: { name: string; inhalt: string }[] +): Promise { + 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([]); + }); +}); diff --git a/paperless-backend/src/erechnung/pdf-attachment.util.ts b/paperless-backend/src/erechnung/pdf-attachment.util.ts new file mode 100644 index 0000000..05c9c47 --- /dev/null +++ b/paperless-backend/src/erechnung/pdf-attachment.util.ts @@ -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 { + 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(); + + 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; +} diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index 60bbe19..c794292 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -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, @InjectRepository(Setting) private readonly settingRepo: Repository, + @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 }; } diff --git a/paperless-backend/src/paperless/paperless.module.ts b/paperless-backend/src/paperless/paperless.module.ts index 6b6f4c6..07ed9d3 100644 --- a/paperless-backend/src/paperless/paperless.module.ts +++ b/paperless-backend/src/paperless/paperless.module.ts @@ -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], diff --git a/paperless-backend/src/postprocessing/export.service.ts b/paperless-backend/src/postprocessing/export.service.ts index 2216677..8ffdd8d 100644 --- a/paperless-backend/src/postprocessing/export.service.ts +++ b/paperless-backend/src/postprocessing/export.service.ts @@ -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 { - 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 { - 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, - }); - } } diff --git a/paperless-backend/src/postprocessing/postprocessing.service.spec.ts b/paperless-backend/src/postprocessing/postprocessing.service.spec.ts index a386cf7..363bcca 100644 --- a/paperless-backend/src/postprocessing/postprocessing.service.spec.ts +++ b/paperless-backend/src/postprocessing/postprocessing.service.spec.ts @@ -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[] = [ - { - Id: 1, - Name: 'Rule1', - DocumentTypeId: 5, - CorrespondentId: null, - OwnerId: null, - TagId: null, - Order: 1, - IsActive: true, - NoFurther: false, - }, - { - Id: 2, - Name: 'StopRule', - DocumentTypeId: null, - CorrespondentId: null, - OwnerId: null, - TagId: null, - Order: 2, - IsActive: true, - NoFurther: true, - }, -]; +/** Bedingung „Dokumenttyp == " als Filterbaum. */ +const documentTypeIs = (id: number): FilterGroup => ({ + combinator: 'AND', + rules: [{ field: 'document_type', operator: 'equals', value: id }], +}); -const mockActions: Partial[] = [ - { - Id: 1, - PostprocessingId: 1, - ActionType: 2, - Content: '99', - Order: 1, - IsActive: true, - }, -]; +/** Aktion „Tag 99 setzen" (ActionType 3). */ +const addTagAction = ( + postprocessingId: number, +): Partial => ({ + Id: postprocessingId * 10, + PostprocessingId: postprocessingId, + ActionType: 3, + Content: { addTags: [99] }, + Order: 1, + IsActive: true, +}); + +const rule = (over: Partial): Partial => ({ + Name: `Regel ${over.Id}`, + FilterJson: documentTypeIs(5), + Order: over.Id ?? 1, + IsActive: true, + NoFurther: false, + ...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); + return module.get(PostprocessingService); + } + + const doc = (over: Record = {}) => ({ + 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: [] } }), + ]); - // Rule1 skipped, Rule2 matched → only 1 action lookup - expect(ppActionRepo.find).toHaveBeenCalledTimes(1); - expect(ppActionRepo.find).toHaveBeenCalledWith({ - where: { PostprocessingId: 2, IsActive: true }, + await service.evaluate(doc()); + + expect(actionRepo.find).not.toHaveBeenCalled(); + }); + + 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], + }); + }); }); diff --git a/paperless-backend/src/settings/settings.controller.spec.ts b/paperless-backend/src/settings/settings.controller.spec.ts index 9c8d0c5..b5e93a6 100644 --- a/paperless-backend/src/settings/settings.controller.spec.ts +++ b/paperless-backend/src/settings/settings.controller.spec.ts @@ -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; let ppActionRepo: ReturnType; let userClientRepo: ReturnType; + 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, { diff --git a/paperless-backend/src/settings/settings.controller.ts b/paperless-backend/src/settings/settings.controller.ts index 67fc92b..abf14a3 100644 --- a/paperless-backend/src/settings/settings.controller.ts +++ b/paperless-backend/src/settings/settings.controller.ts @@ -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, @InjectRepository(Client) private readonly clientRepo: Repository, + @InjectRepository(ClientIdentifier) + private readonly clientIdentifierRepo: Repository, @InjectRepository(Setting) private readonly settingRepo: Repository, @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 }; + } } diff --git a/paperless-backend/src/settings/settings.module.ts b/paperless-backend/src/settings/settings.module.ts index 6d47e1b..be173d5 100644 --- a/paperless-backend/src/settings/settings.module.ts +++ b/paperless-backend/src/settings/settings.module.ts @@ -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, diff --git a/paperless-backend/src/user-settings/user-identity-sync.service.spec.ts b/paperless-backend/src/user-settings/user-identity-sync.service.spec.ts new file mode 100644 index 0000000..d153689 --- /dev/null +++ b/paperless-backend/src/user-settings/user-identity-sync.service.spec.ts @@ -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) => ({ ...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(); + }); +}); diff --git a/paperless-backend/src/user-settings/user-identity-sync.service.ts b/paperless-backend/src/user-settings/user-identity-sync.service.ts new file mode 100644 index 0000000..9280909 --- /dev/null +++ b/paperless-backend/src/user-settings/user-identity-sync.service.ts @@ -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(); + + constructor( + @InjectRepository(UserSettings) + private readonly repo: Repository, + ) {} + + /** 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 { + 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); + } +} diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index fb5105b..145b3f2 100644 --- a/paperless-backend/src/user-settings/user-settings.controller.ts +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -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') diff --git a/paperless-backend/src/user-settings/user-settings.module.ts b/paperless-backend/src/user-settings/user-settings.module.ts index c450bac..f2f8ff2 100644 --- a/paperless-backend/src/user-settings/user-settings.module.ts +++ b/paperless-backend/src/user-settings/user-settings.module.ts @@ -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 {} diff --git a/paperless-backend/src/user-settings/user-settings.service.ts b/paperless-backend/src/user-settings/user-settings.service.ts index 8afd147..81a3b6f 100644 --- a/paperless-backend/src/user-settings/user-settings.service.ts +++ b/paperless-backend/src/user-settings/user-settings.service.ts @@ -71,22 +71,10 @@ export class UserSettingsService { ]).toString('utf8'); } - async getSettings( - userId: string, - email?: string, - preferredUsername?: string, - groups?: string[], - ): Promise { - 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 { + 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 { 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); } diff --git a/paperless-backend/src/webhook/webhook-queue.service.spec.ts b/paperless-backend/src/webhook/webhook-queue.service.spec.ts index 29e75a0..c834cac 100644 --- a/paperless-backend/src/webhook/webhook-queue.service.spec.ts +++ b/paperless-backend/src/webhook/webhook-queue.service.spec.ts @@ -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'; /** diff --git a/paperless-frontend/src/api/posteingang.ts b/paperless-frontend/src/api/posteingang.ts index 1a1821a..ebc6634 100644 --- a/paperless-frontend/src/api/posteingang.ts +++ b/paperless-frontend/src/api/posteingang.ts @@ -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('/api/kontonummern', data).then(r => r.data), + + getErechnung: (id: number) => + api.get(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data), }; diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index a9c8124..5fe4690 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -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 = { + ustid: 'USt-IdNr.', + leitwegid: 'Leitweg-ID', + kaeuferkennung: 'Käufer-Kennung', +}; + export const settingsApi = { // Dokumenttypen getDocTypes: () => api.get('/api/settings/document-types').then(r => r.data), @@ -162,6 +180,14 @@ export const settingsApi = { updateClient: (id: number, AgrarmonitorBetriebId: number | null) => api.put(`/api/settings/clients/${id}`, { AgrarmonitorBetriebId }).then(r => r.data), + // E-Rechnungs-Kennungen der Mandanten + getClientIdentifiers: () => + api.get('/api/settings/client-identifiers').then(r => r.data), + createClientIdentifier: (data: { ClientId: number; Typ: ClientIdentifierTyp; Wert: string }) => + api.post('/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('/api/settings/inbox-actions').then((r) => r.data), @@ -191,7 +217,7 @@ export interface InboxAction { export const INBOX_ACTION_LABELS: Record = { MAIL: 'Per E-Mail senden', - EXPORT: 'Export (FTP/WebDAV)', + EXPORT: 'Export (FTP)', PAPERLESS: 'In Paperless importieren', }; diff --git a/paperless-frontend/src/components/DocumentEditModal.tsx b/paperless-frontend/src/components/DocumentEditModal.tsx index 02382a7..c7467e1 100644 --- a/paperless-frontend/src/components/DocumentEditModal.tsx +++ b/paperless-frontend/src/components/DocumentEditModal.tsx @@ -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([]); 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(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>({}); 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 ? ( + + {text} + aus E-Rechnung + + ) : 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 && ( + +
{beschreibeRechnung(erechnung)}
+ {erechnung.hinweis &&
{erechnung.hinweis}
} + + } + /> + )} +
- - setAusERechnung(prev => ({ ...prev, mandant: false }))} + > {clients.map(c => ( ))} - + + {allClients.map(c => {c.Name})} + + + + + + + + + + + setModalOpen(false)}>
@@ -588,6 +704,12 @@ function DocTypesTab() { key: 'freigabe', render: (v: boolean | null) => v ? Ja : '—', }, + { + title: 'E-Rechnungs-Codes', + dataIndex: 'ERechnungTypeCodes', + key: 'erechnung', + render: (v: string | null) => v ? {v} : '—', + }, { title: '', key: 'actions', @@ -637,6 +759,13 @@ function DocTypesTab() { + + + {editing && ( @@ -655,7 +784,7 @@ function DocTypesTab() { // ═══════════════════════════════════════════════════════════════════ const ACTION_TYPE_LABELS: Record = { - 1: 'Export (FTP/WebDAV)', + 1: 'Export (FTP)', 2: 'Mail versenden', 3: 'Tags setzen/entfernen', 4: 'Custom Field setzen', @@ -1191,7 +1320,6 @@ function ExportTargetsTab() {