From 32b5228f68213caca71b5994a5ba8486a450d94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 2 Nov 2026 14:14:01 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20Zieldokument=20pr=C3=BCfen,=20Versions-V?= =?UTF-8?q?erarbeitung=20abwarten,=20Abschnittsseiten=20vom=20Backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Version nur, wenn die Belegnummer in Paperless eindeutig zu genau diesem Dokument gehört (serverseitig geprüft) - Auf die Paperless-Task der Version warten; Ablehnung → Fehler, Tag 21 weg - Entscheidung im Posteingang nur zusammen mit processOnlyOne - Vergleichsdialog im Posteingang zeigt die Seiten, die tatsächlich hochgeladen werden Co-Authored-By: Claude Opus 5.5 --- .../src/email/email-import.service.spec.ts | 1 + .../src/email/email-import.service.ts | 8 +- .../inbox-postprocessor.service.spec.ts | 27 ++++++ .../inbox-postprocessor.service.ts | 23 ++++- .../paperless-entscheidung.util.ts | 11 +++ .../dokument-version.service.spec.ts | 76 +++++++++++++--- .../src/paperless/dokument-version.service.ts | 63 +++++++++++-- .../paperless-belegnummer-suche.spec.ts | 88 +++++++++++++++++++ paperless-frontend/src/api/inbox.ts | 2 + .../src/pages/InboxDetailPage.tsx | 3 +- 10 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.spec.ts create mode 100644 paperless-backend/src/paperless/paperless-belegnummer-suche.spec.ts diff --git a/paperless-backend/src/email/email-import.service.spec.ts b/paperless-backend/src/email/email-import.service.spec.ts index 0da63cc..711a77e 100644 --- a/paperless-backend/src/email/email-import.service.spec.ts +++ b/paperless-backend/src/email/email-import.service.spec.ts @@ -237,6 +237,7 @@ describe('EmailImportService – Duplikatprüfung', () => { expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledWith( 42, expect.stringMatching(/\.pdf$/), + '2026-000123', ); expect(paperlessService.uploadDocument).not.toHaveBeenCalled(); expect(paperlessService.validateAsnNotExists).not.toHaveBeenCalled(); diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index a181bf5..20622a5 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -644,6 +644,7 @@ export class EmailImportService { */ private async importiereAlsVersion( documentId: number, + belegnummer: string, uploadItems: { path: string; filename: string; rangeKey: string }[], paperlessIds: Record, jobId?: string, @@ -653,7 +654,11 @@ export class EmailImportService { jobId, `Hänge ${item.filename} als neue Version an Dokument ${documentId}...`, ); - await this.dokumentVersion.haengeVersionAn(documentId, item.path); + await this.dokumentVersion.haengeVersionAn( + documentId, + item.path, + belegnummer, + ); paperlessIds[item.rangeKey] = documentId; } } @@ -765,6 +770,7 @@ export class EmailImportService { if (att.versionZuDokumentId) { await this.importiereAlsVersion( att.versionZuDokumentId, + att.belegnummer ?? '', uploadPromises, paperlessIds, data.jobId, diff --git a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.spec.ts b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.spec.ts new file mode 100644 index 0000000..bfb99b5 --- /dev/null +++ b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.spec.ts @@ -0,0 +1,27 @@ +import { BadRequestException } from '@nestjs/common'; +import { InboxPostprocessorService } from './inbox-postprocessor.service'; +import { 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]); + }); +}); diff --git a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts index 567bc90..6a1af6c 100644 --- a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts +++ b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs/promises'; @@ -21,6 +26,7 @@ import { applyTemplate, buildVariables } from './variable-resolver'; import { DokumentVersionService } from '../paperless/dokument-version.service'; import { aktionenAbEntscheidung, + originalSeitenDesAbschnitts, type PaperlessEntscheidung, } from './paperless-entscheidung.util'; @@ -58,6 +64,8 @@ export interface ActionResult { 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 { @@ -101,6 +109,12 @@ export class InboxPostprocessorService { processOnlyOne: boolean = false, entscheidung?: PaperlessEntscheidung, ): Promise { + // Eine Duplikat-Entscheidung gilt immer nur für den einen Abschnitt + if (entscheidung && !processOnlyOne) { + throw new BadRequestException( + 'Eine Duplikat-Entscheidung ist nur zusammen mit processOnlyOne erlaubt', + ); + } const doc = await this.docRepo.findOne({ where: { Id: documentId } }); if (!doc) throw new NotFoundException('Dokument nicht gefunden'); if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) { @@ -306,6 +320,12 @@ export class InboxPostprocessorService { versionMoeglich: res.versionMoeglich, duplikatKandidaten: res.duplikatKandidaten, belegnummer: res.belegnummer, + seiten: res.skipped + ? originalSeitenDesAbschnitts( + survivingOriginalPages, + pageIndices, + ) + : undefined, }); if (res.skipped) { abortProcessing = true; @@ -456,6 +476,7 @@ export class InboxPostprocessorService { await this.dokumentVersion.haengeVersionAn( entscheidung.documentId, pdfPath, + interneBelegnummer, ); this.logger.log( `Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`, diff --git a/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts b/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts index f42ac3b..8266b86 100644 --- a/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts +++ b/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts @@ -16,3 +16,14 @@ export function aktionenAbEntscheidung( 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]); +} diff --git a/paperless-backend/src/paperless/dokument-version.service.spec.ts b/paperless-backend/src/paperless/dokument-version.service.spec.ts index 905f2d7..20e2a52 100644 --- a/paperless-backend/src/paperless/dokument-version.service.spec.ts +++ b/paperless-backend/src/paperless/dokument-version.service.spec.ts @@ -17,6 +17,8 @@ describe('DokumentVersionService.haengeVersionAn', () => { getDocument: jest.Mock; updateDocument: jest.Mock; uploadDocumentVersion: jest.Mock; + pruefeBelegnummer: jest.Mock; + getTask: jest.Mock; }; let service: DokumentVersionService; @@ -25,16 +27,68 @@ describe('DokumentVersionService.haengeVersionAn', () => { 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 }, '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('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'); + await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'); expect(paperless.updateDocument).toHaveBeenCalledWith(42, { tags: [3, 17, 21], @@ -52,7 +106,7 @@ describe('DokumentVersionService.haengeVersionAn', () => { it('setzt Tag 21 nicht doppelt', async () => { paperless.getDocument.mockResolvedValue({ id: 42, tags: [21, 5] }); - await service.haengeVersionAn(42, '/tmp/x.pdf'); + await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'); expect(paperless.updateDocument).not.toHaveBeenCalled(); expect(paperless.uploadDocumentVersion).toHaveBeenCalled(); @@ -65,9 +119,9 @@ describe('DokumentVersionService.haengeVersionAn', () => { .mockResolvedValueOnce({ id: 42, tags: [3, 21] }); paperless.uploadDocumentVersion.mockRejectedValue(fehler); - await expect(service.haengeVersionAn(42, '/tmp/x.pdf')).rejects.toBe( - fehler, - ); + await expect( + service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'), + ).rejects.toBe(fehler); expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, { tags: [3], }); @@ -78,9 +132,9 @@ describe('DokumentVersionService.haengeVersionAn', () => { paperless.getDocument.mockResolvedValue({ id: 42, tags: [21] }); paperless.uploadDocumentVersion.mockRejectedValue(fehler); - await expect(service.haengeVersionAn(42, '/tmp/x.pdf')).rejects.toBe( - fehler, - ); + await expect( + service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'), + ).rejects.toBe(fehler); expect(paperless.updateDocument).not.toHaveBeenCalled(); }); @@ -92,8 +146,8 @@ describe('DokumentVersionService.haengeVersionAn', () => { .mockResolvedValueOnce({}) .mockRejectedValueOnce(new Error('Tag entfernen kaputt')); - await expect(service.haengeVersionAn(42, '/tmp/x.pdf')).rejects.toBe( - fehler, - ); + await expect( + service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'), + ).rejects.toBe(fehler); }); }); diff --git a/paperless-backend/src/paperless/dokument-version.service.ts b/paperless-backend/src/paperless/dokument-version.service.ts index d95b4c7..571836a 100644 --- a/paperless-backend/src/paperless/dokument-version.service.ts +++ b/paperless-backend/src/paperless/dokument-version.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { ConflictException, Injectable, Logger } from '@nestjs/common'; import { PaperlessService } from './paperless.service'; /** Tag „neue Version hochgeladen“ – Postprocessing-Regeln ignorieren solche Belege. */ @@ -6,6 +6,10 @@ 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()}`; @@ -18,21 +22,35 @@ export class DokumentVersionService { constructor(private readonly paperlessService: PaperlessService) {} /** - * Hängt eine PDF als neue Version an ein Stammdokument. Erst wird Tag 21 - * gesetzt, dann die Version hochgeladen; scheitert der Upload, wird ein - * hier neu gesetzter Tag 21 wieder entfernt. + * 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, ): Promise { + const pruefung = await this.paperlessService.pruefeBelegnummer(belegnummer); + 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 { - await this.paperlessService.uploadDocumentVersion( + 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.`, ); @@ -42,6 +60,41 @@ export class DokumentVersionService { } } + /** + * 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + /** Setzt Tag 21; liefert true, wenn er vorher fehlte. */ private async setzeTag(documentId: number): Promise { const tags = await this.leseTags(documentId); diff --git a/paperless-backend/src/paperless/paperless-belegnummer-suche.spec.ts b/paperless-backend/src/paperless/paperless-belegnummer-suche.spec.ts new file mode 100644 index 0000000..29983ce --- /dev/null +++ b/paperless-backend/src/paperless/paperless-belegnummer-suche.spec.ts @@ -0,0 +1,88 @@ +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 }) => + 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], + }); + }); +}); diff --git a/paperless-frontend/src/api/inbox.ts b/paperless-frontend/src/api/inbox.ts index 5502357..2170497 100644 --- a/paperless-frontend/src/api/inbox.ts +++ b/paperless-frontend/src/api/inbox.ts @@ -148,6 +148,8 @@ export interface PostprocessActionResult { duplikatKandidaten?: number[]; verworfen?: boolean; belegnummer?: string; + /** Original-Seiten des Abschnitts laut Backend (bei Duplikaten) */ + seiten?: number[]; } export const clientsApi = { diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index 1003d3f..90df875 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -422,7 +422,8 @@ function PostprocessWizardModal({ {compareForSection !== null && (() => { const dup = steps[compareForSection]?.results?.find((r) => r.skipped); const docId = dup?.duplicateOfDocumentId ?? null; - const pages = documents[compareForSection]?.pages ?? []; + // Seiten so, wie das Backend den Abschnitt hochlädt (manuelle Splits zählen dort nicht) + const pages = dup?.seiten ?? documents[compareForSection]?.pages ?? []; const kandidaten = dup?.duplikatKandidaten ?? []; const pruefung: BelegnummerPruefung | null = kandidaten.length ? {