feat(posteingang): Duplikat als neue Version oder verwerfen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-02 13:58:31 +01:00
1 parent 170eb72c70
commit bdc85f5f1f
6 files changed
+166 -74

No files matched your search

@@ -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<RunForDocumentResult> {
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<string, string>,
defaultFilenameBase?: string,
replaceDuplicate: boolean = false,
entscheidung?: PaperlessEntscheidung,
): Promise<PaperlessRunResult> {
// 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
@@ -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]);
});
});
@@ -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<T extends { ActionType: string }>(
aktionen: T[],
entscheidung?: PaperlessEntscheidung,
): T[] {
if (!entscheidung) return aktionen;
const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS');
return start === -1 ? aktionen : aktionen.slice(start);
}
@@ -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 };
}