From bdc85f5f1faeb77eae2f7990681f8f3c4c5613c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 2 Nov 2026 13:58:31 +0100 Subject: [PATCH] feat(posteingang): Duplikat als neue Version oder verwerfen Co-Authored-By: Claude Opus 5.5 --- .../inbox-postprocessor.service.ts | 160 ++++++++++-------- .../paperless-entscheidung.util.spec.ts | 38 +++++ .../paperless-entscheidung.util.ts | 18 ++ .../src/inbox/inbox.controller.ts | 5 +- paperless-frontend/src/api/inbox.ts | 9 +- .../src/pages/InboxDetailPage.tsx | 10 +- 6 files changed, 166 insertions(+), 74 deletions(-) create mode 100644 paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.spec.ts create mode 100644 paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts diff --git a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts index bf24937..c93bd88 100644 --- a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts +++ b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts @@ -18,6 +18,11 @@ import { extractSectionToTemp, } from './edit-applier'; import { applyTemplate, buildVariables } from './variable-resolver'; +import { DokumentVersionService } from '../paperless/dokument-version.service'; +import { + aktionenAbEntscheidung, + type PaperlessEntscheidung, +} from './paperless-entscheidung.util'; function parseFlexDate(s: string): Date | null { if (!s) return null; @@ -45,6 +50,12 @@ export interface ActionResult { skipped?: boolean; message?: string; duplicateOfDocumentId?: number; + /** Duplikat mit genau einem Stammdokument → neue Version möglich */ + versionMoeglich?: boolean; + /** Alle gefundenen Stammdokumente (bei mehrdeutigen Treffern) */ + duplikatKandidaten?: number[]; + /** Der Benutzer hat das Duplikat verworfen */ + verworfen?: boolean; } export interface RunForDocumentResult { @@ -56,6 +67,8 @@ interface PaperlessRunResult { skipped?: boolean; message?: string; duplicateOfDocumentId?: number; + versionMoeglich?: boolean; + duplikatKandidaten?: number[]; } @Injectable() @@ -65,6 +78,7 @@ export class InboxPostprocessorService { constructor( private readonly pageCache: PageCacheService, private readonly paperlessService: PaperlessService, + private readonly dokumentVersion: DokumentVersionService, private readonly mailService: MailService, private readonly exportService: ExportService, @InjectRepository(InboxPostprocessingAction) @@ -82,7 +96,7 @@ export class InboxPostprocessorService { preferredUsername: string | null, sectionOffset: number = 0, processOnlyOne: boolean = false, - replaceDuplicate: boolean = false, + entscheidung?: PaperlessEntscheidung, ): Promise { const doc = await this.docRepo.findOne({ where: { Id: documentId } }); if (!doc) throw new NotFoundException('Dokument nicht gefunden'); @@ -252,16 +266,31 @@ export class InboxPostprocessorService { : undefined; try { - for (const action of tplActions) { + for (const action of aktionenAbEntscheidung( + tplActions, + entscheidung, + )) { if (abortProcessing) break; - if (action.ActionType === 'PAPERLESS') { + if ( + action.ActionType === 'PAPERLESS' && + entscheidung?.aktion === 'verwerfen' + ) { + results.push({ + sectionIndex: currentSectionIndex, + actionId: action.Id, + actionType: action.ActionType, + ok: true, + verworfen: true, + message: 'Verworfen', + }); + } else if (action.ActionType === 'PAPERLESS') { try { const res = await this.runPaperless( action.Content ?? {}, sectionPdfPath, variables, defaultFilenameBase, - replaceDuplicate, + entscheidung, ); results.push({ sectionIndex: currentSectionIndex, @@ -271,6 +300,8 @@ export class InboxPostprocessorService { skipped: res.skipped, message: res.message, duplicateOfDocumentId: res.duplicateOfDocumentId, + versionMoeglich: res.versionMoeglich, + duplikatKandidaten: res.duplikatKandidaten, }); if (res.skipped) { abortProcessing = true; @@ -404,7 +435,7 @@ export class InboxPostprocessorService { pdfPath: string, variables: Record, defaultFilenameBase?: string, - replaceDuplicate: boolean = false, + entscheidung?: PaperlessEntscheidung, ): Promise { // 1. Interne Belegnummer auflösen (Pflicht) const intNrTpl = String(content.interneBelegnummer ?? '').trim(); @@ -416,7 +447,21 @@ export class InboxPostprocessorService { if (!interneBelegnummer) throw new Error('Interne Belegnummer konnte nicht aufgelöst werden'); - // 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann) + // 2. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen + if (entscheidung?.aktion === 'version') { + await this.dokumentVersion.haengeVersionAn( + entscheidung.documentId, + pdfPath, + ); + this.logger.log( + `Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`, + ); + return { + message: `Als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`, + }; + } + + // 3. ASN bestimmen (für den Upload) const asnTpl = String(content.asn ?? '').trim(); const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null; let archiveSerialNumber: number | undefined; @@ -429,71 +474,52 @@ export class InboxPostprocessorService { if (!Number.isNaN(n)) archiveSerialNumber = n; } - if (replaceDuplicate) { - this.logger.log( - `Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`, + // 4. Duplikat-Check Paperless: CF7 + aus der Belegnummer abgeleitete ASN + const pruefung = + await this.paperlessService.pruefeBelegnummer(interneBelegnummer); + if (pruefung.status !== 'frei') { + this.logger.warn( + `Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`, ); - } else { - // 3. Duplikat-Check lokal (tasks-Tabelle) - const existingTask = await this.taskRepo.findOneBy({ - InterneBelegnummer: interneBelegnummer, - }); - if (existingTask) { - this.logger.warn( - `Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`, - ); - return { - skipped: true, - message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`, - duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined, - }; - } + return { + skipped: true, + message: + pruefung.status === 'eindeutig' + ? `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless` + : `Duplikat – Belegnummer ${interneBelegnummer} mehrfach in Paperless`, + duplicateOfDocumentId: pruefung.documentId ?? pruefung.kandidaten[0], + versionMoeglich: pruefung.status === 'eindeutig', + duplikatKandidaten: pruefung.kandidaten, + }; + } - // 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer) - const cf7DocId = await this.paperlessService.findDocumentIdByCustomField( - 7, - interneBelegnummer, + // 5. Duplikat-Check lokal (tasks-Tabelle) + const existingTask = await this.taskRepo.findOneBy({ + InterneBelegnummer: interneBelegnummer, + }); + if (existingTask) { + this.logger.warn( + `Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`, ); - if (cf7DocId !== null) { - this.logger.warn( - `Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`, - ); - return { - skipped: true, - message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`, - duplicateOfDocumentId: cf7DocId, - }; - } + return { + skipped: true, + message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`, + duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined, + versionMoeglich: false, + }; + } - // 5. Duplikat-Check Paperless API (archive_serial_number) - if (archiveSerialNumber !== undefined) { - const asnDocId = - await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber); - if (asnDocId !== null) { - this.logger.warn( - `Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`, - ); - return { - skipped: true, - message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`, - duplicateOfDocumentId: asnDocId, - }; - } - } - - // 6. Checksum berechnen und prüfen - const buffer = await fs.readFile(pdfPath); - // Paperless ≥ 3.0 vergleicht SHA-256-Checksummen - const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); - const checksumExists = - await this.paperlessService.checksumExists(checksum); - if (checksumExists) { - this.logger.warn(`Duplikat (Checksum): ${checksum}`); - return { - skipped: true, - message: 'Duplikat (Checksum-Übereinstimmung)', - }; - } + // 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 diff --git a/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.spec.ts b/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.spec.ts new file mode 100644 index 0000000..2df44a9 --- /dev/null +++ b/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.spec.ts @@ -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]); + }); +}); diff --git a/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts b/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts new file mode 100644 index 0000000..f42ac3b --- /dev/null +++ b/paperless-backend/src/inbox-postprocessor/paperless-entscheidung.util.ts @@ -0,0 +1,18 @@ +/** 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( + aktionen: T[], + entscheidung?: PaperlessEntscheidung, +): T[] { + if (!entscheidung) return aktionen; + const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS'); + return start === -1 ? aktionen : aktionen.slice(start); +} diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index f3db1e1..453cc99 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -17,6 +17,7 @@ import type { Response } from 'express'; import { createReadStream } from 'fs'; import { InboxService } from './inbox.service'; import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service'; +import type { PaperlessEntscheidung } from '../inbox-postprocessor/paperless-entscheidung.util'; import { BarcodeScannerService } from '../barcode/barcode-scanner.service'; import { UserSettingsService } from '../user-settings/user-settings.service'; import { RequirePermissions } from '../auth/permissions.decorator'; @@ -143,7 +144,7 @@ export class InboxController { body: { sectionOffset?: number; processOnlyOne?: boolean; - replaceDuplicate?: boolean; + entscheidung?: PaperlessEntscheidung; }, ) { const preferredUsername: string | null = @@ -153,7 +154,7 @@ export class InboxController { preferredUsername, body?.sectionOffset ?? 0, body?.processOnlyOne ?? false, - body?.replaceDuplicate ?? false, + body?.entscheidung, ); return { results, totalSections }; } diff --git a/paperless-frontend/src/api/inbox.ts b/paperless-frontend/src/api/inbox.ts index 0d265e7..b6a3277 100644 --- a/paperless-frontend/src/api/inbox.ts +++ b/paperless-frontend/src/api/inbox.ts @@ -87,7 +87,7 @@ export const inboxApi = { postprocess: ( id: string, - opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean }, + opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung }, ) => api .post<{ results: PostprocessActionResult[]; totalSections: number }>( @@ -132,6 +132,9 @@ export const inboxApi = { api.post(`/api/inbox/${encodeURIComponent(id)}/send-email`, body).then(() => {}), }; +/** Entscheidung des Benutzers zu einem Duplikat bei „An Paperless senden“ */ +export type PaperlessEntscheidung = { aktion: 'version'; documentId: number } | { aktion: 'verwerfen' }; + export interface PostprocessActionResult { sectionIndex: number; actionId: number; @@ -140,6 +143,10 @@ export interface PostprocessActionResult { skipped?: boolean; message?: string; duplicateOfDocumentId?: number; + /** Duplikat mit genau einem Stammdokument → neue Version möglich */ + versionMoeglich?: boolean; + duplikatKandidaten?: number[]; + verworfen?: boolean; } export const clientsApi = { diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index 4ff8fca..3908da5 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -22,7 +22,7 @@ import { ZoomInOutlined, ZoomOutOutlined, } 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 { userSettingsApi, type SenderOption } from '../api/userSettings'; import { useIsMobile } from '../hooks/useIsMobile'; @@ -259,7 +259,7 @@ function PostprocessWizardModal({ setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s))); }; - const processSection = async (idx: number, replaceDuplicate = false) => { + const processSection = async (idx: number, entscheidung?: PaperlessEntscheidung) => { if (!documentId) return; setBusy(true); setStepAt(idx, { status: 'processing', message: undefined }); @@ -267,7 +267,7 @@ function PostprocessWizardModal({ const { results } = await inboxApi.postprocess(documentId, { sectionOffset: idx, processOnlyOne: true, - replaceDuplicate, + entscheidung, }); const skipped = results.find((r) => r.skipped); const failed = results.filter((r) => !r.ok); @@ -304,8 +304,10 @@ function PostprocessWizardModal({ }, [open, currentSection, awaitingDecision, finished, busy, sectionCount]); const handleReplace = () => { + const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId; + if (documentId === undefined) return; setAwaitingDecision(false); - void processSection(currentSection, true); + void processSection(currentSection, { aktion: 'version', documentId }); }; const handleSkip = () => {