fix: Zieldokument prüfen, Versions-Verarbeitung abwarten, Abschnittsseiten vom Backend

- 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 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-02 14:14:01 +01:00
1 parent 98021fdcbf
commit 32b5228f68
10 files changed
+283 -19

No files matched your search

@@ -237,6 +237,7 @@ describe('EmailImportService – Duplikatprüfung', () => {
expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledWith( expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledWith(
42, 42,
expect.stringMatching(/\.pdf$/), expect.stringMatching(/\.pdf$/),
'2026-000123',
); );
expect(paperlessService.uploadDocument).not.toHaveBeenCalled(); expect(paperlessService.uploadDocument).not.toHaveBeenCalled();
expect(paperlessService.validateAsnNotExists).not.toHaveBeenCalled(); expect(paperlessService.validateAsnNotExists).not.toHaveBeenCalled();
@@ -644,6 +644,7 @@ export class EmailImportService {
*/ */
private async importiereAlsVersion( private async importiereAlsVersion(
documentId: number, documentId: number,
belegnummer: string,
uploadItems: { path: string; filename: string; rangeKey: string }[], uploadItems: { path: string; filename: string; rangeKey: string }[],
paperlessIds: Record<string, number>, paperlessIds: Record<string, number>,
jobId?: string, jobId?: string,
@@ -653,7 +654,11 @@ export class EmailImportService {
jobId, jobId,
`Hänge ${item.filename} als neue Version an Dokument ${documentId}...`, `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; paperlessIds[item.rangeKey] = documentId;
} }
} }
@@ -765,6 +770,7 @@ export class EmailImportService {
if (att.versionZuDokumentId) { if (att.versionZuDokumentId) {
await this.importiereAlsVersion( await this.importiereAlsVersion(
att.versionZuDokumentId, att.versionZuDokumentId,
att.belegnummer ?? '',
uploadPromises, uploadPromises,
paperlessIds, paperlessIds,
data.jobId, data.jobId,
@@ -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]);
});
});
@@ -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';
@@ -21,6 +26,7 @@ import { applyTemplate, buildVariables } from './variable-resolver';
import { DokumentVersionService } from '../paperless/dokument-version.service'; import { DokumentVersionService } from '../paperless/dokument-version.service';
import { import {
aktionenAbEntscheidung, aktionenAbEntscheidung,
originalSeitenDesAbschnitts,
type PaperlessEntscheidung, type PaperlessEntscheidung,
} from './paperless-entscheidung.util'; } from './paperless-entscheidung.util';
@@ -58,6 +64,8 @@ export interface ActionResult {
verworfen?: boolean; verworfen?: boolean;
/** Aufgelöste interne Belegnummer (bei Duplikaten) */ /** Aufgelöste interne Belegnummer (bei Duplikaten) */
belegnummer?: string; belegnummer?: string;
/** Original-Seiten des Abschnitts (bei Duplikaten, für den Vergleich) */
seiten?: number[];
} }
export interface RunForDocumentResult { export interface RunForDocumentResult {
@@ -101,6 +109,12 @@ export class InboxPostprocessorService {
processOnlyOne: boolean = false, processOnlyOne: boolean = false,
entscheidung?: PaperlessEntscheidung, 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) {
@@ -306,6 +320,12 @@ export class InboxPostprocessorService {
versionMoeglich: res.versionMoeglich, versionMoeglich: res.versionMoeglich,
duplikatKandidaten: res.duplikatKandidaten, duplikatKandidaten: res.duplikatKandidaten,
belegnummer: res.belegnummer, belegnummer: res.belegnummer,
seiten: res.skipped
? originalSeitenDesAbschnitts(
survivingOriginalPages,
pageIndices,
)
: undefined,
}); });
if (res.skipped) { if (res.skipped) {
abortProcessing = true; abortProcessing = true;
@@ -456,6 +476,7 @@ export class InboxPostprocessorService {
await this.dokumentVersion.haengeVersionAn( await this.dokumentVersion.haengeVersionAn(
entscheidung.documentId, entscheidung.documentId,
pdfPath, pdfPath,
interneBelegnummer,
); );
this.logger.log( this.logger.log(
`Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`, `Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
@@ -16,3 +16,14 @@ export function aktionenAbEntscheidung<T extends { ActionType: string }>(
const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS'); const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS');
return start === -1 ? aktionen : aktionen.slice(start); 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]);
}
@@ -17,6 +17,8 @@ describe('DokumentVersionService.haengeVersionAn', () => {
getDocument: jest.Mock; getDocument: jest.Mock;
updateDocument: jest.Mock; updateDocument: jest.Mock;
uploadDocumentVersion: jest.Mock; uploadDocumentVersion: jest.Mock;
pruefeBelegnummer: jest.Mock;
getTask: jest.Mock;
}; };
let service: DokumentVersionService; let service: DokumentVersionService;
@@ -25,16 +27,68 @@ describe('DokumentVersionService.haengeVersionAn', () => {
getDocument: jest.fn(), getDocument: jest.fn(),
updateDocument: jest.fn().mockResolvedValue({}), updateDocument: jest.fn().mockResolvedValue({}),
uploadDocumentVersion: jest.fn().mockResolvedValue('task-1'), 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( service = new DokumentVersionService(
paperless as unknown as PaperlessService, 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('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 () => { it('setzt Tag 21 vor dem Hochladen der Version', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [3, 17] }); 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, { expect(paperless.updateDocument).toHaveBeenCalledWith(42, {
tags: [3, 17, 21], tags: [3, 17, 21],
@@ -52,7 +106,7 @@ describe('DokumentVersionService.haengeVersionAn', () => {
it('setzt Tag 21 nicht doppelt', async () => { it('setzt Tag 21 nicht doppelt', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [21, 5] }); 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.updateDocument).not.toHaveBeenCalled();
expect(paperless.uploadDocumentVersion).toHaveBeenCalled(); expect(paperless.uploadDocumentVersion).toHaveBeenCalled();
@@ -65,9 +119,9 @@ describe('DokumentVersionService.haengeVersionAn', () => {
.mockResolvedValueOnce({ id: 42, tags: [3, 21] }); .mockResolvedValueOnce({ id: 42, tags: [3, 21] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler); paperless.uploadDocumentVersion.mockRejectedValue(fehler);
await expect(service.haengeVersionAn(42, '/tmp/x.pdf')).rejects.toBe( await expect(
fehler, service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
); ).rejects.toBe(fehler);
expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, { expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, {
tags: [3], tags: [3],
}); });
@@ -78,9 +132,9 @@ describe('DokumentVersionService.haengeVersionAn', () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [21] }); paperless.getDocument.mockResolvedValue({ id: 42, tags: [21] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler); paperless.uploadDocumentVersion.mockRejectedValue(fehler);
await expect(service.haengeVersionAn(42, '/tmp/x.pdf')).rejects.toBe( await expect(
fehler, service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
); ).rejects.toBe(fehler);
expect(paperless.updateDocument).not.toHaveBeenCalled(); expect(paperless.updateDocument).not.toHaveBeenCalled();
}); });
@@ -92,8 +146,8 @@ describe('DokumentVersionService.haengeVersionAn', () => {
.mockResolvedValueOnce({}) .mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('Tag entfernen kaputt')); .mockRejectedValueOnce(new Error('Tag entfernen kaputt'));
await expect(service.haengeVersionAn(42, '/tmp/x.pdf')).rejects.toBe( await expect(
fehler, service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
); ).rejects.toBe(fehler);
}); });
}); });
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { PaperlessService } from './paperless.service'; import { PaperlessService } from './paperless.service';
/** Tag „neue Version hochgeladen“ – Postprocessing-Regeln ignorieren solche Belege. */ /** 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'); 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“. */ /** Bezeichnung der Version in Paperless, z. B. „Korrektur vom 02.11.2026“. */
export function versionLabel(datum: Date): string { export function versionLabel(datum: Date): string {
return `Korrektur vom ${pad(datum.getDate())}.${pad(datum.getMonth() + 1)}.${datum.getFullYear()}`; return `Korrektur vom ${pad(datum.getDate())}.${pad(datum.getMonth() + 1)}.${datum.getFullYear()}`;
@@ -18,21 +22,35 @@ export class DokumentVersionService {
constructor(private readonly paperlessService: PaperlessService) {} constructor(private readonly paperlessService: PaperlessService) {}
/** /**
* Hängt eine PDF als neue Version an ein Stammdokument. Erst wird Tag 21 * Hängt eine PDF als neue Version an ein Stammdokument. Vorher wird geprüft,
* gesetzt, dann die Version hochgeladen; scheitert der Upload, wird ein * dass die Belegnummer in Paperless eindeutig zu genau diesem Dokument
* hier neu gesetzter Tag 21 wieder entfernt. * 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( async haengeVersionAn(
rootDocumentId: number, rootDocumentId: number,
pdfPath: string, pdfPath: string,
belegnummer: string,
): Promise<void> { ): Promise<void> {
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); const tagNeuGesetzt = await this.setzeTag(rootDocumentId);
try { try {
await this.paperlessService.uploadDocumentVersion( const taskId = await this.paperlessService.uploadDocumentVersion(
rootDocumentId, rootDocumentId,
pdfPath, pdfPath,
versionLabel(new Date()), versionLabel(new Date()),
); );
await this.warteAufVerarbeitung(rootDocumentId, taskId);
this.logger.log( this.logger.log(
`Neue Version für Dokument ${rootDocumentId} hochgeladen.`, `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<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. */ /** Setzt Tag 21; liefert true, wenn er vorher fehlte. */
private async setzeTag(documentId: number): Promise<boolean> { private async setzeTag(documentId: number): Promise<boolean> {
const tags = await this.leseTags(documentId); const tags = await this.leseTags(documentId);
@@ -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<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],
});
});
});
+2
View File
@@ -148,6 +148,8 @@ export interface PostprocessActionResult {
duplikatKandidaten?: number[]; duplikatKandidaten?: number[];
verworfen?: boolean; verworfen?: boolean;
belegnummer?: string; belegnummer?: string;
/** Original-Seiten des Abschnitts laut Backend (bei Duplikaten) */
seiten?: number[];
} }
export const clientsApi = { export const clientsApi = {
@@ -422,7 +422,8 @@ function PostprocessWizardModal({
{compareForSection !== null && (() => { {compareForSection !== null && (() => {
const dup = steps[compareForSection]?.results?.find((r) => r.skipped); const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
const docId = dup?.duplicateOfDocumentId ?? null; 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 kandidaten = dup?.duplikatKandidaten ?? [];
const pruefung: BelegnummerPruefung | null = kandidaten.length const pruefung: BelegnummerPruefung | null = kandidaten.length
? { ? {