Files
paperlessmanager/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts
T
bjoernpoettkerandClaude Opus 5.5 c1afa02ee2
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
fix: Duplikatprüfung immer gegen die ASN, die das Dokument beim Upload bekäme
Im Posteingang kann das ASN-Feld der Aktion von der Belegnummer abweichen.
Prüfung, Versions-Upload und normaler Upload nutzen jetzt dieselbe ASN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:27:21 +01:00

658 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import { InboxPostprocessingAction } from '../database/entities/inbox-postprocessing-action.entity';
import { InboxDocument } from '../database/entities/inbox-document.entity';
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
import { Task } from '../database/entities/task.entity';
import { PageCacheService } from '../barcode/page-cache.service';
import { PaperlessService } from '../paperless/paperless.service';
import { MailService } from '../postprocessing/mail.service';
import { ExportService } from '../postprocessing/export.service';
import {
applyEditsToTemp,
cleanupTemp,
extractSectionToTemp,
} from './edit-applier';
import { applyTemplate, buildVariables } from './variable-resolver';
import { DokumentVersionService } from '../paperless/dokument-version.service';
import {
aktionenAbEntscheidung,
ermittleUploadAsn,
originalSeitenDesAbschnitts,
type PaperlessEntscheidung,
} from './paperless-entscheidung.util';
function parseFlexDate(s: string): Date | null {
if (!s) return null;
// ISO: YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
// German: DD.MM.YYYY
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) {
const d = new Date(
`${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`,
);
return isNaN(d.getTime()) ? null : d;
}
return null;
}
export interface ActionResult {
sectionIndex: number;
actionId: number;
actionType: string;
ok: boolean;
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;
/** Aufgelöste interne Belegnummer (bei Duplikaten) */
belegnummer?: string;
/** Original-Seiten des Abschnitts (bei Duplikaten, für den Vergleich) */
seiten?: number[];
}
export interface RunForDocumentResult {
results: ActionResult[];
totalSections: number;
}
interface PaperlessRunResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
belegnummer?: string;
}
@Injectable()
export class InboxPostprocessorService {
private readonly logger = new Logger(InboxPostprocessorService.name);
constructor(
private readonly pageCache: PageCacheService,
private readonly paperlessService: PaperlessService,
private readonly dokumentVersion: DokumentVersionService,
private readonly mailService: MailService,
private readonly exportService: ExportService,
@InjectRepository(InboxPostprocessingAction)
private readonly actionRepo: Repository<InboxPostprocessingAction>,
@InjectRepository(InboxDocument)
private readonly docRepo: Repository<InboxDocument>,
@InjectRepository(BarcodeTemplate)
private readonly templateRepo: Repository<BarcodeTemplate>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
) {}
async runForDocument(
documentId: string,
preferredUsername: string | null,
sectionOffset: number = 0,
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) {
throw new NotFoundException('Dokument nicht gefunden');
}
const sourcePdf = this.pageCache.documentPdfPath(doc.Id);
try {
await fs.access(sourcePdf);
} catch {
throw new NotFoundException('Dokument-PDF fehlt');
}
const templates = await this.templateRepo.find({ order: { Id: 'ASC' } });
const matchedTemplateIds = [
...new Set(
doc.QrCodes.map((qr) => {
const tpl = templates.find((t) => {
try {
return new RegExp(t.Regex).test(qr.value);
} catch {
return false;
}
});
return tpl?.Id ?? null;
}).filter((id): id is number => id !== null),
),
];
if (matchedTemplateIds.length === 0)
return { results: [], totalSections: 0 };
const actions = await this.actionRepo.find({
where: matchedTemplateIds.map((tid) => ({
BarcodeTemplateId: tid,
IsActive: true,
})),
order: { BarcodeTemplateId: 'ASC', Order: 'ASC', Id: 'ASC' },
});
if (actions.length === 0) {
return { results: [], totalSections: 0 };
}
// Aktionen nach Template gruppieren, damit jede Gruppe ihre eigenen Barcode-Variablen bekommt
const actionsByTemplate = new Map<number, InboxPostprocessingAction[]>();
for (const action of actions) {
const tid = action.BarcodeTemplateId!;
if (!actionsByTemplate.has(tid)) actionsByTemplate.set(tid, []);
actionsByTemplate.get(tid)!.push(action);
}
let processedPdfPath: string | null = null;
let abortProcessing = false;
const results: ActionResult[] = [];
try {
processedPdfPath = await applyEditsToTemp(doc, sourcePdf);
// Überlebende Seiten berechnen (1-basiert auf Original)
const deletedSet = new Set(doc.DeletedPages ?? []);
const survivingOriginalPages: number[] = [];
for (let p = 1; p <= doc.PageCount; p++) {
if (!deletedSet.has(p)) survivingOriginalPages.push(p);
}
// Mapping: Original-Seite → 0-basierter Index in processedPdf
const processedPageIndex = new Map<number, number>();
survivingOriginalPages.forEach((origPage, idx) =>
processedPageIndex.set(origPage, idx),
);
// QR-Codes mit ihrem Index in der verarbeiteten PDF (gelöschte QR-Seiten herausfiltern)
const qrsWithIdx = doc.QrCodes.filter((qr) =>
processedPageIndex.has(qr.page),
)
.map((qr) => ({
page: qr.page,
value: qr.value,
processedIdx: processedPageIndex.get(qr.page)!,
}))
.sort((a, b) => a.processedIdx - b.processedIdx);
// Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen
const splitPoints: number[] = [];
for (const qr of qrsWithIdx) {
const tplMatch = templates.find((t) => {
try {
return new RegExp(t.Regex).test(qr.value);
} catch {
return false;
}
});
if (tplMatch?.SplitBefore) splitPoints.push(qr.processedIdx);
}
const processedPageCount = survivingOriginalPages.length;
// Gesamtzahl aktiver Abschnitte vorab berechnen (für Stepper-UI im Frontend)
let totalSections = 0;
for (let i = 0; i < qrsWithIdx.length; i++) {
const qr = qrsWithIdx[i];
const tpl = templates.find((t) => {
try {
return new RegExp(t.Regex).test(qr.value);
} catch {
return false;
}
});
if (!tpl) continue;
if (i > 0 && !tpl.SplitBefore) continue;
totalSections++;
}
// Ersten Barcode immer verarbeiten (SplitBefore egal).
// Weitere Barcodes nur wenn SplitBefore=true → neues Dokument.
// sectionOffset: erste N aktive Abschnitte überspringen (für "Nächstes Dokument").
// processOnlyOne: nach dem ersten verarbeiteten Abschnitt abbrechen (für Wizard).
let activeSectionCount = 0;
let processedSection = false;
for (let i = 0; i < qrsWithIdx.length; i++) {
if (abortProcessing) break;
if (processedSection && processOnlyOne) break;
const qr = qrsWithIdx[i];
const tpl = templates.find((t) => {
try {
return new RegExp(t.Regex).test(qr.value);
} catch {
return false;
}
});
if (!tpl) continue;
if (i > 0 && !tpl.SplitBefore) continue;
if (activeSectionCount < sectionOffset) {
activeSectionCount++;
continue;
}
const currentSectionIndex = activeSectionCount;
activeSectionCount++;
const tplActions = actionsByTemplate.get(tpl.Id);
if (!tplActions || tplActions.length === 0) continue;
const variables = buildVariables({
doc,
template: tpl,
matchingQrValue: qr.value,
});
// Abschnitt aus der verarbeiteten PDF extrahieren
const startIdx = qr.processedIdx;
const nextSplitIdx = splitPoints.find((sp) => sp > startIdx);
const endIdx =
nextSplitIdx !== undefined
? nextSplitIdx - 1
: processedPageCount - 1;
const pageIndices = Array.from(
{ length: endIdx - startIdx + 1 },
(_, i) => startIdx + i,
);
const sectionPdfPath = await extractSectionToTemp(
processedPdfPath,
pageIndices,
);
const defaultFilenameBase = tpl.DateinameTemplate
? applyTemplate(tpl.DateinameTemplate, variables)
: undefined;
try {
for (const action of aktionenAbEntscheidung(
tplActions,
entscheidung,
)) {
if (abortProcessing) break;
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,
entscheidung,
);
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
skipped: res.skipped,
message: res.message,
duplicateOfDocumentId: res.duplicateOfDocumentId,
versionMoeglich: res.versionMoeglich,
duplikatKandidaten: res.duplikatKandidaten,
belegnummer: res.belegnummer,
seiten: res.skipped
? originalSeitenDesAbschnitts(
survivingOriginalPages,
pageIndices,
)
: undefined,
});
if (res.skipped) {
abortProcessing = true;
break;
}
} catch (err: any) {
this.logger.error(
`Aktion PAPERLESS#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
);
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: false,
message: err.message,
});
}
} else {
try {
await this.runAction(
action,
sectionPdfPath,
doc,
variables,
defaultFilenameBase,
);
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
});
} catch (err: any) {
this.logger.error(
`Aktion ${action.ActionType}#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
);
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: false,
message: err.message,
});
}
}
}
} finally {
await cleanupTemp(sectionPdfPath);
}
processedSection = true;
}
return { results, totalSections };
} finally {
await cleanupTemp(processedPdfPath);
}
}
private async runAction(
action: InboxPostprocessingAction,
pdfPath: string,
doc: InboxDocument,
variables: Record<string, string>,
defaultFilenameBase?: string,
): Promise<void> {
const content = action.Content ?? {};
switch (action.ActionType) {
case 'MAIL':
return this.runMail(
content,
pdfPath,
doc,
variables,
defaultFilenameBase,
);
case 'EXPORT':
return this.runExport(content, pdfPath, variables, defaultFilenameBase);
default:
throw new Error(`Unbekannter ActionType: ${action.ActionType}`);
}
}
private async runMail(
content: Record<string, any>,
pdfPath: string,
doc: InboxDocument,
variables: Record<string, string>,
defaultFilenameBase?: string,
): Promise<void> {
const to = applyTemplate(String(content.to ?? ''), variables).trim();
if (!to) throw new Error('Empfänger fehlt');
const subject = applyTemplate(String(content.subject ?? ''), variables);
const body = applyTemplate(String(content.body ?? ''), variables);
const filenameTpl = String(content.filenameTemplate ?? '').trim();
const filename = filenameTpl
? `${applyTemplate(filenameTpl, variables)}.pdf`
: defaultFilenameBase
? `${defaultFilenameBase}.pdf`
: doc.OriginalName;
const buffer = await fs.readFile(pdfPath);
await this.mailService.sendMail({
to,
subject,
body,
attachments: [{ filename, content: buffer }],
});
}
private async runExport(
content: Record<string, any>,
pdfPath: string,
variables: Record<string, string>,
defaultFilenameBase?: string,
): Promise<void> {
const targetId = Number(content.exportTargetId);
if (!targetId) throw new Error('Export-Ziel fehlt');
const filenameTpl = String(content.filenameTemplate ?? '').trim();
const filename = filenameTpl
? `${applyTemplate(filenameTpl, variables)}.pdf`
: defaultFilenameBase
? `${defaultFilenameBase}.pdf`
: `${path.basename(pdfPath, '.pdf')}.pdf`;
const buffer = await fs.readFile(pdfPath);
await this.exportService.exportFile(targetId, filename, buffer);
}
private async runPaperless(
content: Record<string, any>,
pdfPath: string,
variables: Record<string, string>,
defaultFilenameBase?: string,
entscheidung?: PaperlessEntscheidung,
): Promise<PaperlessRunResult> {
// 1. Interne Belegnummer auflösen (Pflicht)
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
if (!intNrTpl)
throw new Error(
'Interne Belegnummer ist in der Aktion nicht konfiguriert',
);
const interneBelegnummer = applyTemplate(intNrTpl, variables).trim();
if (!interneBelegnummer)
throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
// 2. ASN, die das Dokument beim Upload bekäme – gegen genau diese wird geprüft
const asnTpl = String(content.asn ?? '').trim();
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
const archiveSerialNumber = ermittleUploadAsn(asn, interneBelegnummer);
// 3. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen
if (entscheidung?.aktion === 'version') {
await this.dokumentVersion.haengeVersionAn(
entscheidung.documentId,
pdfPath,
interneBelegnummer,
archiveSerialNumber ?? null,
);
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`,
};
}
// 4. Duplikat-Check Paperless: CF7 + Upload-ASN
const pruefung = await this.paperlessService.pruefeBelegnummer(
interneBelegnummer,
archiveSerialNumber ?? null,
);
if (pruefung.status !== 'frei') {
this.logger.warn(
`Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
);
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,
belegnummer: 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`,
);
return {
skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
versionMoeglich: false,
belegnummer: interneBelegnummer,
};
}
// 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
const titleTpl = String(content.title ?? '').trim();
const title = titleTpl
? applyTemplate(titleTpl, variables)
: defaultFilenameBase || undefined;
const tags = Array.isArray(content.tags)
? content.tags
.map((t: any) => Number(t))
.filter((n: number) => Number.isFinite(n))
: undefined;
const documentType = content.documentType
? Number(content.documentType)
: undefined;
const correspondent = content.correspondent
? Number(content.correspondent)
: undefined;
const owner =
content.owner !== undefined &&
content.owner !== null &&
content.owner !== ''
? Number(content.owner)
: undefined;
const rawCustomFields: Record<string, string> | null =
content.customFields && typeof content.customFields === 'object'
? Object.fromEntries(
Object.entries(content.customFields as Record<string, any>).map(
([k, v]) => [k, applyTemplate(String(v ?? ''), variables)],
),
)
: null;
// Eingangsdatum: Priorität 1 = dediziertes Feld, Priorität 2 = Custom Field 9, Fallback = heute
const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim();
let eingangsdatum: Date;
if (eingangsdatumTpl) {
eingangsdatum =
parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ??
new Date();
} else if (rawCustomFields?.['9']) {
const parsed = parseFlexDate(rawCustomFields['9']);
if (parsed) {
eingangsdatum = parsed;
delete rawCustomFields['9']; // Task-Processor schreibt Field 9 in korrektem ISO-Format
} else {
eingangsdatum = new Date();
}
} else {
eingangsdatum = new Date();
}
// Custom Fields für den Upload zusammenstellen:
// - CF7 = InterneBelegnummer (für Duplikat-Check via Paperless API)
// - CF9 = Eingangsdatum in ISO-Format
// - User-konfigurierte Felder aus rawCustomFields
const uploadCustomFields: Record<string, string> = {};
if (rawCustomFields) {
for (const [k, v] of Object.entries(rawCustomFields))
uploadCustomFields[k] = v;
}
uploadCustomFields['7'] = interneBelegnummer;
uploadCustomFields['9'] =
`${eingangsdatum.getFullYear()}-${String(eingangsdatum.getMonth() + 1).padStart(2, '0')}-${String(eingangsdatum.getDate()).padStart(2, '0')}`;
// 6. Upload
const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, {
title,
filename: defaultFilenameBase || undefined,
documentType,
correspondent,
owner,
tags,
archiveSerialNumber,
customFields: uploadCustomFields,
});
const taskId = String(taskIdRaw).replace(/"/g, '');
// 7. Task anlegen
const task = this.taskRepo.create({
TaskId: taskId,
InterneBelegnummer: interneBelegnummer,
DocumentType: documentType ?? null,
Eingangsdatum: eingangsdatum,
Fertig: 0,
Tags: tags && tags.length > 0 ? tags.join(',') : null,
BetriebID: owner ?? null,
externeBelegnummer: null,
CustomFieldsJson:
rawCustomFields && Object.keys(rawCustomFields).length > 0
? JSON.stringify(rawCustomFields)
: null,
Asn: asn || null,
Lieferant: null,
EinkaufID: null,
Belegdatum: null,
TaskReferenceID: null,
BarcodeJson: null,
DuplikatZU: null,
});
await this.taskRepo.save(task);
this.logger.log(
`Dokument hochgeladen und Task angelegt: ${interneBelegnummer} → ${taskId}`,
);
return {};
}
}