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:
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(
|
||||
42,
|
||||
expect.stringMatching(/\.pdf$/),
|
||||
'2026-000123',
|
||||
);
|
||||
expect(paperlessService.uploadDocument).not.toHaveBeenCalled();
|
||||
expect(paperlessService.validateAsnNotExists).not.toHaveBeenCalled();
|
||||
|
||||
@@ -644,6 +644,7 @@ export class EmailImportService {
|
||||
*/
|
||||
private async importiereAlsVersion(
|
||||
documentId: number,
|
||||
belegnummer: string,
|
||||
uploadItems: { path: string; filename: string; rangeKey: string }[],
|
||||
paperlessIds: Record<string, number>,
|
||||
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,
|
||||
|
||||
@@ -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 { 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<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 } });
|
||||
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`,
|
||||
|
||||
@@ -16,3 +16,14 @@ export function aktionenAbEntscheidung<T extends { ActionType: string }>(
|
||||
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]);
|
||||
}
|
||||
@@ -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<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 () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<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);
|
||||
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<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. */
|
||||
private async setzeTag(documentId: number): Promise<boolean> {
|
||||
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user