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
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:
commit
0406354b8b
28 files changed
+2230
-245
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.
|
||||
@@ -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,28 +365,44 @@ 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, {
|
||||
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 sliced = await PDFDocument.create();
|
||||
const indices = Array.from(
|
||||
{ length: endIdx - startIdx + 1 },
|
||||
(_, i) => startIdx + i,
|
||||
const pdfDoc = await PDFDocument.load(content.Content1, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
const total = pdfDoc.getPageCount();
|
||||
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 copied = await sliced.copyPages(pdfDoc, indices);
|
||||
copied.forEach((p) => sliced.addPage(p));
|
||||
pdfBytes = Buffer.from(await sliced.save());
|
||||
}
|
||||
const startIdx = pages.start - 1;
|
||||
const endIdx = ende - 1;
|
||||
const sliced = await PDFDocument.create();
|
||||
const indices = Array.from(
|
||||
{ length: endIdx - startIdx + 1 },
|
||||
(_, i) => startIdx + i,
|
||||
);
|
||||
const copied = await sliced.copyPages(pdfDoc, indices);
|
||||
copied.forEach((p) => sliced.addPage(p));
|
||||
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,84 +472,77 @@ 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);
|
||||
|
||||
if (replaceDuplicate) {
|
||||
this.logger.log(
|
||||
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`,
|
||||
);
|
||||
} else {
|
||||
// 3. Duplikat-Check lokal (tasks-Tabelle)
|
||||
const existingTask = await this.taskRepo.findOneBy({
|
||||
InterneBelegnummer: interneBelegnummer,
|
||||
});
|
||||
if (existingTask) {
|
||||
this.logger.warn(
|
||||
`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`,
|
||||
);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
||||
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
|
||||
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(
|
||||
7,
|
||||
// 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,
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
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`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
// 4. Duplikat-Check Paperless: CF7 + Upload-ASN
|
||||
const pruefung = await this.paperlessService.pruefeBelegnummer(
|
||||
interneBelegnummer,
|
||||
archiveSerialNumber ?? null,
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (checksumExists) {
|
||||
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
|
||||
return {
|
||||
skipped: true,
|
||||
message: 'Duplikat (Checksum-Übereinstimmung)',
|
||||
};
|
||||
}
|
||||
// 5. Duplikat-Check lokal (tasks-Tabelle)
|
||||
const existingTask = await this.taskRepo.findOneBy({
|
||||
InterneBelegnummer: interneBelegnummer,
|
||||
});
|
||||
if (existingTask) {
|
||||
this.logger.warn(
|
||||
`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`,
|
||||
);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
||||
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
|
||||
versionMoeglich: false,
|
||||
belegnummer: interneBelegnummer,
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (checksumExists) {
|
||||
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
|
||||
return {
|
||||
skipped: true,
|
||||
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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
inboxDocumentId={documentId}
|
||||
onClose={() => setCompareForSection(null)}
|
||||
onCreateNewVersion={() => {
|
||||
// TODO: wird im nächsten Schritt implementiert
|
||||
}}
|
||||
onSkip={() => {
|
||||
setCompareForSection(null);
|
||||
handleSkip();
|
||||
}}
|
||||
/>
|
||||
{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,
|
||||
}
|
||||
: 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)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user