# 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',