feat: Duplikate im Import als neue Version behandeln #6
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).
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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 type { Response } from 'express';
|
||||||
import { EmailImportService } from './email-import.service';
|
import { EmailImportService } from './email-import.service';
|
||||||
import { EmailPageCacheService } from './email-page-cache.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 { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ export class EmailImportController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly importService: EmailImportService,
|
private readonly importService: EmailImportService,
|
||||||
private readonly pageCache: EmailPageCacheService,
|
private readonly pageCache: EmailPageCacheService,
|
||||||
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// --- Korrespondenten Mapping ---
|
// --- Korrespondenten Mapping ---
|
||||||
@@ -118,6 +124,51 @@ export class EmailImportController {
|
|||||||
return { isDuplicate };
|
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 ---
|
// --- Print Preview ---
|
||||||
@Post('attachments/:attachmentId/print-preview')
|
@Post('attachments/:attachmentId/print-preview')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConflictException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
@@ -14,6 +14,7 @@ import { PdfService } from '../preprocessing/pdf.service';
|
|||||||
import { EmailPageCacheService } from './email-page-cache.service';
|
import { EmailPageCacheService } from './email-page-cache.service';
|
||||||
import { ImapFolderService } from './imap-folder.service';
|
import { ImapFolderService } from './imap-folder.service';
|
||||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||||
|
import { DokumentVersionService } from '../paperless/dokument-version.service';
|
||||||
|
|
||||||
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
|
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
|
||||||
const doc = await PDFDocument.create();
|
const doc = await PDFDocument.create();
|
||||||
@@ -29,11 +30,19 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
|||||||
andWhere: jest.Mock;
|
andWhere: jest.Mock;
|
||||||
getCount: 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 contentRepo: { findOne: jest.Mock };
|
||||||
let paperlessService: {
|
let paperlessService: {
|
||||||
checksumExists: jest.Mock;
|
checksumExists: jest.Mock;
|
||||||
uploadDocument: jest.Mock;
|
uploadDocument: jest.Mock;
|
||||||
|
validateAsnNotExists: jest.Mock;
|
||||||
};
|
};
|
||||||
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
|
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
|
||||||
|
|
||||||
@@ -50,11 +59,25 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
|||||||
Checksum: 'abc123',
|
Checksum: 'abc123',
|
||||||
FileName: 'rechnung.pdf',
|
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() };
|
contentRepo = { findOne: jest.fn() };
|
||||||
paperlessService = {
|
paperlessService = {
|
||||||
checksumExists: jest.fn().mockResolvedValue(false),
|
checksumExists: jest.fn().mockResolvedValue(false),
|
||||||
uploadDocument: jest.fn(),
|
uploadDocument: jest.fn(),
|
||||||
|
validateAsnNotExists: jest.fn(),
|
||||||
};
|
};
|
||||||
fingerprints = {
|
fingerprints = {
|
||||||
ensure: jest.fn().mockResolvedValue(null),
|
ensure: jest.fn().mockResolvedValue(null),
|
||||||
@@ -65,24 +88,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
EmailImportService,
|
EmailImportService,
|
||||||
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
||||||
{ provide: getRepositoryToken(Email), useValue: {} },
|
{ provide: getRepositoryToken(Email), useValue: emailRepo },
|
||||||
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
|
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
|
||||||
{ provide: getRepositoryToken(Content), useValue: contentRepo },
|
{ provide: getRepositoryToken(Content), useValue: contentRepo },
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(CorrespondentEmailMapping),
|
provide: getRepositoryToken(CorrespondentEmailMapping),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
{ provide: getRepositoryToken(Task), useValue: taskRepo },
|
||||||
provide: getRepositoryToken(Task),
|
|
||||||
useValue: {
|
|
||||||
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ provide: PaperlessService, useValue: paperlessService },
|
{ provide: PaperlessService, useValue: paperlessService },
|
||||||
{ provide: PdfService, useValue: {} },
|
{ provide: PdfService, useValue: {} },
|
||||||
{ provide: EmailPageCacheService, useValue: {} },
|
{ provide: EmailPageCacheService, useValue: {} },
|
||||||
{ provide: ImapFolderService, useValue: {} },
|
{ provide: ImapFolderService, useValue: {} },
|
||||||
{ provide: AttachmentFingerprintService, useValue: fingerprints },
|
{ provide: AttachmentFingerprintService, useValue: fingerprints },
|
||||||
|
{ provide: DokumentVersionService, useValue: dokumentVersion },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).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', () => {
|
describe('executeImport', () => {
|
||||||
const importDaten = (splitRanges?: { start: number; end: number }[]) => ({
|
const importDaten = (splitRanges?: { start: number; end: number }[]) => ({
|
||||||
emailDate: '2026-09-25',
|
emailDate: '2026-09-25',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -21,6 +22,7 @@ import * as QRCode from 'qrcode';
|
|||||||
import { EmailPageCacheService } from './email-page-cache.service';
|
import { EmailPageCacheService } from './email-page-cache.service';
|
||||||
import { ImapFolderService } from './imap-folder.service';
|
import { ImapFolderService } from './imap-folder.service';
|
||||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||||
|
import { DokumentVersionService } from '../paperless/dokument-version.service';
|
||||||
import { PdfService } from '../preprocessing/pdf.service';
|
import { PdfService } from '../preprocessing/pdf.service';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -73,6 +75,7 @@ export class EmailImportService {
|
|||||||
private readonly pageCache: EmailPageCacheService,
|
private readonly pageCache: EmailPageCacheService,
|
||||||
private readonly imapFolderService: ImapFolderService,
|
private readonly imapFolderService: ImapFolderService,
|
||||||
private readonly fingerprints: AttachmentFingerprintService,
|
private readonly fingerprints: AttachmentFingerprintService,
|
||||||
|
private readonly dokumentVersion: DokumentVersionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ensurePreviews(emailId: number): Promise<void> {
|
async ensurePreviews(emailId: number): Promise<void> {
|
||||||
@@ -348,9 +351,13 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Print Preview ---
|
// --- Print Preview ---
|
||||||
async generatePrintPdf(
|
/**
|
||||||
|
* PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999`
|
||||||
|
* = bis zum Ende) – ohne Stempel.
|
||||||
|
*/
|
||||||
|
async getAttachmentPdf(
|
||||||
attachmentId: number,
|
attachmentId: number,
|
||||||
barcodeData: any,
|
pages?: { start: number; end: number },
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const content = await this.contentRepo.findOne({
|
const content = await this.contentRepo.findOne({
|
||||||
where: { AttachmentEntityId: attachmentId },
|
where: { AttachmentEntityId: attachmentId },
|
||||||
@@ -358,28 +365,44 @@ export class EmailImportService {
|
|||||||
if (!content)
|
if (!content)
|
||||||
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
|
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 =
|
const pdfDoc = await PDFDocument.load(content.Content1, {
|
||||||
barcodeData._pages;
|
ignoreEncryption: true,
|
||||||
if (pages) {
|
});
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
const total = pdfDoc.getPageCount();
|
||||||
ignoreEncryption: true,
|
const ende = pages.end === 999 ? total : Math.min(pages.end, total);
|
||||||
});
|
if (
|
||||||
const total = pdfDoc.getPageCount();
|
!Number.isInteger(pages.start) ||
|
||||||
const startIdx = Math.max(1, pages.start) - 1;
|
!Number.isInteger(ende) ||
|
||||||
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
|
pages.start < 1 ||
|
||||||
const sliced = await PDFDocument.create();
|
pages.start > ende
|
||||||
const indices = Array.from(
|
) {
|
||||||
{ length: endIdx - startIdx + 1 },
|
throw new BadRequestException(
|
||||||
(_, i) => startIdx + i,
|
`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 { _pages, ...barcode } = barcodeData;
|
||||||
|
const pdfBytes = await this.getAttachmentPdf(
|
||||||
|
attachmentId,
|
||||||
|
_pages as { start: number; end: number } | undefined,
|
||||||
|
);
|
||||||
return this.applyBarcodeToPdf(pdfBytes, barcode);
|
return this.applyBarcodeToPdf(pdfBytes, barcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,6 +582,8 @@ export class EmailImportService {
|
|||||||
jahr: string;
|
jahr: string;
|
||||||
};
|
};
|
||||||
belegnummer?: string;
|
belegnummer?: string;
|
||||||
|
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
|
||||||
|
versionZuDokumentId?: number | null;
|
||||||
}[];
|
}[];
|
||||||
emailDate: string;
|
emailDate: string;
|
||||||
}): Promise<{ success: boolean; results: any[] }> {
|
}): 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: {
|
private async runImport(data: {
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
attachments: {
|
attachments: {
|
||||||
@@ -641,6 +691,8 @@ export class EmailImportService {
|
|||||||
jahr: string;
|
jahr: string;
|
||||||
};
|
};
|
||||||
belegnummer?: string;
|
belegnummer?: string;
|
||||||
|
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
|
||||||
|
versionZuDokumentId?: number | null;
|
||||||
}[];
|
}[];
|
||||||
emailDate: string;
|
emailDate: string;
|
||||||
}): Promise<{ success: boolean; results: any[] }> {
|
}): Promise<{ success: boolean; results: any[] }> {
|
||||||
@@ -668,7 +720,7 @@ export class EmailImportService {
|
|||||||
const baseFilename = sanitizeFilename(
|
const baseFilename = sanitizeFilename(
|
||||||
attachmentEntity.FileName.replace(/\.pdf$/i, ''),
|
attachmentEntity.FileName.replace(/\.pdf$/i, ''),
|
||||||
);
|
);
|
||||||
const paperlessIds: any = {};
|
const paperlessIds: Record<string, number> = {};
|
||||||
|
|
||||||
const uploadPromises = [];
|
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
|
// 0. Check if ASN already exists
|
||||||
if (att.belegnummer) {
|
if (att.belegnummer) {
|
||||||
await this.paperlessService.validateAsnNotExists(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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
@@ -18,6 +23,13 @@ import {
|
|||||||
extractSectionToTemp,
|
extractSectionToTemp,
|
||||||
} from './edit-applier';
|
} from './edit-applier';
|
||||||
import { applyTemplate, buildVariables } from './variable-resolver';
|
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 {
|
function parseFlexDate(s: string): Date | null {
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
@@ -45,6 +57,16 @@ export interface ActionResult {
|
|||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
duplicateOfDocumentId?: number;
|
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 {
|
export interface RunForDocumentResult {
|
||||||
@@ -56,6 +78,9 @@ interface PaperlessRunResult {
|
|||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
duplicateOfDocumentId?: number;
|
duplicateOfDocumentId?: number;
|
||||||
|
versionMoeglich?: boolean;
|
||||||
|
duplikatKandidaten?: number[];
|
||||||
|
belegnummer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -65,6 +90,7 @@ export class InboxPostprocessorService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly pageCache: PageCacheService,
|
private readonly pageCache: PageCacheService,
|
||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
|
private readonly dokumentVersion: DokumentVersionService,
|
||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
@InjectRepository(InboxPostprocessingAction)
|
@InjectRepository(InboxPostprocessingAction)
|
||||||
@@ -82,8 +108,14 @@ export class InboxPostprocessorService {
|
|||||||
preferredUsername: string | null,
|
preferredUsername: string | null,
|
||||||
sectionOffset: number = 0,
|
sectionOffset: number = 0,
|
||||||
processOnlyOne: boolean = false,
|
processOnlyOne: boolean = false,
|
||||||
replaceDuplicate: boolean = false,
|
entscheidung?: PaperlessEntscheidung,
|
||||||
): Promise<RunForDocumentResult> {
|
): 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 } });
|
const doc = await this.docRepo.findOne({ where: { Id: documentId } });
|
||||||
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
|
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
|
||||||
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
|
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
|
||||||
@@ -252,16 +284,31 @@ export class InboxPostprocessorService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const action of tplActions) {
|
for (const action of aktionenAbEntscheidung(
|
||||||
|
tplActions,
|
||||||
|
entscheidung,
|
||||||
|
)) {
|
||||||
if (abortProcessing) break;
|
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 {
|
try {
|
||||||
const res = await this.runPaperless(
|
const res = await this.runPaperless(
|
||||||
action.Content ?? {},
|
action.Content ?? {},
|
||||||
sectionPdfPath,
|
sectionPdfPath,
|
||||||
variables,
|
variables,
|
||||||
defaultFilenameBase,
|
defaultFilenameBase,
|
||||||
replaceDuplicate,
|
entscheidung,
|
||||||
);
|
);
|
||||||
results.push({
|
results.push({
|
||||||
sectionIndex: currentSectionIndex,
|
sectionIndex: currentSectionIndex,
|
||||||
@@ -271,6 +318,15 @@ export class InboxPostprocessorService {
|
|||||||
skipped: res.skipped,
|
skipped: res.skipped,
|
||||||
message: res.message,
|
message: res.message,
|
||||||
duplicateOfDocumentId: res.duplicateOfDocumentId,
|
duplicateOfDocumentId: res.duplicateOfDocumentId,
|
||||||
|
versionMoeglich: res.versionMoeglich,
|
||||||
|
duplikatKandidaten: res.duplikatKandidaten,
|
||||||
|
belegnummer: res.belegnummer,
|
||||||
|
seiten: res.skipped
|
||||||
|
? originalSeitenDesAbschnitts(
|
||||||
|
survivingOriginalPages,
|
||||||
|
pageIndices,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (res.skipped) {
|
if (res.skipped) {
|
||||||
abortProcessing = true;
|
abortProcessing = true;
|
||||||
@@ -404,7 +460,7 @@ export class InboxPostprocessorService {
|
|||||||
pdfPath: string,
|
pdfPath: string,
|
||||||
variables: Record<string, string>,
|
variables: Record<string, string>,
|
||||||
defaultFilenameBase?: string,
|
defaultFilenameBase?: string,
|
||||||
replaceDuplicate: boolean = false,
|
entscheidung?: PaperlessEntscheidung,
|
||||||
): Promise<PaperlessRunResult> {
|
): Promise<PaperlessRunResult> {
|
||||||
// 1. Interne Belegnummer auflösen (Pflicht)
|
// 1. Interne Belegnummer auflösen (Pflicht)
|
||||||
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
|
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
|
||||||
@@ -416,84 +472,77 @@ export class InboxPostprocessorService {
|
|||||||
if (!interneBelegnummer)
|
if (!interneBelegnummer)
|
||||||
throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
|
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 asnTpl = String(content.asn ?? '').trim();
|
||||||
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
|
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
|
||||||
let archiveSerialNumber: number | undefined;
|
const archiveSerialNumber = ermittleUploadAsn(asn, interneBelegnummer);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replaceDuplicate) {
|
// 3. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen
|
||||||
this.logger.log(
|
if (entscheidung?.aktion === 'version') {
|
||||||
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`,
|
await this.dokumentVersion.haengeVersionAn(
|
||||||
);
|
entscheidung.documentId,
|
||||||
} else {
|
pdfPath,
|
||||||
// 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,
|
|
||||||
interneBelegnummer,
|
interneBelegnummer,
|
||||||
|
archiveSerialNumber ?? null,
|
||||||
);
|
);
|
||||||
if (cf7DocId !== null) {
|
this.logger.log(
|
||||||
this.logger.warn(
|
`Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
|
||||||
`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`,
|
);
|
||||||
);
|
return {
|
||||||
return {
|
message: `Als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
|
||||||
skipped: true,
|
};
|
||||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
|
}
|
||||||
duplicateOfDocumentId: cf7DocId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Duplikat-Check Paperless API (archive_serial_number)
|
// 4. Duplikat-Check Paperless: CF7 + Upload-ASN
|
||||||
if (archiveSerialNumber !== undefined) {
|
const pruefung = await this.paperlessService.pruefeBelegnummer(
|
||||||
const asnDocId =
|
interneBelegnummer,
|
||||||
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
|
archiveSerialNumber ?? null,
|
||||||
if (asnDocId !== null) {
|
);
|
||||||
this.logger.warn(
|
if (pruefung.status !== 'frei') {
|
||||||
`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`,
|
this.logger.warn(
|
||||||
);
|
`Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
|
||||||
return {
|
);
|
||||||
skipped: true,
|
return {
|
||||||
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
|
skipped: true,
|
||||||
duplicateOfDocumentId: asnDocId,
|
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
|
// 5. Duplikat-Check lokal (tasks-Tabelle)
|
||||||
const buffer = await fs.readFile(pdfPath);
|
const existingTask = await this.taskRepo.findOneBy({
|
||||||
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
|
InterneBelegnummer: interneBelegnummer,
|
||||||
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
});
|
||||||
const checksumExists =
|
if (existingTask) {
|
||||||
await this.paperlessService.checksumExists(checksum);
|
this.logger.warn(
|
||||||
if (checksumExists) {
|
`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`,
|
||||||
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
|
);
|
||||||
return {
|
return {
|
||||||
skipped: true,
|
skipped: true,
|
||||||
message: 'Duplikat (Checksum-Übereinstimmung)',
|
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
|
// 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 { createReadStream } from 'fs';
|
||||||
import { InboxService } from './inbox.service';
|
import { InboxService } from './inbox.service';
|
||||||
import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.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 { BarcodeScannerService } from '../barcode/barcode-scanner.service';
|
||||||
import { UserSettingsService } from '../user-settings/user-settings.service';
|
import { UserSettingsService } from '../user-settings/user-settings.service';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
import {
|
||||||
|
kennzahlenAusPdf,
|
||||||
|
type VergleichKennzahlen,
|
||||||
|
} from '../paperless/vergleich-kennzahlen.util';
|
||||||
|
|
||||||
@Controller('api/inbox')
|
@Controller('api/inbox')
|
||||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||||
@@ -30,6 +36,7 @@ export class InboxController {
|
|||||||
private readonly postprocessor: InboxPostprocessorService,
|
private readonly postprocessor: InboxPostprocessorService,
|
||||||
private readonly barcodeScanner: BarcodeScannerService,
|
private readonly barcodeScanner: BarcodeScannerService,
|
||||||
private readonly userSettingsService: UserSettingsService,
|
private readonly userSettingsService: UserSettingsService,
|
||||||
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -137,7 +144,7 @@ export class InboxController {
|
|||||||
body: {
|
body: {
|
||||||
sectionOffset?: number;
|
sectionOffset?: number;
|
||||||
processOnlyOne?: boolean;
|
processOnlyOne?: boolean;
|
||||||
replaceDuplicate?: boolean;
|
entscheidung?: PaperlessEntscheidung;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const preferredUsername: string | null =
|
const preferredUsername: string | null =
|
||||||
@@ -147,7 +154,7 @@ export class InboxController {
|
|||||||
preferredUsername,
|
preferredUsername,
|
||||||
body?.sectionOffset ?? 0,
|
body?.sectionOffset ?? 0,
|
||||||
body?.processOnlyOne ?? false,
|
body?.processOnlyOne ?? false,
|
||||||
body?.replaceDuplicate ?? false,
|
body?.entscheidung,
|
||||||
);
|
);
|
||||||
return { results, totalSections };
|
return { results, totalSections };
|
||||||
}
|
}
|
||||||
@@ -250,6 +257,29 @@ export class InboxController {
|
|||||||
return new StreamableFile(Readable.from(buffer));
|
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')
|
@Post(':id/send-email')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async sendEmail(
|
async sendEmail(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module';
|
|||||||
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
|
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
|
||||||
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
||||||
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||||
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
|
|||||||
InboxPostprocessorModule,
|
InboxPostprocessorModule,
|
||||||
PostprocessingModule,
|
PostprocessingModule,
|
||||||
UserSettingsModule,
|
UserSettingsModule,
|
||||||
|
PaperlessModule,
|
||||||
],
|
],
|
||||||
controllers: [InboxController, ClientsController],
|
controllers: [InboxController, ClientsController],
|
||||||
providers: [InboxService, InboxMigrationService],
|
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 };
|
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')
|
@Get('documents')
|
||||||
async getDocuments(
|
async getDocuments(
|
||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PaperlessService } from './paperless.service';
|
|||||||
import { PaperlessController } from './paperless.controller';
|
import { PaperlessController } from './paperless.controller';
|
||||||
import { PaperlessProcessorService } from './paperless-processor.service';
|
import { PaperlessProcessorService } from './paperless-processor.service';
|
||||||
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
|
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
|
||||||
|
import { DokumentVersionService } from './dokument-version.service';
|
||||||
import { DocumentType } from '../database/entities/document-type.entity';
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
import { DocumentField } from '../database/entities/document-field.entity';
|
import { DocumentField } from '../database/entities/document-field.entity';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
@@ -33,7 +34,12 @@ import { ErechnungModule } from '../erechnung/erechnung.module';
|
|||||||
PaperlessService,
|
PaperlessService,
|
||||||
PaperlessProcessorService,
|
PaperlessProcessorService,
|
||||||
PaperlessTaskProcessorService,
|
PaperlessTaskProcessorService,
|
||||||
|
DokumentVersionService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
PaperlessService,
|
||||||
|
PaperlessProcessorService,
|
||||||
|
DokumentVersionService,
|
||||||
],
|
],
|
||||||
exports: [PaperlessService, PaperlessProcessorService],
|
|
||||||
})
|
})
|
||||||
export class PaperlessModule {}
|
export class PaperlessModule {}
|
||||||
@@ -4,6 +4,20 @@ import axios, { type AxiosInstance } from 'axios';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import FormData = require('form-data');
|
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 {
|
export interface PaperlessTaskStatus {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -132,6 +146,34 @@ export class PaperlessService {
|
|||||||
return response.data;
|
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[]> {
|
async getUsers(): Promise<any[]> {
|
||||||
const response = await this.client.get('/users/');
|
const response = await this.client.get('/users/');
|
||||||
return response.data?.results ?? [];
|
return response.data?.results ?? [];
|
||||||
@@ -147,6 +189,18 @@ export class PaperlessService {
|
|||||||
return response.data;
|
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[]> {
|
async getInboxDocuments(): Promise<any[]> {
|
||||||
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
@@ -396,4 +450,72 @@ export class PaperlessService {
|
|||||||
);
|
);
|
||||||
return match ? Number(match.id) : null;
|
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 api from './client';
|
||||||
|
import type { VergleichKennzahlen } from './paperless';
|
||||||
|
|
||||||
export interface CorrespondentMapping {
|
export interface CorrespondentMapping {
|
||||||
Id: number;
|
Id: number;
|
||||||
@@ -19,6 +20,8 @@ export interface AttachmentImportData {
|
|||||||
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
|
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
|
||||||
belegnummer?: string;
|
belegnummer?: string;
|
||||||
isDuplicate?: boolean;
|
isDuplicate?: boolean;
|
||||||
|
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
|
||||||
|
versionZuDokumentId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailImportApi = {
|
export const emailImportApi = {
|
||||||
@@ -50,6 +53,23 @@ export const emailImportApi = {
|
|||||||
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
|
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> => {
|
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 }, {
|
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import api from './client';
|
import api from './client';
|
||||||
import type { BarcodeActionType } from './barcode-templates';
|
import type { BarcodeActionType } from './barcode-templates';
|
||||||
|
import type { VergleichKennzahlen } from './paperless';
|
||||||
|
|
||||||
export type InboxSource = 'all' | 'user';
|
export type InboxSource = 'all' | 'user';
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ export const inboxApi = {
|
|||||||
|
|
||||||
postprocess: (
|
postprocess: (
|
||||||
id: string,
|
id: string,
|
||||||
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
|
opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung },
|
||||||
) =>
|
) =>
|
||||||
api
|
api
|
||||||
.post<{ results: PostprocessActionResult[]; totalSections: number }>(
|
.post<{ results: PostprocessActionResult[]; totalSections: number }>(
|
||||||
@@ -107,6 +108,11 @@ export const inboxApi = {
|
|||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.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[]) =>
|
downloadSegmentBlob: (id: string, pages: number[]) =>
|
||||||
api
|
api
|
||||||
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
|
.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(() => {}),
|
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 {
|
export interface PostprocessActionResult {
|
||||||
sectionIndex: number;
|
sectionIndex: number;
|
||||||
actionId: number;
|
actionId: number;
|
||||||
@@ -134,6 +143,13 @@ export interface PostprocessActionResult {
|
|||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
duplicateOfDocumentId?: number;
|
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 = {
|
export const clientsApi = {
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import api from './client';
|
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 {
|
export interface PaperlessTag {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -62,4 +82,6 @@ export const paperlessApi = {
|
|||||||
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
|
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
|
||||||
getDocumentPdfBlob: (id: number) =>
|
getDocumentPdfBlob: (id: number) =>
|
||||||
api.get<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
|
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
|
||||||
import { emailsApi } from '../api/emails';
|
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 dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
|
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import PdfSplitViewer from './PdfSplitViewer';
|
import PdfSplitViewer from './PdfSplitViewer';
|
||||||
import BarcodePositioner from './BarcodePositioner';
|
import BarcodePositioner from './BarcodePositioner';
|
||||||
|
import { DuplikatVergleichModal } from './DuplikatVergleichModal';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
// Eingangsdatum per item
|
// Eingangsdatum per item
|
||||||
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
|
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
|
// Step 3 specific state
|
||||||
const [importSuccess, setImportSuccess] = useState(false);
|
const [importSuccess, setImportSuccess] = useState(false);
|
||||||
const [importStatus, setImportStatus] = useState('');
|
const [importStatus, setImportStatus] = useState('');
|
||||||
@@ -78,6 +84,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
});
|
});
|
||||||
setEingangsdaten(initialDates);
|
setEingangsdaten(initialDates);
|
||||||
setBarcodes(initialBarcodes);
|
setBarcodes(initialBarcodes);
|
||||||
|
setVersionEntscheidung({});
|
||||||
|
setVergleichQueue([]);
|
||||||
|
|
||||||
// Load correspondents and try to find suggestion
|
// Load correspondents and try to find suggestion
|
||||||
loadCorrespondents();
|
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 () => {
|
const nextStep = async () => {
|
||||||
if (currentStep === 0) {
|
if (currentStep === 0) {
|
||||||
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
|
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
|
||||||
@@ -372,6 +446,20 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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();
|
await preFetchBelegnummern();
|
||||||
}
|
}
|
||||||
setCurrentStep(currentStep + 1);
|
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 finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
|
||||||
|
const entscheidung = versionEntscheidung[item.virtualId];
|
||||||
|
|
||||||
finalData.push({
|
finalData.push({
|
||||||
...item,
|
...item,
|
||||||
|
versionZuDokumentId: entscheidung?.belegnummer === finalBelegnummer ? entscheidung.documentId : null,
|
||||||
splitRanges: item.pages ? [item.pages] : undefined,
|
splitRanges: item.pages ? [item.pages] : undefined,
|
||||||
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
|
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
|
||||||
belegnummer: finalBelegnummer,
|
belegnummer: finalBelegnummer,
|
||||||
@@ -765,6 +855,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
|
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
|
||||||
</Text>
|
</Text>
|
||||||
|
{versionEntscheidung[main.virtualId]?.belegnummer === belegnr && (
|
||||||
|
<Tag color="purple">
|
||||||
|
wird als neue Version zu Dokument {versionEntscheidung[main.virtualId].documentId} hinzugefügt
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
{mainAttachments.length > 0 && (
|
{mainAttachments.length > 0 && (
|
||||||
<div style={{ marginTop: 12, paddingLeft: 16 }}>
|
<div style={{ marginTop: 12, paddingLeft: 16 }}>
|
||||||
@@ -855,6 +950,26 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
{currentStep === 2 && renderStep3()}
|
{currentStep === 2 && renderStep3()}
|
||||||
</div>
|
</div>
|
||||||
</Spin>
|
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,9 @@ import {
|
|||||||
ZoomInOutlined,
|
ZoomInOutlined,
|
||||||
ZoomOutOutlined,
|
ZoomOutOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
|
import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
|
||||||
import { paperlessApi } from '../api/paperless';
|
import type { BelegnummerPruefung } from '../api/paperless';
|
||||||
|
import { DuplikatVergleichModal } from '../components/DuplikatVergleichModal';
|
||||||
import { userSettingsApi, type SenderOption } from '../api/userSettings';
|
import { userSettingsApi, type SenderOption } from '../api/userSettings';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
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';
|
type WizardStepStatus = 'pending' | 'processing' | 'success' | 'duplicate' | 'error' | 'skipped';
|
||||||
|
|
||||||
interface WizardStep {
|
interface WizardStep {
|
||||||
@@ -259,7 +156,7 @@ function PostprocessWizardModal({
|
|||||||
setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s)));
|
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;
|
if (!documentId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStepAt(idx, { status: 'processing', message: undefined });
|
setStepAt(idx, { status: 'processing', message: undefined });
|
||||||
@@ -267,13 +164,17 @@ function PostprocessWizardModal({
|
|||||||
const { results } = await inboxApi.postprocess(documentId, {
|
const { results } = await inboxApi.postprocess(documentId, {
|
||||||
sectionOffset: idx,
|
sectionOffset: idx,
|
||||||
processOnlyOne: true,
|
processOnlyOne: true,
|
||||||
replaceDuplicate,
|
entscheidung,
|
||||||
});
|
});
|
||||||
const skipped = results.find((r) => r.skipped);
|
const skipped = results.find((r) => r.skipped);
|
||||||
const failed = results.filter((r) => !r.ok);
|
const failed = results.filter((r) => !r.ok);
|
||||||
if (skipped) {
|
if (skipped) {
|
||||||
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
|
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
|
||||||
setAwaitingDecision(true);
|
setAwaitingDecision(true);
|
||||||
|
// Treffer in Paperless → Vergleich direkt öffnen
|
||||||
|
if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) {
|
||||||
|
setCompareForSection(idx);
|
||||||
|
}
|
||||||
} else if (failed.length > 0) {
|
} else if (failed.length > 0) {
|
||||||
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
|
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
|
||||||
setStepAt(idx, { status: 'error', message: msg, results });
|
setStepAt(idx, { status: 'error', message: msg, results });
|
||||||
@@ -281,6 +182,9 @@ function PostprocessWizardModal({
|
|||||||
} else if (results.length === 0) {
|
} else if (results.length === 0) {
|
||||||
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
|
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
|
||||||
setAwaitingDecision(true);
|
setAwaitingDecision(true);
|
||||||
|
} else if (entscheidung?.aktion === 'verwerfen') {
|
||||||
|
setStepAt(idx, { status: 'skipped', message: 'Verworfen', results });
|
||||||
|
setCurrentSection((s) => s + 1);
|
||||||
} else {
|
} else {
|
||||||
setStepAt(idx, { status: 'success', results });
|
setStepAt(idx, { status: 'success', results });
|
||||||
setCurrentSection((s) => s + 1);
|
setCurrentSection((s) => s + 1);
|
||||||
@@ -303,9 +207,19 @@ function PostprocessWizardModal({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
|
}, [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);
|
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 = () => {
|
const handleSkip = () => {
|
||||||
@@ -388,16 +302,13 @@ function PostprocessWizardModal({
|
|||||||
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
|
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
|
||||||
{idx === currentSection && awaitingDecision && (
|
{idx === currentSection && awaitingDecision && (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button type="primary" size="small" onClick={handleReplace}>
|
|
||||||
Neue Version erstellen
|
|
||||||
</Button>
|
|
||||||
{dupDocId !== null && (
|
{dupDocId !== null && (
|
||||||
<Button size="small" onClick={() => setCompareForSection(idx)}>
|
<Button type="primary" size="small" onClick={() => setCompareForSection(idx)}>
|
||||||
Vergleichen
|
Vergleichen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="small" onClick={handleSkip}>
|
<Button size="small" danger onClick={handleVerwerfen}>
|
||||||
Überspringen
|
Verwerfen
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
@@ -508,23 +419,33 @@ function PostprocessWizardModal({
|
|||||||
) : (
|
) : (
|
||||||
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
|
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
|
||||||
)}
|
)}
|
||||||
<CompareModal
|
{compareForSection !== null && (() => {
|
||||||
open={compareForSection !== null}
|
const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
|
||||||
paperlessDocumentId={
|
const docId = dup?.duplicateOfDocumentId ?? null;
|
||||||
compareForSection !== null
|
// Seiten so, wie das Backend den Abschnitt hochlädt (manuelle Splits zählen dort nicht)
|
||||||
? steps[compareForSection]?.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null
|
const pages = dup?.seiten ?? documents[compareForSection]?.pages ?? [];
|
||||||
: null
|
const kandidaten = dup?.duplikatKandidaten ?? [];
|
||||||
}
|
const pruefung: BelegnummerPruefung | null = kandidaten.length
|
||||||
inboxDocumentId={documentId}
|
? {
|
||||||
onClose={() => setCompareForSection(null)}
|
status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig',
|
||||||
onCreateNewVersion={() => {
|
documentId: dup?.versionMoeglich ? docId : null,
|
||||||
// TODO: wird im nächsten Schritt implementiert
|
kandidaten,
|
||||||
}}
|
}
|
||||||
onSkip={() => {
|
: null;
|
||||||
setCompareForSection(null);
|
return (
|
||||||
handleSkip();
|
<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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user