feat(paperless): Belege als neue Version anhängen und mit Tag 21 markieren

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-02 13:57:02 +01:00
1 parent 8e0c609802
commit 170eb72c70
4 files changed
+208 -1

No files matched your search

@@ -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,
);
});
});
@@ -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<void> {
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<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) : [];
}
}
@@ -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 {}
@@ -146,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 ?? [];