Merge pull request 'feat: Duplikate im Import als neue Version behandeln' (#6) from feat/duplikat-neue-version into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 10s

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
bjoernpoettker committed 2026-11-02 13:31:04 +00:00
commit 0406354b8b
28 files changed
+2195 -210

No files matched your search

@@ -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 <Dateien>` und `npx prettier --check <Dateien>` 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=<ASN eines Dokuments>` und `GET /api/documents/<id>/` 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/<id>/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/<id>/versions/<version_id>/`), 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<number[]>` – wie `findDocumentIdByCustomField` (gleiche case-insensitive Verifikation), aber alle Treffer, je Treffer `root_document ?? id`.
- `PaperlessService.findRootDocumentIdsByAsn(asn: number): Promise<number[]>` – analog zu `findDocumentIdByAsn`.
- `PaperlessService.pruefeBelegnummer(belegnummer: string): Promise<BelegnummerPruefung>`
- `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<BelegnummerPruefung>` + 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<Kennzahlen>` – 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<Kennzahlen>` – `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<string>` – 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<void>` – 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', <Label>)` aufgerufen (Reihenfolge über `mock.invocationCallOrder` prüfen).
- Tags `[21, 5]`: kein `updateDocument`, Upload läuft (Review Focus 1).
- Upload wirft, Tags vorher `[3]`: nach dem Setzen `[3, 21]` wird beim zweiten Lesen `[3, 21]` geliefert → `updateDocument(42, { tags: [3] })`; `haengeVersionAn` wirft den ursprünglichen Fehler.
- Upload wirft, Tag 21 war schon vorher da: Tag wird **nicht** entfernt, Fehler wird geworfen.
- Upload wirft und das Entfernen wirft ebenfalls: der **Upload**-Fehler wird geworfen.
- [ ] **Step 2:** `npx jest src/paperless/dokument-version.service.spec.ts` → FAIL.
- [ ] **Step 3:** Service und Upload-Methode implementieren.
- [ ] **Step 4:** Test → PASS; `npm run build` im Backend grün; Lint/Prettier grün.
- [ ] **Step 5: Commit** `feat(paperless): Belege als neue Version anhängen und mit Tag 21 markieren`
---
### Task 5: Posteingang – Entscheidung im Inbox-Postprocessor
**Files:**
- Create: `paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts` (+ `.spec.ts`)
- Modify: `paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts` (`runForDocument`, `runPaperless`, Interfaces oben)
- Modify: `paperless-backend/src/inbox-postprocessor/inbox-postprocessor.module.ts` (falls Provider fehlt)
- Modify: `paperless-backend/src/inbox/inbox.controller.ts` (`postprocess`-Body)
- Modify: `paperless-frontend/src/api/inbox.ts` (Typen `postprocess`)
**Interfaces:**
- Consumes: `PaperlessService.pruefeBelegnummer` (Task 2), `DokumentVersionService.haengeVersionAn` (Task 4).
- Produces:
- `export type PaperlessEntscheidung = { aktion: 'version'; documentId: number } | { aktion: 'verwerfen' }`
- `export function aktionenAbEntscheidung<T extends { ActionType: string }>(aktionen: T[], entscheidung?: PaperlessEntscheidung): T[]` – ohne Entscheidung alle; mit Entscheidung alle ab der ersten `PAPERLESS`-Aktion (die davor liefen schon); gibt es keine `PAPERLESS`-Aktion, alle.
- `ActionResult` ergänzt um `versionMoeglich?: boolean` und `duplikatKandidaten?: number[]`; `duplicateOfDocumentId` = eindeutige Stamm-ID bzw. erster Kandidat.
- `runForDocument(documentId, preferredUsername, sectionOffset = 0, processOnlyOne = false, entscheidung?: PaperlessEntscheidung)` – ersetzt den Parameter `replaceDuplicate`.
- Controller-Body: `{ sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung }` (`replaceDuplicate` entfällt).
- Verhalten der PAPERLESS-Aktion:
- `entscheidung.aktion === 'verwerfen'` → Aktion übersprungen, Ergebnis `{ ok: true, skipped: true, message: 'Verworfen' }`, **kein** Abbruch; folgende Aktionen laufen.
- `entscheidung.aktion === 'version'` → Belegnummer/ASN wie heute auflösen, keine Duplikatprüfung, `haengeVersionAn(entscheidung.documentId, sectionPdfPath)`, kein Task; Ergebnis `{ ok: true, message: 'Als neue Version zu Dokument X hinzugefügt' }`; folgende Aktionen laufen.
- ohne Entscheidung: Duplikatprüfung in dieser Reihenfolge: (1) `pruefeBelegnummer(interneBelegnummer)` – `eindeutig` → `skipped`, `versionMoeglich: true`; `mehrdeutig` → `skipped`, `versionMoeglich: false`, `duplikatKandidaten`; (2) lokaler Task (wie heute, `versionMoeglich: false`); (3) Checksumme (unverändert). Die bisherigen Einzelprüfungen CF7 und ASN (Schritte 4/5) entfallen zugunsten von (1). Bei `skipped` Abbruch wie heute.
- [ ] **Step 1: Failing tests** für `aktionenAbEntscheidung` mit `[MAIL, PAPERLESS, EXPORT]`:
- ohne Entscheidung → alle drei;
- `{ aktion: 'verwerfen' }` → `[PAPERLESS, EXPORT]` (Review Focus 4: MAIL nicht doppelt, EXPORT läuft);
- `{ aktion: 'version', documentId: 1 }` → `[PAPERLESS, EXPORT]`;
- `[MAIL, EXPORT]` mit Entscheidung → beide.
- [ ] **Step 2:** `npx jest src/inbox-postprocessor/paperless-entscheidung.util.spec.ts` → FAIL.
- [ ] **Step 3:** Util implementieren, `runForDocument`/`runPaperless`/Controller/Frontend-Typen umstellen.
- [ ] **Step 4:** Test → PASS; `npm run build` und `npm test` im Backend (bekannte, schon vorher rote Specs `pdf-fingerprint.util.spec.ts`, `attachment-fingerprint.service.spec.ts` ausgenommen) grün.
- [ ] **Step 5: Commit** `feat(posteingang): Duplikat als neue Version oder verwerfen`
---
### Task 6: E-Mail-Import – Version beim Import hochladen
**Files:**
- Modify: `paperless-backend/src/email/email-import.service.ts` (Typen von `executeImport`/`runImport`, Upload-Schleife)
- Modify: `paperless-backend/src/email/email-import.service.spec.ts`
- Modify: `paperless-backend/src/email/email.module.ts` (falls Provider fehlt)
- Modify: `paperless-frontend/src/api/email-import.ts` (`AttachmentImportData.versionZuDokumentId?: number | null`)
**Interfaces:**
- Consumes: `DokumentVersionService.haengeVersionAn` (Task 4).
- Produces: Anhang-Eintrag in `executeImport` akzeptiert `versionZuDokumentId?: number | null`. Ist er gesetzt:
- kein `validateAsnNotExists`, kein `uploadDocument`, kein Task, kein Task-Polling, kein `setBelegnummer`;
- je Upload-Datei `haengeVersionAn(versionZuDokumentId, uploadItem.path)`; `paperlessIds[rangeKey] = versionZuDokumentId`;
- `ImportStatus = 1`, `InterneBelegnummer` wie bisher am Attachment gesetzt;
- Ergebnis enthält `{ attachmentId, paperlessIds, alsVersion: true }`.
- [ ] **Step 1: Failing test** in `email-import.service.spec.ts`, neuer `describe('Import als Version')` mit dem bestehenden Testmodul (+ Provider `DokumentVersionService` gemockt), Anhang mit `versionZuDokumentId: 42`, `belegnummer: '2026-000123'`, `barcode.nummer: '000123'`:
- `dokumentVersionService.haengeVersionAn` einmal mit `42` und einem `.pdf`-Pfad aufgerufen;
- `paperlessService.uploadDocument`, `paperlessService.validateAsnNotExists` und `taskRepo.save` **nicht** aufgerufen;
- gespeichertes Attachment hat `PaperlessDocumentIds` `{ full: 42 }` und `ImportStatus: 1`.
- [ ] **Step 2:** `npx jest src/email/email-import.service.spec.ts` → neuer Test FAIL.
- [ ] **Step 3:** Zweig in `runImport` implementieren (eigene private Methode, damit die Upload-Schleife lesbar bleibt).
- [ ] **Step 4:** Test → PASS, übrige Tests der Datei weiter grün; Lint/Prettier grün.
- [ ] **Step 5: Commit** `feat(mail): Beleg beim Import als neue Version anhängen`
---
### Task 7: Frontend – gemeinsamer Vergleichsdialog, E-Mail-Import und Posteingang
**Files:**
- Create: `paperless-frontend/src/components/DuplikatVergleichModal.tsx`
- Modify: `paperless-frontend/src/pages/InboxDetailPage.tsx` (`CompareModal` entfernen, Wizard umstellen)
- Modify: `paperless-frontend/src/components/MailImportWizard.tsx`
**Interfaces:**
- Consumes: Frontend-APIs aus Task 2, 3, 5, 6.
- Produces: `DuplikatVergleichModal` mit Props
```ts
{
open: boolean;
belegnummer: string;
pruefung: BelegnummerPruefung | null; // eindeutig → beide Knöpfe; mehrdeutig → nur Verwerfen + Hinweis
paperlessDocumentId: number | null; // linke Seite
ladeNeuesPdf: () => Promise<Blob>; // rechte Seite
ladeKennzahlen: () => Promise<VergleichKennzahlen>;
onVersion: () => void;
onVerwerfen: () => void;
onClose: () => void;
}
```
Titel `Belegnummer ${belegnummer} existiert bereits`. Auffälligkeiten-Leiste über den PDFs:
- je Kennzahl eine Zeile `Seiten: 2 (vorhanden) → 3 (neu)` bzw. `Wörter: 412 → 398`; Abweichung als orange `Tag`, `null` als „nicht ermittelbar“ (grau, keine Abweichung);
- keine Abweichung und nichts `null` → `Alert type="success"` „Seiten- und Wortzahl gleich“;
- mehrdeutig → `Alert type="warning"` „Mehrere Dokumente in Paperless mit dieser Belegnummer (IDs: …) – eine neue Version ist nicht möglich.“
- Knöpfe: `Verwerfen` und primär `Als neue Version hinzufügen` (nur bei `eindeutig`). Layout der PDFs wie bisher (`flex`, mobil untereinander, `75vh`), Object-URLs beim Schließen freigeben.
**Posteingang (`PostprocessWizardModal`):**
- `processSection(idx, entscheidung?: PaperlessEntscheidung)`; Ergebnis mit `versionMoeglich` oder `duplikatKandidaten` öffnet den Dialog automatisch (`setCompareForSection(idx)`).
- Zeilen-Knöpfe bei Duplikat: `Vergleichen` (öffnet Dialog, nur wenn eine Dokument-ID vorliegt) und `Verwerfen`; „Neue Version erstellen“ (alter `replaceDuplicate`-Weg) entfällt.
- `onVersion` → `processSection(idx, { aktion: 'version', documentId })`; `onVerwerfen` und Zeilen-„Verwerfen“ → `processSection(idx, { aktion: 'verwerfen' })`; danach Abschnitt-Status `skipped` mit Meldung „Verworfen“ bzw. `success`.
- Rechte Seite: `inboxApi.downloadSegmentBlob(documentId, documents[idx].pages)`; Kennzahlen: `inboxApi.vergleich(documentId, paperlessDocumentId, documents[idx].pages)`.
**E-Mail-Import (`MailImportWizard`):**
- State `versionEntscheidung: Record<virtualId, { belegnummer: string; documentId: number }>`.
- In `nextStep` bei `currentStep === 1`, nach `recheckDuplicates` und vor `preFetchBelegnummern`: für jedes nicht ignorierte Item im Modus `manuell` die Belegnummer `${jahr}-${nummer.padStart(6,'0')}` bilden und `paperlessApi.pruefeBelegnummer` aufrufen; eine vorhandene Entscheidung gilt nur, wenn ihre `belegnummer` gleich ist (sonst verwerfen – Review Focus 3).
- Treffer (`eindeutig`/`mehrdeutig`) ohne gültige Entscheidung nacheinander im Dialog zeigen; Schrittwechsel erst, wenn alle entschieden sind. Schließen ohne Entscheidung → Warteschlange abbrechen, in Schritt 2 bleiben.
- `onVersion` → Entscheidung speichern; `onVerwerfen` → Item auf `type: 'IGNORE'`; sind danach alle Items `IGNORE` → `ignoreEmail()` statt Schrittwechsel.
- Rechte Seite: PDF des Anhangs bzw. Bereichs (Task 3), Kennzahlen: `emailImportApi.vergleich(...)`.
- `executeImport`: `versionZuDokumentId` aus gültiger Entscheidung in `finalData` übernehmen.
- In Schritt 3 („Abschluss“) bei Version-Items einen Hinweis „wird als neue Version zu Dokument X hinzugefügt“ anzeigen.
- [ ] **Step 1:** Komponente bauen, `CompareModal` in `InboxDetailPage.tsx` ersetzen und Posteingang umstellen.
- [ ] **Step 2:** `MailImportWizard` umstellen.
- [ ] **Step 3:** `cd paperless-frontend && npm run lint && npm run build` → beide ohne Fehler.
- [ ] **Step 4: Abnahme im Browser** (Dev-Server, Backend gegen Paperless), nur mit Freigabe des Nutzers für echte Uploads:
- Mail mit manueller, vorhandener Belegnummer → Dialog erscheint vor Schritt 3, Auffälligkeiten stimmen; „Verwerfen“ stellt auf Ignorieren; „Als neue Version“ → Import hängt Version an (in Paperless sichtbar, kein neues Dokument, Tag 21 gesetzt).
- Zurück, Belegnummer ändern, Weiter → alte Entscheidung greift nicht (Review Focus 3).
- Posteingang-Abschnitt mit vorhandener Belegnummer → Dialog öffnet automatisch; beide Entscheidungen; Aktionen nach PAPERLESS laufen.
- [ ] **Step 5: Commit** `feat(frontend): Vergleichsdialog für Duplikate mit neuer Version`
---
### Task 8: Todos und Abschluss
**Files:**
- Modify: `docs/todos.md`
- [ ] **Step 1:** Die drei Einträge aus dem Spec-Abschnitt „Todos-Einträge“ wörtlich als `- [ ]`-Punkte an `docs/todos.md` anhängen.
- [ ] **Step 2:** Backend `npm run build`, `npm test` (bekannte rote Specs ausgenommen), Frontend `npm run build` – alles grün.
- [ ] **Step 3: Commit** `docs: offene Punkte zu Versionen und Task-Bearbeitung`
@@ -0,0 +1,239 @@
# Duplikat → neue Version (Design)
Stand: 02.11.2026
## Worum es geht
Wird ein Beleg ein zweites Mal mit derselben **internen Belegnummer** nach
Paperless gebracht, soll er nicht als zweites, eigenständiges Dokument landen.
Stattdessen sieht der Benutzer beide Belege nebeneinander und entscheidet:
- **Als neue Version hinzufügen** – die Datei wird über die Versionsfunktion
von Paperless-NGX an das vorhandene Dokument gehängt.
- **Verwerfen** – der Beleg wird nicht importiert.
Typische Fälle:
1. **Korrigierte Rechnung**: Lieferant schickt eine fehlerbereinigte Fassung mit
derselben Rechnungsnummer.
2. **Nachträglich geteilt**: Ein Dokument enthielt zwei Belege. Der Benutzer
teilt das PDF **von Hand** und sendet es neu; der Teil mit der bereits
vergebenen Belegnummer wird zur neuen Version.
## Ziele
- Gleicher Vergleichsdialog im **E-Mail-Import** und im **Posteingang (Scanner)**.
- Oben im Dialog eine Leiste mit **Auffälligkeiten**: abweichende
**Seitenzahl** und abweichende **Wortzahl**. Darunter beide PDFs nebeneinander.
- Maßgeblich für „Belegnummer existiert schon“ ist **Paperless** (Custom Field 7
= interne Belegnummer). Das ist die wichtige Prüfung.
## Nicht-Ziele
- Umbau der Task-Bearbeitung – läuft unverändert weiter.
- **Checksummen-Duplikate umgehen** (gleiche Datei erneut importieren) – bleibt
wie heute, wird in `docs/todos.md` notiert.
- Inhaltliche Analyse (Beträge, Datum, Positionen) für die Auffälligkeiten –
dafür gibt es noch keine Auswertung.
- Metadaten des vorhandenen Dokuments ändern (Titel, Tags, Custom Fields,
Korrespondent bleiben unverändert; nur die Datei kommt als Version dazu).
- Nachgelagerte Automatik für die Version (Postprocessing-Regeln, Mail,
WebDAV-Export, Agrarmonitor-Austausch/Storno) – wird vorerst **unterdrückt**,
die fachliche Logik kommt später (siehe Todos).
## Anti-Ziele (wäre ein Fehlschlag, auch wenn es „funktioniert“)
- Ein Beleg mit vorhandener Belegnummer landet doch als zweites Dokument in
Paperless, ohne dass der Benutzer es so entschieden hat.
- Eine Version wird an das **falsche** Dokument gehängt.
- „Verwerfen“ lässt eine reservierte Belegnummer oder einen halb angelegten
Task zurück.
## Paperless-API (am Live-System geprüft, API v10)
- `POST /api/documents/{id}/update_version/` – multipart, Feld `document`
(Datei, Pflicht), `version_label` (optional, max. 64 Zeichen). Antwort: String
(vermutlich die Consume-Task-ID; Verarbeitung asynchron).
- `PATCH|DELETE /api/documents/{id}/versions/{version_id}/` – Label ändern/löschen.
- `DocumentVersionInfo`: `id`, `added`, `version_label`, `checksum`, `is_root`.
## Ablauf E-Mail-Import (`MailImportWizard.tsx`)
Schritte: 0 Zuordnung → 1 Bearbeitung → 2 Abschluss.
1. In Schritt 1 („Bearbeitung“) hat ein Beleg den Modus **Manuell** und eine
Belegnummer (Jahr + 6-stellige Nummer → `JJJJ-NNNNNN`).
2. Beim Klick auf **Weiter** (Schritt 1 → 2) prüft das Backend für jeden
manuellen Beleg in Paperless, ob die Belegnummer schon vergeben ist (CF7 und
ASN, siehe „Eindeutigkeit des Treffers“).
3. Gibt es Treffer, öffnet sich **vor** dem Schrittwechsel der Vergleichsdialog
– nacheinander für jeden betroffenen Beleg.
- Links: vorhandenes Dokument aus Paperless. Rechts: der neue Beleg (bei
geteilten Anhängen nur die gewählten Seiten).
- **Als neue Version hinzufügen** → Beleg wird für den Import als
„Version zu Dokument X“ markiert.
- **Verwerfen** → Beleg wird auf `IGNORE` gestellt. Stehen danach alle Belege
der Mail auf `IGNORE`, greift die bestehende Logik (Mail als ignoriert
markieren).
- Dialog schließen (X) ohne Entscheidung → kein Schrittwechsel, der Benutzer
bleibt in Schritt 1.
4. Beim **Import ausführen** werden Version-Belege nicht per `post_document`
hochgeladen, sondern per `update_version` an Dokument X gehängt.
- Die ASN-Sperre (`validateAsnNotExists`) und der Belegnummer-Duplikatcheck
entfallen für diese Belege – sonst würde der Import abbrechen.
- Hochgeladen wird wie beim normalen Import das Original-PDF bzw. die
gewählten Seiten, ohne eingebrannten Stempel.
- Kein neuer Enrichment-Task in der `tasks`-Tabelle nötig (Metadaten bleiben).
- Im `Attachment`-Datensatz wird die Dokument-ID X unter dem jeweiligen
Bereich eingetragen, damit der Anhang als importiert gilt.
5. Die Checksummen-Prüfung (`recheckDuplicates`) bleibt unverändert.
## Ablauf Posteingang (`InboxDetailPage.tsx`, `inbox-postprocessor`)
Heute: `runPaperless` meldet ein Duplikat mit `duplicateOfDocumentId`; der
Wizard bietet „Neue Version erstellen“ (= Duplikatprüfung überspringen und als
zweites Dokument hochladen), „Vergleichen“ und „Überspringen“. Im
Vergleichsdialog ist „Neue Version anlegen“ noch ein TODO.
Neu:
- Liefert der Duplikat-Treffer eine Paperless-Dokument-ID, öffnet sich der
Vergleichsdialog **automatisch**.
- **Als neue Version hinzufügen** → Backend verarbeitet den Abschnitt erneut,
lädt die fertige PDF (nach allen Bearbeitungen/Stempeln) aber per
`update_version` an Dokument X hoch statt per `post_document`. Danach ist der
Abschnitt „erfolgreich“.
- **Verwerfen** → entspricht dem heutigen „Überspringen“ (Text wird umbenannt).
- Der bisherige Weg „Duplikatprüfung überspringen und als eigenes Dokument
hochladen“ entfällt in der Oberfläche.
- Duplikate **ohne** Dokument-ID (Checksumme, lokaler Task ohne
`PaperlessDocumentID`) behalten den heutigen Weg: nur Verwerfen/Überspringen.
- Rechts im Dialog wird der **betroffene Abschnitt** gezeigt, nicht das ganze
Inbox-Dokument (heute zeigt der Dialog das ganze Dokument).
## Nachgelagerte Automatik unterdrücken
Entscheidung des Nutzers: Nach dem Hinzufügen einer Version bekommt das
(Stamm-)Dokument den Tag **„neue Version hochgeladen“ (ID 21)**. Der Nutzer
konfiguriert die Postprocessing-Regeln so, dass Belege mit Tag 21 ignoriert
werden. Im Code gibt es **keine** eigene Unterdrückung (keine Markierung, keine
Änderung an der Webhook-Warteschlange).
- Reihenfolge: **erst** Tag 21 setzen (Tags lesen, 21 ergänzen, vorhandene
behalten), **dann** `update_version`. Schlägt der Versions-Upload fehl, wird
Tag 21 wieder entfernt – aber nur, wenn er vorher nicht schon gesetzt war.
- Entfernen von Tag 21 ist Sache des Nutzers.
- Die Anreicherung in `processDocumentById` (Tag 17, bereit/nicht bereit,
Titel-Template) läuft beim Webhook weiterhin – nur die Regeln greifen nicht.
Im **Posteingang** betrifft die Abfrage nur die Aktion „An Paperless senden“.
Die übrigen Aktionen des Barcode-Templates (MAIL, EXPORT) davor und danach
laufen normal: Die Aktionen davor sind beim Erkennen des Duplikats bereits
gelaufen und werden nicht wiederholt; die Aktionen danach werden nach der
Entscheidung (Version **oder** Verwerfen) ganz normal ausgeführt.
## Vergleichsdialog (gemeinsame Komponente)
- Eine Komponente für beide Stellen (aus `CompareModal` herauslösen).
- Titel z. B. „Belegnummer 2026-000123 existiert bereits“.
- **Auffälligkeiten-Leiste** oben (Ant Design `Alert`/`Tag`s):
- Seitenzahl: „Seiten: 2 (vorhanden) → 3 (neu)“ – hervorgehoben bei Abweichung.
- Wortzahl: „Wörter: 412 → 398“ – hervorgehoben bei Abweichung.
- Ist eine Zahl nicht ermittelbar (z. B. PDF ohne Textebene), steht dort
„nicht ermittelbar“ statt einer Abweichung.
- Keine Abweichung → kurzer neutraler Hinweis „Seiten- und Wortzahl gleich“.
- Darunter beide PDFs nebeneinander (mobil untereinander), wie heute.
- Knöpfe: **Verwerfen** / **Als neue Version hinzufügen** (primär).
### Kennzahlen ermitteln (Backend-Endpunkt)
- Vorhanden (Paperless): Seitenzahl aus `page_count` des Dokuments, Wortzahl aus
dessen `content` (OCR-Text von Paperless). Verglichen wird mit der aktuellen
(neuesten) Fassung des Dokuments.
- Neu: Seitenzahl per `pdf-lib`, Wortzahl aus der Textebene per `pdf-parse`
(beides bereits Abhängigkeiten im Backend). Keine Textebene → „nicht
ermittelbar“.
- Wortzählung: Text an Leerraum trennen, nur Tokens mit mindestens einem
Buchstaben oder einer Ziffer zählen.
## Versionsbezeichnung
`version_label` = „Korrektur vom TT.MM.JJJJ“ (Datum des Hinzufügens). Max. 64 Zeichen.
## Eindeutigkeit des Treffers
Geprüft wird immer gegen Paperless, und zwar über **beide** Merkmale, die aus
der internen Belegnummer folgen:
- CF7 = interne Belegnummer (z. B. `2026-000123`)
- ASN = die ASN, die das Dokument beim Upload **tatsächlich bekäme**: im
Posteingang aus dem ASN-Feld der Aktion, falls gesetzt, sonst aus der
Belegnummer ohne Bindestrich (z. B. `2026000123`); im E-Mail-Import immer aus
der Belegnummer. Prüfung, Versions-Upload und normaler Upload verwenden
dieselbe ASN.
„Als neue Version hinzufügen“ wird nur angeboten, wenn alle Treffer auf **genau
ein** Dokument zeigen. Zeigen CF7- und ASN-Treffer auf verschiedene Dokumente
oder gibt es mehrere CF7-Treffer, zeigt der Dialog einen Hinweis und bietet nur
**Verwerfen** an. Dafür muss die Suche alle Treffer liefern, nicht nur den ersten.
## Am Live-System zu prüfen (vor/bei der Umsetzung)
- Antwort von `update_version` (Task-ID?) und wie der Abschluss erkennbar ist.
- Liefern CF7-Suche, `GET /documents/{id}/` (`content`, `page_count`) und
`/download/` bei einem versionierten Dokument die Stamm-ID bzw. die neueste
Fassung? `update_version` muss immer auf die Stamm-ID zielen.
## Todos-Einträge
In `docs/todos.md` ergänzen:
1. „Duplikatprüfung per Checksumme gezielt umgehen können, wenn ein bereits
importiertes Dokument bewusst erneut importiert (z. B. geteilt) werden soll.“
2. „Neue Version eines Belegs in Agrarmonitor nachziehen (derzeit unterdrückt):
- Beleg noch im AM-Posteingang → dort löschen und neue Fassung senden.
- Beleg verarbeitet, aber nicht gebucht → im jeweiligen Einkauf das
vorhandene Dokument löschen und die neue Fassung hochladen.
- Beleg bereits gebucht → Storno-Beleg zum gebuchten Beleg erstellen und die
neue Fassung über die jeweilige E-Mail-Adresse neu in den AM-Posteingang
senden.
Außerdem klären, ob Postprocessing-Regeln (Mail an Buchhaltung, Export) für
Versionen laufen sollen.“
3. „Task-Bearbeitung (`PaperlessTaskProcessorService`, 30-s-Cron) auf den
Paperless-Webhook umstellen. Beim Upload werden die Metadaten schon gesetzt;
übrig bleiben Owner entfernen, Notizen, Anlage-Verknüpfung (Feld 8) und die
lokale Buchführung (Task/Attachment/documents) – die brauchen die
Dokument-ID nach dem Consume.“
## Dem Umsetzer überlassen
- Schnitt der Backend-Endpunkte (eigener Endpunkt für „Belegnummern prüfen“ und
„Kennzahlen“, oder zusammengefasst).
- Wie das Ergebnis von `update_version` verfolgt wird (Task-Polling wie beim
normalen Upload, sofern die Antwort eine Task-ID ist).
- Tests: mindestens Unit-Tests für Wortzählung/Auffälligkeiten und für die
Entscheidung Upload vs. Version im Backend.
## Eigene Entscheidungen (nicht ausdrücklich vom Nutzer)
- Versionsbezeichnung „Korrektur vom …“.
- Version nur bei eindeutigem Treffer (CF7 und ASN zeigen auf dasselbe
Dokument); sonst nur Verwerfen.
- E-Mail-Import: Version wird wie normale Importe ohne eingebrannten Stempel
hochgeladen; keine erneute Barcode-Notiz (die hat das Stammdokument schon).
- Automatisches Öffnen des Dialogs im Posteingang; Wegfall von „als eigenes
Dokument hochladen“.
- Dialog schließen ohne Entscheidung = im Schritt bleiben.
- Rechts nur der betroffene Abschnitt statt des ganzen Inbox-Dokuments.
## Ergebnisse Live-Prüfung (02.11.2026, nur lesend)
- `GET /api/documents/` liefert je Dokument `root_document` (bei Stammdokumenten
`null`) und `versions` (Liste inkl. des Stamms selbst mit `is_root: true`).
Normalisierung `root_document ?? id` ist damit tragfähig.
- Im System gibt es derzeit kein Dokument mit mehr als einer Version; ob
Versionen in Listen-/Suchergebnissen als eigene Einträge auftauchen, ist
daher offen – die Normalisierung deckt beide Fälle ab.
- Der schreibende Teil (Test-Upload einer Version, Antwortformat, Webhook)
wird bei der Abnahme in Task 7 mit Freigabe des Nutzers nachgeholt.
+7
View File
@@ -6,3 +6,10 @@ Hier sind die Todos, die wir vorerst zurückgestellt haben:
- [ ] **Korrespondenten-Sync**: Kunden aus Agrarmonitor abfragen und automatisch Korrespondenten in Paperless anlegen, falls diese nicht existieren (basierend auf der Nummer).
- [ ] **Erweiterte Titel-Logik**: Implementierung der spezifischen Präfixe (ERG, EGU, etc.) basierend auf dem Dokumententyp, falls die automatischen Titel-Templates von Paperless nicht ausreichen.
- [ ] **Vollständige Kunden-Synchronisation**: Regelmäßiger Abgleich der Kundendaten zwischen Agrarmonitor und Paperless.
- [ ] **Checksummen-Duplikate gezielt umgehen**: Duplikatprüfung per Checksumme gezielt umgehen können, wenn ein bereits importiertes Dokument bewusst erneut importiert (z. B. geteilt) werden soll.
- [ ] **Neue Version eines Belegs in Agrarmonitor nachziehen** (derzeit über Tag 21 „neue Version hochgeladen“ von den Postprocessing-Regeln ausgenommen):
- Beleg noch im AM-Posteingang → dort löschen und neue Fassung senden.
- Beleg verarbeitet, aber nicht gebucht → im jeweiligen Einkauf das vorhandene Dokument löschen und die neue Fassung hochladen.
- Beleg bereits gebucht → Storno-Beleg zum gebuchten Beleg erstellen und die neue Fassung über die jeweilige E-Mail-Adresse neu in den AM-Posteingang senden.
- Außerdem klären, ob Postprocessing-Regeln (Mail an Buchhaltung, Export) für Versionen laufen sollen.
- [ ] **Task-Bearbeitung auf Webhook umstellen**: `PaperlessTaskProcessorService` (30-s-Cron) durch den Paperless-Webhook ersetzen. Beim Upload werden die Metadaten schon gesetzt; übrig bleiben Owner entfernen, Notizen, Anlage-Verknüpfung (Feld 8) und die lokale Buchführung (Task/Attachment/documents) – die brauchen die Dokument-ID nach dem Consume.
@@ -14,6 +14,11 @@ import {
import type { Response } from 'express';
import { EmailImportService } from './email-import.service';
import { EmailPageCacheService } from './email-page-cache.service';
import { PaperlessService } from '../paperless/paperless.service';
import {
kennzahlenAusPdf,
type VergleichKennzahlen,
} from '../paperless/vergleich-kennzahlen.util';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@@ -24,6 +29,7 @@ export class EmailImportController {
constructor(
private readonly importService: EmailImportService,
private readonly pageCache: EmailPageCacheService,
private readonly paperlessService: PaperlessService,
) {}
// --- Korrespondenten Mapping ---
@@ -118,6 +124,51 @@ export class EmailImportController {
return { isDuplicate };
}
// --- Duplikatvergleich ---
/** PDF eines Anhangs bzw. Seitenbereichs ohne Stempel (rechte Seite im Vergleich). */
@Get('attachments/:attachmentId/pdf')
@RequirePermissions(Permission.VIEW_MAIL)
async getAttachmentPdf(
@Param('attachmentId') attachmentId: string,
@Query('start') start: string | undefined,
@Query('end') end: string | undefined,
@Res() res: Response,
) {
const pdf = await this.importService.getAttachmentPdf(
parseInt(attachmentId, 10),
seitenbereich(start, end),
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`inline; filename="anhang-${attachmentId}.pdf"`,
);
res.send(pdf);
}
@Post('attachments/:attachmentId/vergleich')
@RequirePermissions(Permission.VIEW_MAIL)
async vergleich(
@Param('attachmentId') attachmentId: string,
@Body()
body: {
paperlessDocumentId: number;
pages?: { start: number; end: number };
},
): Promise<VergleichKennzahlen> {
const pdf = await this.importService.getAttachmentPdf(
parseInt(attachmentId, 10),
body.pages,
);
const [vorhanden, neu] = await Promise.all([
this.paperlessService.getDocumentKennzahlen(
Number(body.paperlessDocumentId),
),
kennzahlenAusPdf(pdf),
]);
return { vorhanden, neu };
}
// --- Print Preview ---
@Post('attachments/:attachmentId/print-preview')
@RequirePermissions(Permission.VIEW_MAIL)
@@ -202,3 +253,12 @@ export class EmailImportController {
}
}
}
function seitenbereich(
start: string | undefined,
end: string | undefined,
): { start: number; end: number } | undefined {
const s = parseInt(start ?? '', 10);
const e = parseInt(end ?? '', 10);
return Number.isNaN(s) || Number.isNaN(e) ? undefined : { start: s, end: e };
}
@@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { PDFDocument } from 'pdf-lib';
@@ -14,6 +14,7 @@ import { PdfService } from '../preprocessing/pdf.service';
import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { DokumentVersionService } from '../paperless/dokument-version.service';
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
const doc = await PDFDocument.create();
@@ -29,11 +30,19 @@ describe('EmailImportService – Duplikatprüfung', () => {
andWhere: jest.Mock;
getCount: jest.Mock;
};
let attachmentRepo: { findOne: jest.Mock };
let attachmentRepo: { findOne: jest.Mock; save: jest.Mock };
let taskRepo: {
createQueryBuilder: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};
let emailRepo: { update: jest.Mock; findOne: jest.Mock };
let dokumentVersion: { haengeVersionAn: jest.Mock };
let contentRepo: { findOne: jest.Mock };
let paperlessService: {
checksumExists: jest.Mock;
uploadDocument: jest.Mock;
validateAsnNotExists: jest.Mock;
};
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
@@ -50,11 +59,25 @@ describe('EmailImportService – Duplikatprüfung', () => {
Checksum: 'abc123',
FileName: 'rechnung.pdf',
}),
save: jest.fn().mockImplementation((a: unknown) => Promise.resolve(a)),
};
taskRepo = {
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
create: jest.fn().mockImplementation((t: unknown) => t),
save: jest.fn(),
};
emailRepo = {
update: jest.fn().mockResolvedValue({}),
findOne: jest.fn().mockResolvedValue(null),
};
dokumentVersion = {
haengeVersionAn: jest.fn().mockResolvedValue(undefined),
};
contentRepo = { findOne: jest.fn() };
paperlessService = {
checksumExists: jest.fn().mockResolvedValue(false),
uploadDocument: jest.fn(),
validateAsnNotExists: jest.fn(),
};
fingerprints = {
ensure: jest.fn().mockResolvedValue(null),
@@ -65,24 +88,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
providers: [
EmailImportService,
{ provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: getRepositoryToken(Email), useValue: {} },
{ provide: getRepositoryToken(Email), useValue: emailRepo },
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
{ provide: getRepositoryToken(Content), useValue: contentRepo },
{
provide: getRepositoryToken(CorrespondentEmailMapping),
useValue: {},
},
{
provide: getRepositoryToken(Task),
useValue: {
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
},
},
{ provide: getRepositoryToken(Task), useValue: taskRepo },
{ provide: PaperlessService, useValue: paperlessService },
{ provide: PdfService, useValue: {} },
{ provide: EmailPageCacheService, useValue: {} },
{ provide: ImapFolderService, useValue: {} },
{ provide: AttachmentFingerprintService, useValue: fingerprints },
{ provide: DokumentVersionService, useValue: dokumentVersion },
],
}).compile();
@@ -189,6 +208,76 @@ describe('EmailImportService – Duplikatprüfung', () => {
});
});
describe('getAttachmentPdf', () => {
beforeEach(async () => {
contentRepo.findOne.mockResolvedValue({
Content1: await pdfMitSeiten(3),
});
});
it('liefert den gewünschten Seitenbereich', async () => {
const pdf = await service.getAttachmentPdf(7, { start: 2, end: 3 });
expect((await PDFDocument.load(pdf)).getPageCount()).toBe(2);
});
it.each([
{ start: 3, end: 2 },
{ start: 0, end: 2 },
{ start: 5, end: 6 },
{ start: Number.NaN, end: 2 },
])(
'meldet einen ungültigen Seitenbereich $start–$end als 400',
async (pages) => {
await expect(service.getAttachmentPdf(7, pages)).rejects.toThrow(
BadRequestException,
);
},
);
});
describe('Import als Version', () => {
it('hängt den Beleg als Version an, statt ein neues Dokument anzulegen', async () => {
contentRepo.findOne.mockResolvedValue({
Content1: await pdfMitSeiten(1),
});
await service.executeImport({
emailDate: '2026-09-25',
attachments: [
{
attachmentId: 7,
type: 'MAIN',
belegnummer: '2026-000123',
barcode: {
x: 0,
y: 0,
nummer: '000123',
datum: '2026-09-25',
jahr: '2026',
},
versionZuDokumentId: 42,
},
],
});
expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledTimes(1);
expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledWith(
42,
expect.stringMatching(/\.pdf$/),
'2026-000123',
);
expect(paperlessService.uploadDocument).not.toHaveBeenCalled();
expect(paperlessService.validateAsnNotExists).not.toHaveBeenCalled();
expect(taskRepo.save).not.toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
PaperlessDocumentIds: { full: 42 },
ImportStatus: 1,
}),
);
});
});
describe('executeImport', () => {
const importDaten = (splitRanges?: { start: number; end: number }[]) => ({
emailDate: '2026-09-25',
@@ -4,6 +4,7 @@ import {
HttpException,
HttpStatus,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
@@ -21,6 +22,7 @@ import * as QRCode from 'qrcode';
import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { DokumentVersionService } from '../paperless/dokument-version.service';
import { PdfService } from '../preprocessing/pdf.service';
import * as path from 'path';
import * as os from 'os';
@@ -73,6 +75,7 @@ export class EmailImportService {
private readonly pageCache: EmailPageCacheService,
private readonly imapFolderService: ImapFolderService,
private readonly fingerprints: AttachmentFingerprintService,
private readonly dokumentVersion: DokumentVersionService,
) {}
async ensurePreviews(emailId: number): Promise<void> {
@@ -348,9 +351,13 @@ export class EmailImportService {
}
// --- Print Preview ---
async generatePrintPdf(
/**
* PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999`
* = bis zum Ende) – ohne Stempel.
*/
async getAttachmentPdf(
attachmentId: number,
barcodeData: any,
pages?: { start: number; end: number },
): Promise<Buffer> {
const content = await this.contentRepo.findOne({
where: { AttachmentEntityId: attachmentId },
@@ -358,17 +365,25 @@ export class EmailImportService {
if (!content)
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
let pdfBytes: Buffer = content.Content1;
if (!pages) return content.Content1;
const pages: { start: number; end: number } | undefined =
barcodeData._pages;
if (pages) {
const pdfDoc = await PDFDocument.load(pdfBytes, {
const pdfDoc = await PDFDocument.load(content.Content1, {
ignoreEncryption: true,
});
const total = pdfDoc.getPageCount();
const startIdx = Math.max(1, pages.start) - 1;
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
const ende = pages.end === 999 ? total : Math.min(pages.end, total);
if (
!Number.isInteger(pages.start) ||
!Number.isInteger(ende) ||
pages.start < 1 ||
pages.start > ende
) {
throw new BadRequestException(
`Ungültiger Seitenbereich ${pages.start}–${pages.end} (Dokument hat ${total} Seiten)`,
);
}
const startIdx = pages.start - 1;
const endIdx = ende - 1;
const sliced = await PDFDocument.create();
const indices = Array.from(
{ length: endIdx - startIdx + 1 },
@@ -376,10 +391,18 @@ export class EmailImportService {
);
const copied = await sliced.copyPages(pdfDoc, indices);
copied.forEach((p) => sliced.addPage(p));
pdfBytes = Buffer.from(await sliced.save());
return Buffer.from(await sliced.save());
}
async generatePrintPdf(
attachmentId: number,
barcodeData: any,
): Promise<Buffer> {
const { _pages, ...barcode } = barcodeData;
const pdfBytes = await this.getAttachmentPdf(
attachmentId,
_pages as { start: number; end: number } | undefined,
);
return this.applyBarcodeToPdf(pdfBytes, barcode);
}
@@ -559,6 +582,8 @@ export class EmailImportService {
jahr: string;
};
belegnummer?: string;
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
versionZuDokumentId?: number | null;
}[];
emailDate: string;
}): Promise<{ success: boolean; results: any[] }> {
@@ -625,6 +650,31 @@ export class EmailImportService {
}
}
/**
* Hängt jede Upload-Datei als neue Version an das Stammdokument. Kein Task,
* keine Belegnummer-Bestätigung – Metadaten des Dokuments bleiben unverändert.
*/
private async importiereAlsVersion(
documentId: number,
belegnummer: string,
uploadItems: { path: string; filename: string; rangeKey: string }[],
paperlessIds: Record<string, number>,
jobId?: string,
): Promise<void> {
for (const item of uploadItems) {
this.setJobStatus(
jobId,
`Hänge ${item.filename} als neue Version an Dokument ${documentId}...`,
);
await this.dokumentVersion.haengeVersionAn(
documentId,
item.path,
belegnummer,
);
paperlessIds[item.rangeKey] = documentId;
}
}
private async runImport(data: {
jobId?: string;
attachments: {
@@ -641,6 +691,8 @@ export class EmailImportService {
jahr: string;
};
belegnummer?: string;
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
versionZuDokumentId?: number | null;
}[];
emailDate: string;
}): Promise<{ success: boolean; results: any[] }> {
@@ -668,7 +720,7 @@ export class EmailImportService {
const baseFilename = sanitizeFilename(
attachmentEntity.FileName.replace(/\.pdf$/i, ''),
);
const paperlessIds: any = {};
const paperlessIds: Record<string, number> = {};
const uploadPromises = [];
@@ -726,6 +778,29 @@ export class EmailImportService {
});
}
// Vom Benutzer bestätigt: als neue Version an das vorhandene Dokument
if (att.versionZuDokumentId) {
await this.importiereAlsVersion(
att.versionZuDokumentId,
att.belegnummer ?? '',
uploadPromises,
paperlessIds,
data.jobId,
);
attachmentEntity.PaperlessDocumentIds = paperlessIds;
attachmentEntity.ImportStatus = 1;
if (att.belegnummer) {
attachmentEntity.InterneBelegnummer = att.belegnummer;
}
await this.attachmentRepo.save(attachmentEntity);
results.push({
attachmentId: att.attachmentId,
paperlessIds,
alsVersion: true,
});
continue;
}
// 0. Check if ASN already exists
if (att.belegnummer) {
await this.paperlessService.validateAsnNotExists(att.belegnummer);
@@ -0,0 +1,45 @@
import { BadRequestException } from '@nestjs/common';
import { InboxPostprocessorService } from './inbox-postprocessor.service';
import {
ermittleUploadAsn,
originalSeitenDesAbschnitts,
} from './paperless-entscheidung.util';
describe('InboxPostprocessorService.runForDocument', () => {
it('nimmt eine Duplikat-Entscheidung nur für genau einen Abschnitt an', async () => {
const docRepo = { findOne: jest.fn() };
const service = new (InboxPostprocessorService as unknown as new (
...args: unknown[]
) => InboxPostprocessorService)({}, {}, {}, {}, {}, {}, docRepo, {}, {});
await expect(
service.runForDocument('doc-1', null, 0, false, {
aktion: 'version',
documentId: 42,
}),
).rejects.toThrow(BadRequestException);
expect(docRepo.findOne).not.toHaveBeenCalled();
});
});
describe('originalSeitenDesAbschnitts', () => {
it('bildet Indizes der bearbeiteten PDF auf Original-Seiten ab', () => {
// Seite 2 gelöscht: bearbeitete PDF = Original 1, 3, 4, 5
expect(originalSeitenDesAbschnitts([1, 3, 4, 5], [1, 2])).toEqual([3, 4]);
});
});
describe('ermittleUploadAsn', () => {
it('nimmt die ASN aus dem Template-Feld, wenn gesetzt', () => {
expect(ermittleUploadAsn('ASN 555', '2026-000123')).toBe(555);
});
it('bildet sie sonst aus der Belegnummer', () => {
expect(ermittleUploadAsn('', '2026-000123')).toBe(2026000123);
expect(ermittleUploadAsn(null, '2026-000123')).toBe(2026000123);
});
it('liefert undefined, wenn keine Ziffern vorhanden sind', () => {
expect(ermittleUploadAsn(null, 'abc')).toBeUndefined();
});
});
@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs/promises';
@@ -18,6 +23,13 @@ import {
extractSectionToTemp,
} from './edit-applier';
import { applyTemplate, buildVariables } from './variable-resolver';
import { DokumentVersionService } from '../paperless/dokument-version.service';
import {
aktionenAbEntscheidung,
ermittleUploadAsn,
originalSeitenDesAbschnitts,
type PaperlessEntscheidung,
} from './paperless-entscheidung.util';
function parseFlexDate(s: string): Date | null {
if (!s) return null;
@@ -45,6 +57,16 @@ export interface ActionResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
/** Alle gefundenen Stammdokumente (bei mehrdeutigen Treffern) */
duplikatKandidaten?: number[];
/** Der Benutzer hat das Duplikat verworfen */
verworfen?: boolean;
/** Aufgelöste interne Belegnummer (bei Duplikaten) */
belegnummer?: string;
/** Original-Seiten des Abschnitts (bei Duplikaten, für den Vergleich) */
seiten?: number[];
}
export interface RunForDocumentResult {
@@ -56,6 +78,9 @@ interface PaperlessRunResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
belegnummer?: string;
}
@Injectable()
@@ -65,6 +90,7 @@ export class InboxPostprocessorService {
constructor(
private readonly pageCache: PageCacheService,
private readonly paperlessService: PaperlessService,
private readonly dokumentVersion: DokumentVersionService,
private readonly mailService: MailService,
private readonly exportService: ExportService,
@InjectRepository(InboxPostprocessingAction)
@@ -82,8 +108,14 @@ export class InboxPostprocessorService {
preferredUsername: string | null,
sectionOffset: number = 0,
processOnlyOne: boolean = false,
replaceDuplicate: boolean = false,
entscheidung?: PaperlessEntscheidung,
): Promise<RunForDocumentResult> {
// Eine Duplikat-Entscheidung gilt immer nur für den einen Abschnitt
if (entscheidung && !processOnlyOne) {
throw new BadRequestException(
'Eine Duplikat-Entscheidung ist nur zusammen mit processOnlyOne erlaubt',
);
}
const doc = await this.docRepo.findOne({ where: { Id: documentId } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
@@ -252,16 +284,31 @@ export class InboxPostprocessorService {
: undefined;
try {
for (const action of tplActions) {
for (const action of aktionenAbEntscheidung(
tplActions,
entscheidung,
)) {
if (abortProcessing) break;
if (action.ActionType === 'PAPERLESS') {
if (
action.ActionType === 'PAPERLESS' &&
entscheidung?.aktion === 'verwerfen'
) {
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
verworfen: true,
message: 'Verworfen',
});
} else if (action.ActionType === 'PAPERLESS') {
try {
const res = await this.runPaperless(
action.Content ?? {},
sectionPdfPath,
variables,
defaultFilenameBase,
replaceDuplicate,
entscheidung,
);
results.push({
sectionIndex: currentSectionIndex,
@@ -271,6 +318,15 @@ export class InboxPostprocessorService {
skipped: res.skipped,
message: res.message,
duplicateOfDocumentId: res.duplicateOfDocumentId,
versionMoeglich: res.versionMoeglich,
duplikatKandidaten: res.duplikatKandidaten,
belegnummer: res.belegnummer,
seiten: res.skipped
? originalSeitenDesAbschnitts(
survivingOriginalPages,
pageIndices,
)
: undefined,
});
if (res.skipped) {
abortProcessing = true;
@@ -404,7 +460,7 @@ export class InboxPostprocessorService {
pdfPath: string,
variables: Record<string, string>,
defaultFilenameBase?: string,
replaceDuplicate: boolean = false,
entscheidung?: PaperlessEntscheidung,
): Promise<PaperlessRunResult> {
// 1. Interne Belegnummer auflösen (Pflicht)
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
@@ -416,25 +472,50 @@ export class InboxPostprocessorService {
if (!interneBelegnummer)
throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
// 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann)
// 2. ASN, die das Dokument beim Upload bekäme – gegen genau diese wird geprüft
const asnTpl = String(content.asn ?? '').trim();
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
let archiveSerialNumber: number | undefined;
if (asn) {
const n = parseInt(asn.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) archiveSerialNumber = n;
}
if (archiveSerialNumber === undefined) {
const n = parseInt(interneBelegnummer.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) archiveSerialNumber = n;
const archiveSerialNumber = ermittleUploadAsn(asn, interneBelegnummer);
// 3. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen
if (entscheidung?.aktion === 'version') {
await this.dokumentVersion.haengeVersionAn(
entscheidung.documentId,
pdfPath,
interneBelegnummer,
archiveSerialNumber ?? null,
);
this.logger.log(
`Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
);
return {
message: `Als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
};
}
if (replaceDuplicate) {
this.logger.log(
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`,
// 4. Duplikat-Check Paperless: CF7 + Upload-ASN
const pruefung = await this.paperlessService.pruefeBelegnummer(
interneBelegnummer,
archiveSerialNumber ?? null,
);
} else {
// 3. Duplikat-Check lokal (tasks-Tabelle)
if (pruefung.status !== 'frei') {
this.logger.warn(
`Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
);
return {
skipped: true,
message:
pruefung.status === 'eindeutig'
? `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`
: `Duplikat – Belegnummer ${interneBelegnummer} mehrfach in Paperless`,
duplicateOfDocumentId: pruefung.documentId ?? pruefung.kandidaten[0],
versionMoeglich: pruefung.status === 'eindeutig',
duplikatKandidaten: pruefung.kandidaten,
belegnummer: interneBelegnummer,
};
}
// 5. Duplikat-Check lokal (tasks-Tabelle)
const existingTask = await this.taskRepo.findOneBy({
InterneBelegnummer: interneBelegnummer,
});
@@ -446,47 +527,16 @@ export class InboxPostprocessorService {
skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
versionMoeglich: false,
belegnummer: interneBelegnummer,
};
}
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(
7,
interneBelegnummer,
);
if (cf7DocId !== null) {
this.logger.warn(
`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`,
);
return {
skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
duplicateOfDocumentId: cf7DocId,
};
}
// 5. Duplikat-Check Paperless API (archive_serial_number)
if (archiveSerialNumber !== undefined) {
const asnDocId =
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
if (asnDocId !== null) {
this.logger.warn(
`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`,
);
return {
skipped: true,
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
duplicateOfDocumentId: asnDocId,
};
}
}
// 6. Checksum berechnen und prüfen
const buffer = await fs.readFile(pdfPath);
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const checksumExists =
await this.paperlessService.checksumExists(checksum);
const checksumExists = await this.paperlessService.checksumExists(checksum);
if (checksumExists) {
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
return {
@@ -494,7 +544,6 @@ export class InboxPostprocessorService {
message: 'Duplikat (Checksum-Übereinstimmung)',
};
}
}
// 7. Restliche Metadaten auflösen
@@ -0,0 +1,38 @@
import { aktionenAbEntscheidung } from './paperless-entscheidung.util';
const MAIL = { ActionType: 'MAIL' };
const PAPERLESS = { ActionType: 'PAPERLESS' };
const EXPORT = { ActionType: 'EXPORT' };
describe('aktionenAbEntscheidung', () => {
it('ohne Entscheidung laufen alle Aktionen', () => {
expect(aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT])).toEqual([
MAIL,
PAPERLESS,
EXPORT,
]);
});
it('nach „Verwerfen“ laufen Aktionen vor PAPERLESS nicht doppelt, die danach schon', () => {
expect(
aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT], {
aktion: 'verwerfen',
}),
).toEqual([PAPERLESS, EXPORT]);
});
it('nach „Version“ geht es ab der PAPERLESS-Aktion weiter', () => {
expect(
aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT], {
aktion: 'version',
documentId: 1,
}),
).toEqual([PAPERLESS, EXPORT]);
});
it('ohne PAPERLESS-Aktion laufen trotz Entscheidung alle', () => {
expect(
aktionenAbEntscheidung([MAIL, EXPORT], { aktion: 'verwerfen' }),
).toEqual([MAIL, EXPORT]);
});
});
@@ -0,0 +1,45 @@
/** Entscheidung des Benutzers zu einem Duplikat bei der Aktion „An Paperless senden“. */
export type PaperlessEntscheidung =
| { aktion: 'version'; documentId: number }
| { aktion: 'verwerfen' };
/**
* Aktionen eines Abschnitts, die nach einer Duplikat-Entscheidung noch laufen:
* alles ab der ersten PAPERLESS-Aktion – die davor liefen schon beim ersten
* Durchlauf. Ohne Entscheidung oder ohne PAPERLESS-Aktion laufen alle.
*/
export function aktionenAbEntscheidung<T extends { ActionType: string }>(
aktionen: T[],
entscheidung?: PaperlessEntscheidung,
): T[] {
if (!entscheidung) return aktionen;
const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS');
return start === -1 ? aktionen : aktionen.slice(start);
}
/**
* Original-Seitennummern (1-basiert) eines Abschnitts: `pageIndices` sind
* 0-basierte Indizes in der bearbeiteten PDF (gelöschte Seiten fehlen dort).
*/
export function originalSeitenDesAbschnitts(
survivingOriginalPages: number[],
pageIndices: number[],
): number[] {
return pageIndices.map((idx) => survivingOriginalPages[idx]);
}
/**
* ASN, die das Dokument beim Upload bekommt: aus dem ASN-Feld der Aktion
* (bereits aufgelöst), sonst aus der internen Belegnummer – jeweils nur die Ziffern.
*/
export function ermittleUploadAsn(
asnAusTemplate: string | null,
interneBelegnummer: string,
): number | undefined {
for (const quelle of [asnAusTemplate, interneBelegnummer]) {
if (!quelle) continue;
const n = parseInt(quelle.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) return n;
}
return undefined;
}
@@ -17,10 +17,16 @@ import type { Response } from 'express';
import { createReadStream } from 'fs';
import { InboxService } from './inbox.service';
import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service';
import type { PaperlessEntscheidung } from '../inbox-postprocessor/paperless-entscheidung.util';
import { BarcodeScannerService } from '../barcode/barcode-scanner.service';
import { UserSettingsService } from '../user-settings/user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { PaperlessService } from '../paperless/paperless.service';
import {
kennzahlenAusPdf,
type VergleichKennzahlen,
} from '../paperless/vergleich-kennzahlen.util';
@Controller('api/inbox')
@RequirePermissions(Permission.VIEW_SCANNER)
@@ -30,6 +36,7 @@ export class InboxController {
private readonly postprocessor: InboxPostprocessorService,
private readonly barcodeScanner: BarcodeScannerService,
private readonly userSettingsService: UserSettingsService,
private readonly paperlessService: PaperlessService,
) {}
@Get()
@@ -137,7 +144,7 @@ export class InboxController {
body: {
sectionOffset?: number;
processOnlyOne?: boolean;
replaceDuplicate?: boolean;
entscheidung?: PaperlessEntscheidung;
},
) {
const preferredUsername: string | null =
@@ -147,7 +154,7 @@ export class InboxController {
preferredUsername,
body?.sectionOffset ?? 0,
body?.processOnlyOne ?? false,
body?.replaceDuplicate ?? false,
body?.entscheidung,
);
return { results, totalSections };
}
@@ -250,6 +257,29 @@ export class InboxController {
return new StreamableFile(Readable.from(buffer));
}
/** Seiten-/Wortzahl des Abschnitts und des vorhandenen Paperless-Dokuments. */
@Post(':id/vergleich')
async vergleich(
@Param('id') id: string,
@Body() body: { paperlessDocumentId: number; pages: number[] },
@Request() req: any,
): Promise<VergleichKennzahlen> {
const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
const { buffer } = await this.inboxService.getSegmentPdfBuffer(
id,
preferredUsername,
body.pages ?? [],
);
const [vorhanden, neu] = await Promise.all([
this.paperlessService.getDocumentKennzahlen(
Number(body.paperlessDocumentId),
),
kennzahlenAusPdf(buffer),
]);
return { vorhanden, neu };
}
@Post(':id/send-email')
@HttpCode(204)
async sendEmail(
@@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module';
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
import { UserSettingsModule } from '../user-settings/user-settings.module';
import { PaperlessModule } from '../paperless/paperless.module';
@Module({
imports: [
@@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
InboxPostprocessorModule,
PostprocessingModule,
UserSettingsModule,
PaperlessModule,
],
controllers: [InboxController, ClientsController],
providers: [InboxService, InboxMigrationService],
@@ -0,0 +1,57 @@
import {
belegnummerZuAsn,
klassifiziereTreffer,
} from './belegnummer-pruefung.util';
describe('belegnummerZuAsn', () => {
it('entfernt Bindestriche und liefert die Zahl', () => {
expect(belegnummerZuAsn('2026-000123')).toBe(2026000123);
});
it('liefert null für leere oder nicht numerische Belegnummern', () => {
expect(belegnummerZuAsn('')).toBeNull();
expect(belegnummerZuAsn('abc')).toBeNull();
});
});
describe('klassifiziereTreffer', () => {
it('ohne Treffer ist die Belegnummer frei', () => {
expect(klassifiziereTreffer([], [])).toEqual({
status: 'frei',
documentId: null,
kandidaten: [],
});
});
it('CF7 und ASN auf demselben Dokument sind eindeutig', () => {
expect(klassifiziereTreffer([42], [42])).toEqual({
status: 'eindeutig',
documentId: 42,
kandidaten: [42],
});
});
it('Stamm und Version (gleiche Stamm-ID) zählen als ein Dokument', () => {
expect(klassifiziereTreffer([42, 42], [])).toEqual({
status: 'eindeutig',
documentId: 42,
kandidaten: [42],
});
});
it('verschiedene Dokumente sind mehrdeutig', () => {
expect(klassifiziereTreffer([42], [7])).toEqual({
status: 'mehrdeutig',
documentId: null,
kandidaten: [7, 42],
});
});
it('ein Treffer nur über die ASN ist eindeutig', () => {
expect(klassifiziereTreffer([], [7])).toEqual({
status: 'eindeutig',
documentId: 7,
kandidaten: [7],
});
});
});
@@ -0,0 +1,35 @@
export type BelegnummerStatus = 'frei' | 'eindeutig' | 'mehrdeutig';
export interface BelegnummerPruefung {
status: BelegnummerStatus;
/** Stammdokument, an das eine neue Version gehängt werden kann (nur bei „eindeutig“) */
documentId: number | null;
/** Alle gefundenen Stammdokumente, aufsteigend */
kandidaten: number[];
}
/** ASN aus der internen Belegnummer: `2026-000123` → `2026000123`. */
export function belegnummerZuAsn(belegnummer: string): number | null {
const ziffern = (belegnummer ?? '').replace(/-/g, '').trim();
if (!ziffern) return null;
const asn = parseInt(ziffern, 10);
return Number.isNaN(asn) ? null : asn;
}
/**
* Führt die Treffer der CF7- und ASN-Suche (bereits auf Stammdokumente
* normalisiert) zusammen. Nur genau ein Stammdokument ist „eindeutig“.
*/
export function klassifiziereTreffer(
cf7RootIds: number[],
asnRootIds: number[],
): BelegnummerPruefung {
const kandidaten = [...new Set([...cf7RootIds, ...asnRootIds])].sort(
(a, b) => a - b,
);
if (kandidaten.length === 0)
return { status: 'frei', documentId: null, kandidaten };
if (kandidaten.length === 1)
return { status: 'eindeutig', documentId: kandidaten[0], kandidaten };
return { status: 'mehrdeutig', documentId: null, kandidaten };
}
@@ -0,0 +1,164 @@
import {
DokumentVersionService,
versionLabel,
} from './dokument-version.service';
import { PaperlessService } from './paperless.service';
describe('versionLabel', () => {
it('benennt die Version nach dem Datum', () => {
expect(versionLabel(new Date(2026, 10, 2))).toBe(
'Korrektur vom 02.11.2026',
);
});
});
describe('DokumentVersionService.haengeVersionAn', () => {
let paperless: {
getDocument: jest.Mock;
updateDocument: jest.Mock;
uploadDocumentVersion: jest.Mock;
pruefeBelegnummer: jest.Mock;
getTask: jest.Mock;
};
let service: DokumentVersionService;
beforeEach(() => {
paperless = {
getDocument: jest.fn(),
updateDocument: jest.fn().mockResolvedValue({}),
uploadDocumentVersion: jest.fn().mockResolvedValue('task-1'),
pruefeBelegnummer: jest.fn().mockResolvedValue({
status: 'eindeutig',
documentId: 42,
kandidaten: [42],
}),
getTask: jest.fn().mockResolvedValue({ status: 'SUCCESS' }),
};
service = new DokumentVersionService(
paperless as unknown as PaperlessService,
);
jest
.spyOn(service as unknown as { warte: () => Promise<void> }, 'warte')
.mockResolvedValue(undefined);
});
it('lehnt ab, wenn die Belegnummer nicht (mehr) eindeutig zu diesem Dokument gehört', async () => {
paperless.pruefeBelegnummer.mockResolvedValue({
status: 'eindeutig',
documentId: 7,
kandidaten: [7],
});
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toThrow(/Dokument 42/);
expect(paperless.updateDocument).not.toHaveBeenCalled();
expect(paperless.uploadDocumentVersion).not.toHaveBeenCalled();
});
it('prüft die Upload-ASN, wenn sie übergeben wird', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [] });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123', 555);
expect(paperless.pruefeBelegnummer).toHaveBeenCalledWith(
'2026-000123',
555,
);
});
it('wartet auf die Paperless-Verarbeitung der Version', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [] });
paperless.getTask
.mockResolvedValueOnce({ status: 'STARTED' })
.mockResolvedValueOnce({ status: 'SUCCESS' });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123');
expect(paperless.getTask).toHaveBeenCalledWith('task-1');
expect(paperless.getTask).toHaveBeenCalledTimes(2);
});
it('meldet einen Fehler und entfernt Tag 21, wenn Paperless die Version ablehnt', async () => {
paperless.getDocument
.mockResolvedValueOnce({ id: 42, tags: [3] })
.mockResolvedValueOnce({ id: 42, tags: [3, 21] });
paperless.getTask.mockResolvedValue({
status: 'FAILURE',
message: 'kaputt',
});
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toThrow(/kaputt/);
expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, {
tags: [3],
});
});
it('setzt Tag 21 vor dem Hochladen der Version', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [3, 17] });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123');
expect(paperless.updateDocument).toHaveBeenCalledWith(42, {
tags: [3, 17, 21],
});
expect(paperless.uploadDocumentVersion).toHaveBeenCalledWith(
42,
'/tmp/x.pdf',
expect.stringMatching(/^Korrektur vom \d{2}\.\d{2}\.\d{4}$/),
);
expect(paperless.updateDocument.mock.invocationCallOrder[0]).toBeLessThan(
paperless.uploadDocumentVersion.mock.invocationCallOrder[0],
);
});
it('setzt Tag 21 nicht doppelt', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [21, 5] });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123');
expect(paperless.updateDocument).not.toHaveBeenCalled();
expect(paperless.uploadDocumentVersion).toHaveBeenCalled();
});
it('entfernt den neu gesetzten Tag, wenn der Upload scheitert', async () => {
const fehler = new Error('Upload kaputt');
paperless.getDocument
.mockResolvedValueOnce({ id: 42, tags: [3] })
.mockResolvedValueOnce({ id: 42, tags: [3, 21] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler);
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toBe(fehler);
expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, {
tags: [3],
});
});
it('lässt einen schon vorhandenen Tag 21 stehen, wenn der Upload scheitert', async () => {
const fehler = new Error('Upload kaputt');
paperless.getDocument.mockResolvedValue({ id: 42, tags: [21] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler);
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toBe(fehler);
expect(paperless.updateDocument).not.toHaveBeenCalled();
});
it('meldet den Upload-Fehler, auch wenn das Entfernen des Tags scheitert', async () => {
const fehler = new Error('Upload kaputt');
paperless.getDocument.mockResolvedValue({ id: 42, tags: [3] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler);
paperless.updateDocument
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('Tag entfernen kaputt'));
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toBe(fehler);
});
});
@@ -0,0 +1,131 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { PaperlessService } from './paperless.service';
/** Tag „neue Version hochgeladen“ – Postprocessing-Regeln ignorieren solche Belege. */
export const NEUE_VERSION_TAG_ID = 21;
const pad = (n: number) => String(n).padStart(2, '0');
/** Wie beim normalen Import: bis zu 30 × 2 s auf die Paperless-Verarbeitung warten. */
const MAX_ABFRAGEN = 30;
const ABFRAGE_ABSTAND_MS = 2000;
/** Bezeichnung der Version in Paperless, z. B. „Korrektur vom 02.11.2026“. */
export function versionLabel(datum: Date): string {
return `Korrektur vom ${pad(datum.getDate())}.${pad(datum.getMonth() + 1)}.${datum.getFullYear()}`;
}
@Injectable()
export class DokumentVersionService {
private readonly logger = new Logger(DokumentVersionService.name);
constructor(private readonly paperlessService: PaperlessService) {}
/**
* Hängt eine PDF als neue Version an ein Stammdokument. Vorher wird geprüft,
* dass die Belegnummer in Paperless eindeutig zu genau diesem Dokument
* gehört. Erst wird Tag 21 gesetzt, dann die Version hochgeladen und auf die
* Verarbeitung gewartet; scheitert das, wird ein hier neu gesetzter Tag 21
* wieder entfernt.
*/
async haengeVersionAn(
rootDocumentId: number,
pdfPath: string,
belegnummer: string,
uploadAsn?: number | null,
): Promise<void> {
const pruefung = await this.paperlessService.pruefeBelegnummer(
belegnummer,
uploadAsn,
);
if (
pruefung.status !== 'eindeutig' ||
pruefung.documentId !== rootDocumentId
) {
throw new ConflictException(
`Belegnummer ${belegnummer} gehört in Paperless nicht eindeutig zu Dokument ${rootDocumentId} (gefunden: ${pruefung.kandidaten.join(', ') || 'keins'}) – keine neue Version angelegt.`,
);
}
const tagNeuGesetzt = await this.setzeTag(rootDocumentId);
try {
const taskId = await this.paperlessService.uploadDocumentVersion(
rootDocumentId,
pdfPath,
versionLabel(new Date()),
);
await this.warteAufVerarbeitung(rootDocumentId, taskId);
this.logger.log(
`Neue Version für Dokument ${rootDocumentId} hochgeladen.`,
);
} catch (err) {
if (tagNeuGesetzt) await this.entferneTag(rootDocumentId);
throw err;
}
}
/**
* Wartet, bis Paperless die Version verarbeitet hat. FAILURE/REVOKED → Fehler;
* bleibt die Task offen, wird nur gewarnt (Ergebnis unbekannt).
*/
private async warteAufVerarbeitung(
documentId: number,
taskId: string,
): Promise<void> {
if (!taskId) {
this.logger.warn(
`Keine Task-ID für die Version von Dokument ${documentId} – Verarbeitung nicht überprüfbar.`,
);
return;
}
for (let i = 0; i < MAX_ABFRAGEN; i++) {
const task = await this.paperlessService
.getTask(taskId)
.catch(() => null);
if (task?.status === 'SUCCESS') return;
if (task?.status === 'FAILURE' || task?.status === 'REVOKED') {
throw new Error(
`Paperless hat die neue Version für Dokument ${documentId} abgelehnt: ${task.message ?? 'keine Meldung'}`,
);
}
await this.warte(ABFRAGE_ABSTAND_MS);
}
this.logger.warn(
`Version für Dokument ${documentId} (Task ${taskId}) nach ${(MAX_ABFRAGEN * ABFRAGE_ABSTAND_MS) / 1000} s noch nicht verarbeitet.`,
);
}
protected warte(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Setzt Tag 21; liefert true, wenn er vorher fehlte. */
private async setzeTag(documentId: number): Promise<boolean> {
const tags = await this.leseTags(documentId);
if (tags.includes(NEUE_VERSION_TAG_ID)) return false;
await this.paperlessService.updateDocument(documentId, {
tags: [...tags, NEUE_VERSION_TAG_ID],
});
return true;
}
private async entferneTag(documentId: number): Promise<void> {
try {
const tags = await this.leseTags(documentId);
await this.paperlessService.updateDocument(documentId, {
tags: tags.filter((t) => t !== NEUE_VERSION_TAG_ID),
});
} catch (err) {
this.logger.error(
`Tag ${NEUE_VERSION_TAG_ID} konnte nach fehlgeschlagenem Versions-Upload nicht von Dokument ${documentId} entfernt werden: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private async leseTags(documentId: number): Promise<number[]> {
const doc = (await this.paperlessService.getDocument(documentId)) as {
tags?: number[];
};
return Array.isArray(doc?.tags) ? doc.tags.map(Number) : [];
}
}
@@ -0,0 +1,101 @@
import { ConfigService } from '@nestjs/config';
import { PaperlessService } from './paperless.service';
describe('PaperlessService – Belegnummer-Suche', () => {
let service: PaperlessService;
let get: jest.Mock;
beforeEach(() => {
service = new PaperlessService({
get: (_k: string, d?: unknown) => d,
} as unknown as ConfigService);
get = jest.fn();
(service as unknown as { client: { get: jest.Mock } }).client = { get };
});
it('führt Stamm und Version auf das Stammdokument zusammen (CF7)', async () => {
get.mockResolvedValue({
data: {
results: [
{
id: 42,
root_document: null,
custom_fields: [{ field: 7, value: '2026-000123' }],
},
{
id: 99,
root_document: 42,
custom_fields: [{ field: 7, value: '2026-000123' }],
},
{
id: 50,
root_document: null,
custom_fields: [{ field: 7, value: '2026-0001234' }],
},
],
},
});
expect(
await service.findRootDocumentIdsByCustomField(7, '2026-000123'),
).toEqual([42, 42]);
});
it('führt Stamm und Version auf das Stammdokument zusammen (ASN)', async () => {
get.mockResolvedValue({
data: {
results: [
{ id: 42, archive_serial_number: 2026000123 },
{ id: 99, root_document: 42, archive_serial_number: 2026000123 },
],
},
});
expect(await service.findRootDocumentIdsByAsn(2026000123)).toEqual([
42, 42,
]);
});
it('ein Dokument mit Versionen gilt als eindeutig', async () => {
get.mockImplementation(
(_url: string, cfg: { params: Record<string, unknown> }) =>
Promise.resolve({
data: {
results: cfg.params.archive_serial_number
? [
{
id: 99,
root_document: 42,
archive_serial_number: 2026000123,
},
]
: [
{
id: 42,
custom_fields: [{ field: 7, value: '2026-000123' }],
},
],
},
}),
);
expect(await service.pruefeBelegnummer('2026-000123')).toEqual({
status: 'eindeutig',
documentId: 42,
kandidaten: [42],
});
});
it('prüft die übergebene Upload-ASN statt der aus der Belegnummer gebildeten', async () => {
get.mockResolvedValue({ data: { results: [] } });
await service.pruefeBelegnummer('2026-000123', 555);
const asnWerte = (
get.mock.calls as [string, { params: Record<string, unknown> }][]
)
.map(([, cfg]) => cfg.params.archive_serial_number)
.filter((wert) => wert !== undefined);
expect(asnWerte).toEqual([555]);
});
});
@@ -81,6 +81,13 @@ export class PaperlessController {
return { exists };
}
/** Ist die interne Belegnummer in Paperless schon vergeben (CF7 + ASN)? */
@Get('belegnummer-pruefung')
@RequirePermissions(Permission.VIEW_MAIL)
async pruefeBelegnummer(@Query('belegnummer') belegnummer: string) {
return this.paperlessService.pruefeBelegnummer(belegnummer ?? '');
}
@Get('documents')
async getDocuments(
@Query('search') search?: string,
@@ -4,6 +4,7 @@ import { PaperlessService } from './paperless.service';
import { PaperlessController } from './paperless.controller';
import { PaperlessProcessorService } from './paperless-processor.service';
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
import { DokumentVersionService } from './dokument-version.service';
import { DocumentType } from '../database/entities/document-type.entity';
import { DocumentField } from '../database/entities/document-field.entity';
import { Task } from '../database/entities/task.entity';
@@ -33,7 +34,12 @@ import { ErechnungModule } from '../erechnung/erechnung.module';
PaperlessService,
PaperlessProcessorService,
PaperlessTaskProcessorService,
DokumentVersionService,
],
exports: [
PaperlessService,
PaperlessProcessorService,
DokumentVersionService,
],
exports: [PaperlessService, PaperlessProcessorService],
})
export class PaperlessModule {}
@@ -4,6 +4,20 @@ import axios, { type AxiosInstance } from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import FormData = require('form-data');
import { type Kennzahlen, zaehleWoerter } from './vergleich-kennzahlen.util';
import {
type BelegnummerPruefung,
belegnummerZuAsn,
klassifiziereTreffer,
} from './belegnummer-pruefung.util';
/** Ausschnitt eines Paperless-Dokuments, wie ihn die Belegnummer-Suche braucht. */
interface PaperlessTrefferDokument {
id: number;
root_document?: number | null;
archive_serial_number?: number | null;
custom_fields?: { field: number; value: string | number | boolean | null }[];
}
export interface PaperlessTaskStatus {
taskId: string;
@@ -132,6 +146,34 @@ export class PaperlessService {
return response.data;
}
/**
* Lädt eine PDF als neue Version eines vorhandenen Dokuments hoch
* (`update_version`, Paperless-NGX API v10). Liefert die Consume-Task-ID.
*/
async uploadDocumentVersion(
documentId: number,
filePath: string,
versionLabel: string,
): Promise<string> {
const form = new FormData();
form.append('document', fs.createReadStream(filePath), {
filename: path.basename(filePath),
contentType: 'application/pdf',
});
form.append('version_label', versionLabel.slice(0, 64));
const response = await this.client.post<string>(
`/documents/${documentId}/update_version/`,
form,
{ headers: form.getHeaders(), timeout: 120000 },
);
const taskId = (response.data ?? '').replace(/"/g, '');
this.logger.log(
`Version für Dokument ${documentId} hochgeladen: ${taskId || '(keine Task-ID)'}`,
);
return taskId;
}
async getUsers(): Promise<any[]> {
const response = await this.client.get('/users/');
return response.data?.results ?? [];
@@ -147,6 +189,18 @@ export class PaperlessService {
return response.data;
}
/** Seiten- und Wortzahl eines Paperless-Dokuments (aktuelle Fassung). */
async getDocumentKennzahlen(id: number): Promise<Kennzahlen> {
const response = await this.client.get<{
page_count?: number | null;
content?: string | null;
}>(`/documents/${id}/`);
return {
seiten: response.data.page_count ?? null,
woerter: zaehleWoerter(response.data.content),
};
}
async getInboxDocuments(): Promise<any[]> {
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
const response = await this.client.get('/documents/', {
@@ -396,4 +450,72 @@ export class PaperlessService {
);
return match ? Number(match.id) : null;
}
/** Stamm-ID eines Treffers – Versionen zeigen über `root_document` auf ihren Stamm. */
private static rootId(doc: PaperlessTrefferDokument): number {
return Number(doc.root_document ?? doc.id);
}
private async sucheDokumente(
params: Record<string, unknown>,
): Promise<PaperlessTrefferDokument[]> {
const response = await this.client.get<{
results?: PaperlessTrefferDokument[];
}>('/documents/', {
params: { ...params, page_size: 100, truncate_content: true },
});
return response.data.results ?? [];
}
/**
* Alle Stammdokumente, deren Custom Field exakt (case-insensitiv) den Wert hat.
*/
async findRootDocumentIdsByCustomField(
fieldId: number,
value: string,
): Promise<number[]> {
const treffer = await this.sucheDokumente({
custom_field_query: JSON.stringify([fieldId, 'icontains', value]),
});
const valueLower = value.toLowerCase();
return treffer
.filter((doc) =>
(doc.custom_fields ?? []).some(
(cf) =>
cf.field === fieldId &&
String(cf.value ?? '').toLowerCase() === valueLower,
),
)
.map((doc) => PaperlessService.rootId(doc));
}
/** Alle Stammdokumente mit genau dieser ASN. */
async findRootDocumentIdsByAsn(asn: number): Promise<number[]> {
if (!Number.isFinite(asn)) return [];
const treffer = await this.sucheDokumente({ archive_serial_number: asn });
return treffer
.filter((doc) => Number(doc.archive_serial_number) === asn)
.map((doc) => PaperlessService.rootId(doc));
}
/**
* Prüft, ob die interne Belegnummer in Paperless schon vergeben ist – über
* CF7 und die ASN, die das Dokument beim Upload bekäme (`uploadAsn`; ohne
* Angabe die aus der Belegnummer gebildete).
*/
async pruefeBelegnummer(
belegnummer: string,
uploadAsn?: number | null,
): Promise<BelegnummerPruefung> {
const nummer = (belegnummer ?? '').trim();
if (!nummer) return klassifiziereTreffer([], []);
const asn = uploadAsn ?? belegnummerZuAsn(nummer);
const [cf7, asnTreffer] = await Promise.all([
this.findRootDocumentIdsByCustomField(7, nummer),
asn !== null
? this.findRootDocumentIdsByAsn(asn)
: Promise.resolve([] as number[]),
]);
return klassifiziereTreffer(cf7, asnTreffer);
}
}
@@ -0,0 +1,48 @@
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { kennzahlenAusPdf, zaehleWoerter } from './vergleich-kennzahlen.util';
describe('zaehleWoerter', () => {
it('zählt nur Wörter mit Buchstaben oder Ziffern', () => {
// „–“ und „€“ zählen nicht
expect(zaehleWoerter('Rechnung Nr. 123 – Betrag 4,50 €')).toBe(5);
});
it('ignoriert Seitenmarker von pdf-parse', () => {
expect(zaehleWoerter('-- 1 of 2 --')).toBeNull();
});
it('liefert null für leeren oder fehlenden Text', () => {
expect(zaehleWoerter('')).toBeNull();
expect(zaehleWoerter(null)).toBeNull();
});
});
describe('kennzahlenAusPdf', () => {
it('ermittelt Seiten und Wörter eines PDFs mit Textebene', async () => {
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
pdf.addPage().drawText('Hallo Welt', { x: 50, y: 700, font, size: 14 });
pdf.addPage();
const buffer = Buffer.from(await pdf.save());
expect(await kennzahlenAusPdf(buffer)).toEqual({ seiten: 2, woerter: 2 });
});
it('meldet bei PDFs ohne Text die Wortzahl als nicht ermittelbar', async () => {
const pdf = await PDFDocument.create();
pdf.addPage();
const buffer = Buffer.from(await pdf.save());
expect(await kennzahlenAusPdf(buffer)).toEqual({
seiten: 1,
woerter: null,
});
});
it('liefert für unlesbare Dateien keine Kennzahlen', async () => {
expect(await kennzahlenAusPdf(Buffer.from('kaputt'))).toEqual({
seiten: null,
woerter: null,
});
});
});
@@ -0,0 +1,56 @@
import { PDFDocument } from 'pdf-lib';
import { PDFParse } from 'pdf-parse';
/** Kennzahlen eines Belegs; `null` = nicht ermittelbar. */
export interface Kennzahlen {
seiten: number | null;
woerter: number | null;
}
/** Kennzahlen des vorhandenen (Paperless) und des neuen Belegs. */
export interface VergleichKennzahlen {
vorhanden: Kennzahlen;
neu: Kennzahlen;
}
/**
* Zählt Wörter: Tokens zwischen Leerraum, die mindestens einen Buchstaben oder
* eine Ziffer enthalten. Seitenmarker von pdf-parse („-- 1 of 2 --“) zählen
* nicht. Kein Wort → `null` (z. B. Scan ohne Textebene).
*/
export function zaehleWoerter(text: string | null | undefined): number | null {
if (!text) return null;
const ohneMarker = text.replace(/-- \d+ of \d+ --/g, ' ');
const anzahl = ohneMarker
.split(/\s+/)
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
return anzahl > 0 ? anzahl : null;
}
async function seitenzahl(pdf: Buffer): Promise<number | null> {
try {
const doc = await PDFDocument.load(pdf, { ignoreEncryption: true });
return doc.getPageCount();
} catch {
return null;
}
}
async function wortzahl(pdf: Buffer): Promise<number | null> {
// pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben
const parser = new PDFParse({ data: new Uint8Array(pdf) });
try {
const { text } = await parser.getText();
return zaehleWoerter(text);
} catch {
return null;
} finally {
await parser.destroy().catch(() => undefined);
}
}
/** Seiten- und Wortzahl eines PDFs; Lesefehler ergeben `null`. */
export async function kennzahlenAusPdf(pdf: Buffer): Promise<Kennzahlen> {
const [seiten, woerter] = await Promise.all([seitenzahl(pdf), wortzahl(pdf)]);
return { seiten, woerter };
}
@@ -1,4 +1,5 @@
import api from './client';
import type { VergleichKennzahlen } from './paperless';
export interface CorrespondentMapping {
Id: number;
@@ -19,6 +20,8 @@ export interface AttachmentImportData {
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
belegnummer?: string;
isDuplicate?: boolean;
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
versionZuDokumentId?: number | null;
}
export const emailImportApi = {
@@ -50,6 +53,23 @@ export const emailImportApi = {
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
},
attachmentPdfBlob: async (attachmentId: number, pages?: { start: number; end: number }): Promise<Blob> => {
const res = await api.get(`/api/email-import/attachments/${attachmentId}/pdf`, {
params: pages ? { start: pages.start, end: pages.end } : undefined,
responseType: 'blob',
});
return res.data;
},
vergleich: async (
attachmentId: number,
paperlessDocumentId: number,
pages?: { start: number; end: number },
): Promise<VergleichKennzahlen> => {
const res = await api.post(`/api/email-import/attachments/${attachmentId}/vergleich`, { paperlessDocumentId, pages });
return res.data;
},
printPreview: async (attachmentId: number, barcodeData: any, pages?: { start: number; end: number }): Promise<Blob> => {
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
responseType: 'blob',
+17 -1
View File
@@ -1,5 +1,6 @@
import api from './client';
import type { BarcodeActionType } from './barcode-templates';
import type { VergleichKennzahlen } from './paperless';
export type InboxSource = 'all' | 'user';
@@ -86,7 +87,7 @@ export const inboxApi = {
postprocess: (
id: string,
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung },
) =>
api
.post<{ results: PostprocessActionResult[]; totalSections: number }>(
@@ -107,6 +108,11 @@ export const inboxApi = {
)
.then((r) => r.data),
vergleich: (id: string, paperlessDocumentId: number, pages: number[]) =>
api
.post<VergleichKennzahlen>(`/api/inbox/${encodeURIComponent(id)}/vergleich`, { paperlessDocumentId, pages })
.then((r) => r.data),
downloadSegmentBlob: (id: string, pages: number[]) =>
api
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
@@ -126,6 +132,9 @@ export const inboxApi = {
api.post(`/api/inbox/${encodeURIComponent(id)}/send-email`, body).then(() => {}),
};
/** Entscheidung des Benutzers zu einem Duplikat bei „An Paperless senden“ */
export type PaperlessEntscheidung = { aktion: 'version'; documentId: number } | { aktion: 'verwerfen' };
export interface PostprocessActionResult {
sectionIndex: number;
actionId: number;
@@ -134,6 +143,13 @@ export interface PostprocessActionResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
verworfen?: boolean;
belegnummer?: string;
/** Original-Seiten des Abschnitts laut Backend (bei Duplikaten) */
seiten?: number[];
}
export const clientsApi = {
+22
View File
@@ -1,5 +1,25 @@
import api from './client';
export type BelegnummerStatus = 'frei' | 'eindeutig' | 'mehrdeutig';
export interface BelegnummerPruefung {
status: BelegnummerStatus;
/** Stammdokument für eine neue Version (nur bei „eindeutig“) */
documentId: number | null;
kandidaten: number[];
}
/** Kennzahlen eines Belegs; `null` = nicht ermittelbar. */
export interface Kennzahlen {
seiten: number | null;
woerter: number | null;
}
export interface VergleichKennzahlen {
vorhanden: Kennzahlen;
neu: Kennzahlen;
}
export interface PaperlessTag {
id: number;
slug: string;
@@ -62,4 +82,6 @@ export const paperlessApi = {
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
getDocumentPdfBlob: (id: number) =>
api.get<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
pruefeBelegnummer: (belegnummer: string) =>
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
};
@@ -0,0 +1,198 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Alert, Button, Modal, Space, Spin, Tag, Typography, message } from 'antd';
import { paperlessApi, type BelegnummerPruefung, type VergleichKennzahlen } from '../api/paperless';
import { useIsMobile } from '../hooks/useIsMobile';
/** Länger wird auf Seiten-/Wortzahl nicht gewartet – der Vergleich bleibt trotzdem möglich. */
const KENNZAHLEN_TIMEOUT_MS = 30000;
function mitTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Zeitüberschreitung')), ms);
promise.then(
(wert) => {
clearTimeout(timer);
resolve(wert);
},
(err: unknown) => {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
},
);
});
}
export interface DuplikatVergleichModalProps {
open: boolean;
belegnummer: string;
/** eindeutig → Version möglich; mehrdeutig → nur Verwerfen */
pruefung: BelegnummerPruefung | null;
/** Vorhandenes Dokument (linke Seite) */
paperlessDocumentId: number | null;
/** Neuer Beleg (rechte Seite) */
ladeNeuesPdf: () => Promise<Blob>;
ladeKennzahlen: () => Promise<VergleichKennzahlen>;
onVersion: () => void;
onVerwerfen: () => void;
onClose: () => void;
}
function KennzahlZeile({ label, vorhanden, neu }: { label: string; vorhanden: number | null; neu: number | null }) {
const anzeige = (wert: number | null) => (wert === null ? 'nicht ermittelbar' : String(wert));
const vergleichbar = vorhanden !== null && neu !== null;
const abweichung = vergleichbar && vorhanden !== neu;
return (
<div>
<Typography.Text strong>{label}:</Typography.Text> {anzeige(vorhanden)} (vorhanden) → {anzeige(neu)} (neu){' '}
{abweichung && <Tag color="orange">abweichend</Tag>}
{!vergleichbar && <Tag>nicht vergleichbar</Tag>}
</div>
);
}
function PdfSpalte({ titel, url, loading }: { titel: string; url: string | null; loading: boolean }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
{titel}
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !url ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={url} title={titel} style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
);
}
/**
* Vergleich eines Belegs mit einem vorhandenen Paperless-Dokument derselben
* internen Belegnummer: Auffälligkeiten (Seiten-/Wortzahl) oben, beide PDFs
* nebeneinander, Entscheidung „neue Version“ oder „verwerfen“.
*/
export function DuplikatVergleichModal({
open,
belegnummer,
pruefung,
paperlessDocumentId,
ladeNeuesPdf,
ladeKennzahlen,
onVersion,
onVerwerfen,
onClose,
}: DuplikatVergleichModalProps) {
const isMobile = useIsMobile();
const [vorhandenUrl, setVorhandenUrl] = useState<string | null>(null);
const [neuUrl, setNeuUrl] = useState<string | null>(null);
const [kennzahlen, setKennzahlen] = useState<VergleichKennzahlen | null>(null);
const [kennzahlenFehler, setKennzahlenFehler] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || paperlessDocumentId === null) return;
let cancelled = false;
let urlAlt: string | null = null;
let urlNeu: string | null = null;
setLoading(true);
setKennzahlen(null);
setKennzahlenFehler(false);
(async () => {
try {
const [altBlob, neuBlob] = await Promise.all([paperlessApi.getDocumentPdfBlob(paperlessDocumentId), ladeNeuesPdf()]);
if (cancelled) return;
urlAlt = URL.createObjectURL(altBlob);
urlNeu = URL.createObjectURL(neuBlob);
setVorhandenUrl(urlAlt);
setNeuUrl(urlNeu);
} catch {
if (!cancelled) message.error('Vorschau konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
mitTimeout(ladeKennzahlen(), KENNZAHLEN_TIMEOUT_MS)
.then((k) => {
if (!cancelled) setKennzahlen(k);
})
.catch(() => {
if (!cancelled) setKennzahlenFehler(true);
});
return () => {
cancelled = true;
if (urlAlt) URL.revokeObjectURL(urlAlt);
if (urlNeu) URL.revokeObjectURL(urlNeu);
setVorhandenUrl(null);
setNeuUrl(null);
};
// Lade-Funktionen ändern sich bei jedem Render; neu laden nur bei anderem Dokument
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, paperlessDocumentId]);
const versionMoeglich = pruefung?.status === 'eindeutig';
let auffaelligkeiten: ReactNode = <Spin size="small" />;
if (kennzahlenFehler) {
auffaelligkeiten = (
<Alert type="warning" showIcon message="Seiten- und Wortzahl konnten nicht ermittelt werden – bitte die PDFs direkt vergleichen." />
);
} else if (kennzahlen) {
const { vorhanden, neu } = kennzahlen;
const alleBekannt = [vorhanden.seiten, neu.seiten, vorhanden.woerter, neu.woerter].every((w) => w !== null);
const gleich = alleBekannt && vorhanden.seiten === neu.seiten && vorhanden.woerter === neu.woerter;
auffaelligkeiten = gleich ? (
<Alert type="success" showIcon message="Seiten- und Wortzahl gleich" />
) : (
<Alert
type="info"
showIcon
message="Auffälligkeiten"
description={
<Space direction="vertical" size={2}>
<KennzahlZeile label="Seiten" vorhanden={vorhanden.seiten} neu={neu.seiten} />
<KennzahlZeile label="Wörter" vorhanden={vorhanden.woerter} neu={neu.woerter} />
</Space>
}
/>
);
}
return (
<Modal
open={open}
title={`Belegnummer ${belegnummer} existiert bereits`}
onCancel={onClose}
width="90vw"
style={{ top: 20 }}
destroyOnClose
footer={[
<Button key="verwerfen" danger onClick={onVerwerfen}>
Verwerfen
</Button>,
versionMoeglich && (
<Button key="version" type="primary" onClick={onVersion}>
Als neue Version hinzufügen
</Button>
),
]}
>
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{pruefung?.status === 'mehrdeutig' && (
<Alert
type="warning"
showIcon
message={`Mehrere Dokumente in Paperless mit dieser Belegnummer (IDs: ${pruefung.kandidaten.join(', ')}) – eine neue Version ist nicht möglich.`}
/>
)}
{auffaelligkeiten}
</Space>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 12, height: '70vh' }}>
<PdfSpalte titel={`Vorhanden (Paperless #${paperlessDocumentId ?? '–'})`} url={vorhandenUrl} loading={loading} />
<PdfSpalte titel="Neuer Beleg" url={neuUrl} loading={loading} />
</div>
</Modal>
);
}
@@ -6,13 +6,14 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
import { emailsApi } from '../api/emails';
import { paperlessApi, type PaperlessCorrespondent } from '../api/paperless';
import { paperlessApi, type BelegnummerPruefung, type PaperlessCorrespondent } from '../api/paperless';
import dayjs from 'dayjs';
import {
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
} from '@ant-design/icons';
import PdfSplitViewer from './PdfSplitViewer';
import BarcodePositioner from './BarcodePositioner';
import { DuplikatVergleichModal } from './DuplikatVergleichModal';
const { Text } = Typography;
@@ -43,6 +44,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
// Eingangsdatum per item
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
// Duplikatvergleich: Entscheidung „neue Version“ je Beleg (gilt nur für diese Belegnummer)
const [versionEntscheidung, setVersionEntscheidung] = useState<Record<string, { belegnummer: string; documentId: number }>>({});
// Noch zu entscheidende Treffer, werden nacheinander im Vergleichsdialog gezeigt
const [vergleichQueue, setVergleichQueue] = useState<{ virtualId: string; belegnummer: string; pruefung: BelegnummerPruefung }[]>([]);
// Step 3 specific state
const [importSuccess, setImportSuccess] = useState(false);
const [importStatus, setImportStatus] = useState('');
@@ -78,6 +84,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
});
setEingangsdaten(initialDates);
setBarcodes(initialBarcodes);
setVersionEntscheidung({});
setVergleichQueue([]);
// Load correspondents and try to find suggestion
loadCorrespondents();
@@ -349,6 +357,72 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
}
};
// Manuell eingegebene Belegnummer im Format JJJJ-NNNNNN (wie beim Import), sonst null
const manuelleBelegnummer = (virtualId: string): string | null => {
if ((belegnummerMode[virtualId] || 'neu') !== 'manuell') return null;
let num = belegnummern[virtualId] || '';
if (num.includes('-')) num = num.split('-')[1];
if (!num || /^0+$/.test(num)) return null;
const yr = barcodes[virtualId]?.jahr || (eingangsdaten[virtualId] || dayjs(email.Date)).format('YYYY');
return `${yr}-${String(num).padStart(6, '0')}`;
};
// Prüft manuelle Belegnummern gegen Paperless; liefert die Treffer ohne gültige Entscheidung
const pruefeManuelleBelegnummern = async () => {
const kandidaten = importData
.filter(i => i.type !== 'IGNORE')
.map(i => ({ virtualId: i.virtualId, belegnummer: manuelleBelegnummer(i.virtualId) }))
.filter((k): k is { virtualId: string; belegnummer: string } => k.belegnummer !== null);
// Entscheidungen für inzwischen geänderte Belegnummern verfallen
const gueltig: typeof versionEntscheidung = {};
for (const k of kandidaten) {
const e = versionEntscheidung[k.virtualId];
if (e && e.belegnummer === k.belegnummer) gueltig[k.virtualId] = e;
}
setVersionEntscheidung(gueltig);
const offen = kandidaten.filter(k => !gueltig[k.virtualId]);
const pruefungen = await Promise.all(offen.map(k => paperlessApi.pruefeBelegnummer(k.belegnummer)));
return offen
.map((k, idx) => ({ ...k, pruefung: pruefungen[idx] }))
.filter(k => k.pruefung.status !== 'frei');
};
const weiterZuAbschluss = async (daten: AttachmentImportData[]) => {
if (daten.length > 0 && daten.every(i => i.type === 'IGNORE')) {
await ignoreEmail();
return;
}
await preFetchBelegnummern();
setCurrentStep(2);
};
// Nächsten Treffer zeigen; nach dem letzten geht es zu Schritt 3
const naechsterVergleich = (daten: AttachmentImportData[]) => {
const rest = vergleichQueue.slice(1);
setVergleichQueue(rest);
if (rest.length === 0) void weiterZuAbschluss(daten);
};
const handleVergleichVersion = () => {
const aktuell = vergleichQueue[0];
if (!aktuell?.pruefung.documentId) return;
setVersionEntscheidung(prev => ({
...prev,
[aktuell.virtualId]: { belegnummer: aktuell.belegnummer, documentId: aktuell.pruefung.documentId! },
}));
naechsterVergleich(importData);
};
const handleVergleichVerwerfen = () => {
const aktuell = vergleichQueue[0];
if (!aktuell) return;
const daten = importData.map(i => (i.virtualId === aktuell.virtualId ? { ...i, type: 'IGNORE' as const } : i));
setImportData(daten);
naechsterVergleich(daten);
};
const nextStep = async () => {
if (currentStep === 0) {
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
@@ -372,6 +446,20 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
} finally {
setLoading(false);
}
// Manuelle Belegnummern, die es in Paperless schon gibt → erst vergleichen
setLoading(true);
try {
const treffer = await pruefeManuelleBelegnummern();
if (treffer.length > 0) {
setVergleichQueue(treffer);
return;
}
} catch {
message.error('Belegnummer-Prüfung fehlgeschlagen – bitte erneut versuchen.');
return;
} finally {
setLoading(false);
}
await preFetchBelegnummern();
}
setCurrentStep(currentStep + 1);
@@ -448,9 +536,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
}
const finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
const entscheidung = versionEntscheidung[item.virtualId];
finalData.push({
...item,
versionZuDokumentId: entscheidung?.belegnummer === finalBelegnummer ? entscheidung.documentId : null,
splitRanges: item.pages ? [item.pages] : undefined,
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
belegnummer: finalBelegnummer,
@@ -765,6 +855,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
<Text type="secondary">
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
</Text>
{versionEntscheidung[main.virtualId]?.belegnummer === belegnr && (
<Tag color="purple">
wird als neue Version zu Dokument {versionEntscheidung[main.virtualId].documentId} hinzugefügt
</Tag>
)}
</Space>
{mainAttachments.length > 0 && (
<div style={{ marginTop: 12, paddingLeft: 16 }}>
@@ -855,6 +950,26 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
{currentStep === 2 && renderStep3()}
</div>
</Spin>
{vergleichQueue.length > 0 && (() => {
const aktuell = vergleichQueue[0];
const item = importData.find(i => i.virtualId === aktuell.virtualId);
const docId = aktuell.pruefung.documentId ?? aktuell.pruefung.kandidaten[0] ?? null;
if (!item) return null;
return (
<DuplikatVergleichModal
key={aktuell.virtualId}
open
belegnummer={aktuell.belegnummer}
pruefung={aktuell.pruefung}
paperlessDocumentId={docId}
ladeNeuesPdf={() => emailImportApi.attachmentPdfBlob(item.attachmentId, item.pages)}
ladeKennzahlen={() => emailImportApi.vergleich(item.attachmentId, docId ?? 0, item.pages)}
onVersion={handleVergleichVersion}
onVerwerfen={handleVergleichVerwerfen}
onClose={() => setVergleichQueue([])}
/>
);
})()}
</Modal>
);
}
+51 -130
View File
@@ -22,8 +22,9 @@ import {
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons';
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
import type { BelegnummerPruefung } from '../api/paperless';
import { DuplikatVergleichModal } from '../components/DuplikatVergleichModal';
import { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -97,110 +98,6 @@ function SourceTag({ source }: { source: InboxFile['source'] }) {
);
}
interface CompareModalProps {
open: boolean;
paperlessDocumentId: number | null;
inboxDocumentId: string;
onClose: () => void;
onCreateNewVersion: () => void;
onSkip: () => void;
}
function CompareModal({
open,
paperlessDocumentId,
inboxDocumentId,
onClose,
onCreateNewVersion,
onSkip,
}: CompareModalProps) {
const compareIsMobile = useIsMobile();
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || paperlessDocumentId === null) return;
let cancelled = false;
let papUrl: string | null = null;
let inbUrl: string | null = null;
setLoading(true);
(async () => {
try {
const [pBlob, iBlob] = await Promise.all([
paperlessApi.getDocumentPdfBlob(paperlessDocumentId),
inboxApi.previewBlob(inboxDocumentId),
]);
if (cancelled) return;
papUrl = URL.createObjectURL(pBlob);
inbUrl = URL.createObjectURL(iBlob);
setPaperlessUrl(papUrl);
setInboxUrl(inbUrl);
} catch {
if (!cancelled) message.error('Vorschau konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (papUrl) URL.revokeObjectURL(papUrl);
if (inbUrl) URL.revokeObjectURL(inbUrl);
setPaperlessUrl(null);
setInboxUrl(null);
};
}, [open, paperlessDocumentId, inboxDocumentId]);
return (
<Modal
open={open}
title="Duplikat vergleichen"
onCancel={onClose}
width="90vw"
style={{ top: 20 }}
footer={[
<Button key="skip" onClick={onSkip}>
Überspringen
</Button>,
<Button key="new" type="primary" onClick={onCreateNewVersion}>
Neue Version anlegen
</Button>,
]}
>
<div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Original (Paperless)
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !paperlessUrl ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={paperlessUrl} title="Paperless Original" style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Aktueller Abschnitt (Inbox)
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !inboxUrl ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={inboxUrl} title="Inbox Abschnitt" style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
</div>
</Modal>
);
}
type WizardStepStatus = 'pending' | 'processing' | 'success' | 'duplicate' | 'error' | 'skipped';
interface WizardStep {
@@ -259,7 +156,7 @@ function PostprocessWizardModal({
setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s)));
};
const processSection = async (idx: number, replaceDuplicate = false) => {
const processSection = async (idx: number, entscheidung?: PaperlessEntscheidung) => {
if (!documentId) return;
setBusy(true);
setStepAt(idx, { status: 'processing', message: undefined });
@@ -267,13 +164,17 @@ function PostprocessWizardModal({
const { results } = await inboxApi.postprocess(documentId, {
sectionOffset: idx,
processOnlyOne: true,
replaceDuplicate,
entscheidung,
});
const skipped = results.find((r) => r.skipped);
const failed = results.filter((r) => !r.ok);
if (skipped) {
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
setAwaitingDecision(true);
// Treffer in Paperless → Vergleich direkt öffnen
if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) {
setCompareForSection(idx);
}
} else if (failed.length > 0) {
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
setStepAt(idx, { status: 'error', message: msg, results });
@@ -281,6 +182,9 @@ function PostprocessWizardModal({
} else if (results.length === 0) {
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
setAwaitingDecision(true);
} else if (entscheidung?.aktion === 'verwerfen') {
setStepAt(idx, { status: 'skipped', message: 'Verworfen', results });
setCurrentSection((s) => s + 1);
} else {
setStepAt(idx, { status: 'success', results });
setCurrentSection((s) => s + 1);
@@ -303,9 +207,19 @@ function PostprocessWizardModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
const handleReplace = () => {
const handleVersion = () => {
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
if (documentId === undefined) return;
setCompareForSection(null);
setAwaitingDecision(false);
void processSection(currentSection, true);
void processSection(currentSection, { aktion: 'version', documentId });
};
// Folgende Aktionen (z. B. Export) laufen trotzdem – nur der Paperless-Upload entfällt
const handleVerwerfen = () => {
setCompareForSection(null);
setAwaitingDecision(false);
void processSection(currentSection, { aktion: 'verwerfen' });
};
const handleSkip = () => {
@@ -388,16 +302,13 @@ function PostprocessWizardModal({
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
{idx === currentSection && awaitingDecision && (
<Space wrap>
<Button type="primary" size="small" onClick={handleReplace}>
Neue Version erstellen
</Button>
{dupDocId !== null && (
<Button size="small" onClick={() => setCompareForSection(idx)}>
<Button type="primary" size="small" onClick={() => setCompareForSection(idx)}>
Vergleichen
</Button>
)}
<Button size="small" onClick={handleSkip}>
Überspringen
<Button size="small" danger onClick={handleVerwerfen}>
Verwerfen
</Button>
</Space>
)}
@@ -508,23 +419,33 @@ function PostprocessWizardModal({
) : (
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
)}
<CompareModal
open={compareForSection !== null}
paperlessDocumentId={
compareForSection !== null
? steps[compareForSection]?.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null
: null
{compareForSection !== null && (() => {
const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
const docId = dup?.duplicateOfDocumentId ?? null;
// Seiten so, wie das Backend den Abschnitt hochlädt (manuelle Splits zählen dort nicht)
const pages = dup?.seiten ?? documents[compareForSection]?.pages ?? [];
const kandidaten = dup?.duplikatKandidaten ?? [];
const pruefung: BelegnummerPruefung | null = kandidaten.length
? {
status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig',
documentId: dup?.versionMoeglich ? docId : null,
kandidaten,
}
inboxDocumentId={documentId}
: null;
return (
<DuplikatVergleichModal
open
belegnummer={dup?.belegnummer ?? ''}
pruefung={pruefung}
paperlessDocumentId={docId}
ladeNeuesPdf={() => inboxApi.downloadSegmentBlob(documentId, pages)}
ladeKennzahlen={() => inboxApi.vergleich(documentId, docId ?? 0, pages)}
onVersion={handleVersion}
onVerwerfen={handleVerwerfen}
onClose={() => setCompareForSection(null)}
onCreateNewVersion={() => {
// TODO: wird im nächsten Schritt implementiert
}}
onSkip={() => {
setCompareForSection(null);
handleSkip();
}}
/>
);
})()}
</Modal>
);
}