diff --git a/paperless-backend/src/paperless/dokument-version.service.spec.ts b/paperless-backend/src/paperless/dokument-version.service.spec.ts new file mode 100644 index 0000000..905f2d7 --- /dev/null +++ b/paperless-backend/src/paperless/dokument-version.service.spec.ts @@ -0,0 +1,99 @@ +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; + }; + let service: DokumentVersionService; + + beforeEach(() => { + paperless = { + getDocument: jest.fn(), + updateDocument: jest.fn().mockResolvedValue({}), + uploadDocumentVersion: jest.fn().mockResolvedValue('task-1'), + }; + service = new DokumentVersionService( + paperless as unknown as PaperlessService, + ); + }); + + 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'); + + 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'); + + 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')).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')).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')).rejects.toBe( + fehler, + ); + }); +}); diff --git a/paperless-backend/src/paperless/dokument-version.service.ts b/paperless-backend/src/paperless/dokument-version.service.ts new file mode 100644 index 0000000..d95b4c7 --- /dev/null +++ b/paperless-backend/src/paperless/dokument-version.service.ts @@ -0,0 +1,74 @@ +import { 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'); + +/** 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. Erst wird Tag 21 + * gesetzt, dann die Version hochgeladen; scheitert der Upload, wird ein + * hier neu gesetzter Tag 21 wieder entfernt. + */ + async haengeVersionAn( + rootDocumentId: number, + pdfPath: string, + ): Promise { + const tagNeuGesetzt = await this.setzeTag(rootDocumentId); + try { + await this.paperlessService.uploadDocumentVersion( + rootDocumentId, + pdfPath, + versionLabel(new Date()), + ); + this.logger.log( + `Neue Version für Dokument ${rootDocumentId} hochgeladen.`, + ); + } catch (err) { + if (tagNeuGesetzt) await this.entferneTag(rootDocumentId); + throw err; + } + } + + /** Setzt Tag 21; liefert true, wenn er vorher fehlte. */ + private async setzeTag(documentId: number): Promise { + 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 { + 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 { + const doc = (await this.paperlessService.getDocument(documentId)) as { + tags?: number[]; + }; + return Array.isArray(doc?.tags) ? doc.tags.map(Number) : []; + } +} diff --git a/paperless-backend/src/paperless/paperless.module.ts b/paperless-backend/src/paperless/paperless.module.ts index 07ed9d3..3ab112a 100644 --- a/paperless-backend/src/paperless/paperless.module.ts +++ b/paperless-backend/src/paperless/paperless.module.ts @@ -4,6 +4,7 @@ import { PaperlessService } from './paperless.service'; import { PaperlessController } from './paperless.controller'; import { PaperlessProcessorService } from './paperless-processor.service'; import { PaperlessTaskProcessorService } from './paperless-task-processor.service'; +import { DokumentVersionService } from './dokument-version.service'; import { DocumentType } from '../database/entities/document-type.entity'; import { DocumentField } from '../database/entities/document-field.entity'; import { Task } from '../database/entities/task.entity'; @@ -33,7 +34,12 @@ import { ErechnungModule } from '../erechnung/erechnung.module'; PaperlessService, PaperlessProcessorService, PaperlessTaskProcessorService, + DokumentVersionService, + ], + exports: [ + PaperlessService, + PaperlessProcessorService, + DokumentVersionService, ], - exports: [PaperlessService, PaperlessProcessorService], }) export class PaperlessModule {} diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index 58521a1..90a856c 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -146,6 +146,34 @@ export class PaperlessService { return response.data; } + /** + * Lädt eine PDF als neue Version eines vorhandenen Dokuments hoch + * (`update_version`, Paperless-NGX API v10). Liefert die Consume-Task-ID. + */ + async uploadDocumentVersion( + documentId: number, + filePath: string, + versionLabel: string, + ): Promise { + 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( + `/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 { const response = await this.client.get('/users/'); return response.data?.results ?? [];