From 62d81dc25f0986ee151cb9658d7a9a4b405b678f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 2 Nov 2026 13:52:04 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20Design=20und=20Plan=20=E2=80=9EDuplikat?= =?UTF-8?q?=20=E2=86=92=20neue=20Version=E2=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-11-02-duplikat-neue-version.md | 287 ++++++++++++++++++ ...2026-11-02-duplikat-neue-version-design.md | 225 ++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-duplikat-neue-version.md create mode 100644 docs/superpowers/specs/2026-11-02-duplikat-neue-version-design.md diff --git a/docs/superpowers/plans/2026-11-02-duplikat-neue-version.md b/docs/superpowers/plans/2026-11-02-duplikat-neue-version.md new file mode 100644 index 0000000..7c0aa68 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-duplikat-neue-version.md @@ -0,0 +1,287 @@ +# Duplikat → neue Version – Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein Beleg, dessen interne Belegnummer in Paperless schon existiert, wird nach einem Vergleichsdialog entweder als neue Version an das vorhandene Dokument gehängt oder verworfen – im E-Mail-Import und im Posteingang. + +**Architecture:** Backend bekommt drei Bausteine im `paperless`-Modul: Belegnummer-Prüfung gegen Paperless (CF7 + ASN, auf Stammdokument normalisiert), Kennzahlen (Seiten/Wörter) und `DokumentVersionService` (Upload per `update_version` + Tag 21 „neue Version hochgeladen“ am Dokument; die Postprocessing-Regeln ignoriert der Nutzer per Regel-Konfiguration). E-Mail-Import und Inbox-Postprocessor nutzen sie; das Frontend bekommt eine gemeinsame Komponente `DuplikatVergleichModal`. + +**Tech Stack:** NestJS 11, TypeORM/MariaDB, Jest, pdf-lib, pdf-parse (`PDFParse`), React 19 + Ant Design, Axios. + +**Spec:** `docs/superpowers/specs/2026-11-02-duplikat-neue-version-design.md` + +## Global Constraints + +- Sprache: UI-Texte, Kommentare, Commit-Messages auf Deutsch; Bezeichner in bestehender Konvention (gemischt dt./engl. wie im Umfeld). +- Maßgeblich für „Belegnummer existiert“ ist **Paperless**: CF7 = interne Belegnummer **und** ASN = Belegnummer ohne Bindestriche (`2026-000123` → `2026000123`). +- Treffer immer auf das Stammdokument normalisieren: `root_document ?? id`. +- „Als neue Version hinzufügen“ nur bei **genau einem** Stammdokument; sonst nur „Verwerfen“. +- Versions-Upload: `POST /api/documents/{id}/update_version/`, multipart `document` + `version_label` = `Korrektur vom TT.MM.JJJJ` (max. 64 Zeichen). +- Nach Versions-Upload bekommt das Stammdokument Tag **21** („neue Version hochgeladen“), vorhandene Tags bleiben. Keine eigene Webhook-Unterdrückung im Code. +- Checksummen-Prüfungen (`checkDuplicate`, `recheckDuplicates`, `sperreImporte`, Inbox-Schritt 6) bleiben unverändert. +- Task-Bearbeitung (`PaperlessTaskProcessorService`) bleibt unverändert; für Versionen wird **kein** Task angelegt. +- Produktions-Schema nur per Migration (`src/database/migrations/`), handgeschrieben wie `1782700000000-CreateWebhookQueue.ts`. +- Backend nach jeder Änderung: `npx eslint ` und `npx prettier --check ` grün für die geänderten Dateien; Frontend: `npm run lint` und `npm run build` grün. + +## Review Focus + +1. **Dokument hat Tag 21 schon** (zweite Korrektur) – Tag nicht doppelt setzen und bei fehlgeschlagenem Upload **nicht** entfernen. → Test in Task 4. +2. **Dokument hat schon Versionen**: CF7-/ASN-Suche liefert Stamm *und* Versionen → darf nicht als „mehrdeutig“ gelten. → Test in Task 2. +3. **E-Mail-Import: Benutzer geht nach der Entscheidung zurück und ändert die Belegnummer** – die alte Versions-Entscheidung darf nicht am neuen Wert kleben. → Abnahme in Task 7. +4. **Posteingang „Verwerfen“**: Aktionen *nach* PAPERLESS laufen, Aktionen *davor* nicht doppelt. → Test in Task 5. +5. **PDF ohne Textebene** (Scan): Wortzahl „nicht ermittelbar“ statt 0 bzw. falscher Abweichung. → Test in Task 3. + +--- + +### Task 1: Live-Prüfung der Paperless-Versionsfunktion + +Keine Code-Änderung. Ergebnisse als Abschnitt „Ergebnisse Live-Prüfung“ unten in die Spec schreiben. **Vor Schritt 2 den Nutzer fragen**, welches Testdokument verwendet werden darf (der Upload verändert ein echtes Dokument in Produktion). + +- [ ] **Step 1:** `GET /api/documents/?archive_serial_number=` und `GET /api/documents//` abrufen; notieren, ob `root_document` und `versions` befüllt sind. +- [ ] **Step 2:** Mit Freigabe des Nutzers eine Test-PDF per `curl -F document=@test.pdf -F version_label="Test" .../api/documents//update_version/` hochladen. Notieren: Antwortformat (Task-UUID?), Ergebnis von `GET /api/tasks/?task_id=…` nach Abschluss (`result_data.document_id`?). +- [ ] **Step 3:** Danach notieren: Liefert die CF7-Suche (`custom_field_query=[7,"icontains","…"]`) und die ASN-Suche die Version als eigenes Dokument? Welche Werte haben `id`, `root_document`, `page_count`, `content` am Stamm (neueste Fassung?) und was liefert `/download/`? +- [ ] **Step 4:** Im Backend-Log nachsehen, ob und mit welcher Dokument-ID ein Webhook kam (nur zur Info; die Regeln filtern über Tag 21). +- [ ] **Step 5:** Testversion wieder löschen (`DELETE /api/documents//versions//`), Ergebnisse in die Spec schreiben, committen: +```bash +git add docs/superpowers/specs/2026-11-02-duplikat-neue-version-design.md +git commit -m "docs: Ergebnisse der Live-Prüfung Paperless-Versionen" +``` +Weichen die Ergebnisse von den Annahmen in Task 2/4 ab (z. B. keine Task-ID, kein `root_document`), vor Task 2 den Plan anpassen. + +--- + +### Task 2: Belegnummer-Prüfung gegen Paperless + +**Files:** +- Create: `paperless-backend/src/paperless/belegnummer-pruefung.util.ts` +- Create: `paperless-backend/src/paperless/belegnummer-pruefung.util.spec.ts` +- Modify: `paperless-backend/src/paperless/paperless.service.ts` (neben `findDocumentIdByCustomField`/`findDocumentIdByAsn`) +- Modify: `paperless-backend/src/paperless/paperless.controller.ts` +- Modify: `paperless-frontend/src/api/paperless.ts` + +**Interfaces:** +- Produces: + - `export type BelegnummerStatus = 'frei' | 'eindeutig' | 'mehrdeutig'` + - `export interface BelegnummerPruefung { status: BelegnummerStatus; documentId: number | null; kandidaten: number[] }` + - `export function belegnummerZuAsn(belegnummer: string): number | null` – entfernt `-`, `parseInt`; `null` bei NaN oder leer. + - `export function klassifiziereTreffer(cf7RootIds: number[], asnRootIds: number[]): BelegnummerPruefung` – Vereinigung, dedupliziert, aufsteigend sortiert; 0 → frei, 1 → eindeutig (`documentId` = diese ID), >1 → mehrdeutig (`documentId: null`). + - `PaperlessService.findRootDocumentIdsByCustomField(fieldId: number, value: string): Promise` – wie `findDocumentIdByCustomField` (gleiche case-insensitive Verifikation), aber alle Treffer, je Treffer `root_document ?? id`. + - `PaperlessService.findRootDocumentIdsByAsn(asn: number): Promise` – analog zu `findDocumentIdByAsn`. + - `PaperlessService.pruefeBelegnummer(belegnummer: string): Promise` + - `GET /api/paperless/belegnummer-pruefung?belegnummer=2026-000123` → `BelegnummerPruefung`; `@RequirePermissions(Permission.VIEW_MAIL)` (wie die E-Mail-Import-Endpunkte). + - Frontend: `paperlessApi.pruefeBelegnummer(belegnummer: string): Promise` + exportierte Typen. + +- [ ] **Step 1: Failing tests** in `belegnummer-pruefung.util.spec.ts`: +```ts +expect(belegnummerZuAsn('2026-000123')).toBe(2026000123); +expect(belegnummerZuAsn('')).toBeNull(); +expect(belegnummerZuAsn('abc')).toBeNull(); +expect(klassifiziereTreffer([], [])).toEqual({ status: 'frei', documentId: null, kandidaten: [] }); +expect(klassifiziereTreffer([42], [42])).toEqual({ status: 'eindeutig', documentId: 42, kandidaten: [42] }); +expect(klassifiziereTreffer([42, 42], [])).toEqual({ status: 'eindeutig', documentId: 42, kandidaten: [42] }); // Review Focus 2: Stamm + Version +expect(klassifiziereTreffer([42], [7])).toEqual({ status: 'mehrdeutig', documentId: null, kandidaten: [7, 42] }); +expect(klassifiziereTreffer([], [7])).toEqual({ status: 'eindeutig', documentId: 7, kandidaten: [7] }); +``` +- [ ] **Step 2:** `cd paperless-backend && npx jest src/paperless/belegnummer-pruefung.util.spec.ts` → FAIL (Modul fehlt). +- [ ] **Step 3:** Util implementieren; Service-Methoden und Endpoint ergänzen; Frontend-API-Methode ergänzen. +- [ ] **Step 4:** Test erneut → PASS; ESLint/Prettier für die geänderten Dateien grün. +- [ ] **Step 5: Commit** `feat(paperless): Belegnummer gegen Paperless prüfen (CF7 + ASN)` + +--- + +### Task 3: Kennzahlen für den Vergleich (Seiten, Wörter) + +**Files:** +- Create: `paperless-backend/src/paperless/vergleich-kennzahlen.util.ts` +- Create: `paperless-backend/src/paperless/vergleich-kennzahlen.util.spec.ts` +- Modify: `paperless-backend/src/paperless/paperless.service.ts` +- Modify: `paperless-backend/src/inbox/inbox.controller.ts` (+ `inbox.service.ts`, falls Hilfsmethode nötig) +- Modify: `paperless-backend/src/email/email-import.controller.ts`, `email-import.service.ts` +- Modify: `paperless-frontend/src/api/inbox.ts`, `paperless-frontend/src/api/email-import.ts` + +**Interfaces:** +- Produces: + - `export interface Kennzahlen { seiten: number | null; woerter: number | null }` + - `export interface VergleichKennzahlen { vorhanden: Kennzahlen; neu: Kennzahlen }` + - `export function zaehleWoerter(text: string | null | undefined): number | null` – an `/\s+/` trennen, nur Tokens mit mind. einem Buchstaben oder einer Ziffer (`/[\p{L}\p{N}]/u`) zählen; Seitenmarker `-- N of M --` vorher entfernen; ergibt sich 0 → `null` („nicht ermittelbar“). + - `export async function kennzahlenAusPdf(pdf: Buffer): Promise` – Seiten per `PDFDocument.load(..., { ignoreEncryption: true }).getPageCount()`, Text per `PDFParse` (Muster aus `email/pdf-fingerprint.util.ts`, `destroy()` im `finally`); Lesefehler → jeweils `null`. + - `PaperlessService.getDocumentKennzahlen(documentId: number): Promise` – `page_count` und `zaehleWoerter(content)` aus `getDocument` (laut Task 1 neueste Fassung). + - `POST /api/inbox/:id/vergleich` Body `{ paperlessDocumentId: number; pages: number[] }` → `VergleichKennzahlen`; neue PDF über `inboxService.getSegmentPdfBuffer(id, user, pages)`. + - `POST /api/email-import/attachments/:attachmentId/vergleich` Body `{ paperlessDocumentId: number; pages?: { start: number; end: number } }` → `VergleichKennzahlen`; `@RequirePermissions(Permission.VIEW_MAIL)`; PDF aus `Content.Content1`, bei `pages` nur dieser Bereich (pdf-lib `copyPages` wie in `runImport`). + - Frontend: `inboxApi.vergleich(id, paperlessDocumentId, pages)`, `emailImportApi.vergleich(attachmentId, paperlessDocumentId, pages?)`, `emailImportApi.attachmentPdfBlob(attachmentId, pages?)` falls es noch keinen PDF-Endpunkt für Anhänge/Bereiche gibt (vorhanden ist `printPreview` – nur verwenden, wenn ohne Stempel möglich; sonst `GET /api/email-import/attachments/:attachmentId/pdf?start=&end=` ergänzen). + +- [ ] **Step 1: Failing tests:** +```ts +expect(zaehleWoerter('Rechnung Nr. 123 – Betrag 4,50 €')).toBe(5); // "–" und "€" zählen nicht +expect(zaehleWoerter('-- 1 of 2 --')).toBeNull(); +expect(zaehleWoerter('')).toBeNull(); +expect(zaehleWoerter(null)).toBeNull(); +// PDF mit Text (pdf-lib, StandardFonts.Helvetica, 2 Seiten, Text "Hallo Welt" auf Seite 1) +expect(await kennzahlenAusPdf(pdfMitText)).toEqual({ seiten: 2, woerter: 2 }); +// PDF ohne Text (1 leere Seite) – Review Focus 5 +expect(await kennzahlenAusPdf(leeresPdf)).toEqual({ seiten: 1, woerter: null }); +expect(await kennzahlenAusPdf(Buffer.from('kaputt'))).toEqual({ seiten: null, woerter: null }); +``` +- [ ] **Step 2:** `npx jest src/paperless/vergleich-kennzahlen.util.spec.ts` → FAIL. +- [ ] **Step 3:** Util, Service-Methode, beide Endpunkte und Frontend-API implementieren. +- [ ] **Step 4:** Test → PASS; Lint/Prettier grün. +- [ ] **Step 5: Commit** `feat: Seiten- und Wortzahl für den Duplikatvergleich` + +--- + +### Task 4: Versions-Upload mit Tag 21 + +**Files:** +- Create: `paperless-backend/src/paperless/dokument-version.service.ts` +- Create: `paperless-backend/src/paperless/dokument-version.service.spec.ts` +- Modify: `paperless-backend/src/paperless/paperless.service.ts`, `paperless.module.ts` + +**Interfaces:** +- Produces: + - `PaperlessService.uploadDocumentVersion(documentId: number, filePath: string, versionLabel: string): Promise` – multipart wie `uploadDocument`, Timeout 120 s, Rückgabe ohne Anführungszeichen. + - `export const NEUE_VERSION_TAG_ID = 21;` + - `export function versionLabel(datum: Date): string` → `Korrektur vom 02.11.2026`. + - `DokumentVersionService.haengeVersionAn(rootDocumentId: number, pdfPath: string): Promise` – Reihenfolge: + 1. Dokument lesen; hat es Tag 21 noch nicht, per `updateDocument(rootDocumentId, { tags })` bisherige Tags plus 21 setzen. + 2. Danach `uploadDocumentVersion(rootDocumentId, pdfPath, versionLabel(new Date()))`. + 3. Schlägt der Upload fehl **und** wurde Tag 21 in Schritt 1 neu gesetzt: Dokument erneut lesen, Tag 21 entfernen (`updateDocument` mit den übrigen Tags), danach den ursprünglichen Fehler weiterwerfen. Schlägt das Entfernen selbst fehl, nur loggen (`Tag 21 konnte nach fehlgeschlagenem Versions-Upload nicht entfernt werden`) und trotzdem den ursprünglichen Fehler werfen. + Exportiert aus `PaperlessModule`. + +- [ ] **Step 1: Failing tests** (`PaperlessService` gemockt): + - `versionLabel(new Date(2026, 10, 2))` → `'Korrektur vom 02.11.2026'`. + - Tags `[3, 17]`: `updateDocument(42, { tags: [3, 17, 21] })` wird **vor** `uploadDocumentVersion(42, '/tmp/x.pdf',