Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
@@ -0,0 +1,70 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { PDFDocument, degrees } from 'pdf-lib';
import type { InboxDocument } from '../database/entities/inbox-document.entity';
/**
* Wendet die virtuellen Edits (DeletedPages, Rotations) auf das Original-PDF an
* und schreibt das Ergebnis in eine temporäre Datei. Gibt den Pfad zurück.
* Aufrufer ist verantwortlich für das Aufräumen.
*/
export async function applyEditsToTemp(
doc: InboxDocument,
pdfPath: string,
): Promise<string> {
const bytes = await fs.readFile(pdfPath);
const pdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
const rotations = doc.Rotations ?? {};
for (const [pageStr, rot] of Object.entries(rotations)) {
const pageNum = Number(pageStr);
if (!Number.isInteger(pageNum)) continue;
const idx = pageNum - 1;
if (idx < 0 || idx >= pdf.getPageCount()) continue;
const normalized = ((Math.round(rot / 90) * 90) % 360 + 360) % 360;
if (normalized === 0) continue;
pdf.getPage(idx).setRotation(degrees(normalized));
}
// Seiten in absteigender Reihenfolge entfernen, damit Indizes stabil bleiben
const deleted = [...(doc.DeletedPages ?? [])].sort((a, b) => b - a);
for (const pageNum of deleted) {
const idx = pageNum - 1;
if (idx < 0 || idx >= pdf.getPageCount()) continue;
pdf.removePage(idx);
}
const out = await pdf.save();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-pp-'));
const tmpPath = path.join(tmpDir, 'document.pdf');
await fs.writeFile(tmpPath, out);
return tmpPath;
}
export async function cleanupTemp(filePath: string | null): Promise<void> {
if (!filePath) return;
try {
await fs.unlink(filePath);
await fs.rmdir(path.dirname(filePath)).catch(() => undefined);
} catch {
// ignore
}
}
export async function extractSectionToTemp(
pdfPath: string,
pageIndices: number[],
): Promise<string> {
const bytes = await fs.readFile(pdfPath);
const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
const outPdf = await PDFDocument.create();
const copied = await outPdf.copyPages(srcPdf, pageIndices);
copied.forEach(p => outPdf.addPage(p));
const out = await outPdf.save();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-section-'));
const tmpPath = path.join(tmpDir, 'section.pdf');
await fs.writeFile(tmpPath, out);
return tmpPath;
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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 { InboxPostprocessorService } from './inbox-postprocessor.service';
import { BarcodeModule } from '../barcode/barcode.module';
import { PaperlessModule } from '../paperless/paperless.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
@Module({
imports: [
TypeOrmModule.forFeature([InboxPostprocessingAction, InboxDocument, BarcodeTemplate, Task]),
BarcodeModule,
PaperlessModule,
PostprocessingModule,
],
providers: [InboxPostprocessorService],
exports: [InboxPostprocessorService],
})
export class InboxPostprocessorModule {}
@@ -0,0 +1,488 @@
import { 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';
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;
}
export interface RunForDocumentResult {
results: ActionResult[];
totalSections: number;
}
interface PaperlessRunResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
}
@Injectable()
export class InboxPostprocessorService {
private readonly logger = new Logger(InboxPostprocessorService.name);
constructor(
private readonly pageCache: PageCacheService,
private readonly paperlessService: PaperlessService,
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,
replaceDuplicate: boolean = false,
): Promise<RunForDocumentResult> {
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 tplActions) {
if (abortProcessing) break;
if (action.ActionType === 'PAPERLESS') {
try {
const res = await this.runPaperless(action.Content ?? {}, sectionPdfPath, variables, defaultFilenameBase, replaceDuplicate);
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
skipped: res.skipped,
message: res.message,
duplicateOfDocumentId: res.duplicateOfDocumentId,
});
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,
replaceDuplicate: boolean = false,
): 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 bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann)
const asnTpl = String(content.asn ?? '').trim();
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
let archiveSerialNumber: number | undefined;
if (asn) {
const n = parseInt(asn.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) archiveSerialNumber = n;
}
if (archiveSerialNumber === undefined) {
const n = parseInt(interneBelegnummer.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) archiveSerialNumber = n;
}
if (replaceDuplicate) {
this.logger.log(`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`);
} 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,
};
}
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(7, interneBelegnummer);
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,
};
}
// 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);
const checksum = crypto.createHash('md5').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 {};
}
}
@@ -0,0 +1,66 @@
import type { BarcodeTemplate } from '../database/entities/barcode-template.entity';
import type { InboxDocument } from '../database/entities/inbox-document.entity';
export interface ResolverContext {
doc: InboxDocument;
template: BarcodeTemplate;
matchingQrValue: string | null;
now?: Date;
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function dateVars(d: Date): Record<string, string> {
const yyyy = String(d.getFullYear());
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
return {
datum: `${yyyy}-${mm}-${dd}`,
jahr: yyyy,
monat: mm,
tag: dd,
zeitstempel: d.toISOString(),
};
}
function sanitizeKey(name: string): string {
return name.replace(/[^A-Za-z0-9_]/g, '_');
}
/**
* Berechnet alle Platzhalter für eine Aktion einer bestimmten Barcode-Vorlage:
* - Datums-Variablen: {datum}, {jahr}, {monat}, {tag}, {zeitstempel}
* - {barcode} = gesamter Barcode-Wert
* - {barcode.<gruppe>} = Named Capture Group aus der Vorlage-Regex
*/
export function buildVariables(ctx: ResolverContext): Record<string, string> {
const vars: Record<string, string> = {};
Object.assign(vars, dateVars(ctx.now ?? new Date()));
if (ctx.matchingQrValue !== null) {
vars['barcode'] = ctx.matchingQrValue;
try {
const m = ctx.matchingQrValue.match(new RegExp(ctx.template.Regex));
if (m?.groups) {
for (const [g, gv] of Object.entries(m.groups)) {
if (gv !== undefined) vars[`barcode.${sanitizeKey(g)}`] = gv;
}
}
} catch {}
}
return vars;
}
/**
* Ersetzt Platzhalter der Form `{name}` und `{name.group}` im Template.
* Unbekannte Platzhalter bleiben unverändert.
*/
export function applyTemplate(template: string, vars: Record<string, string>): string {
if (!template) return template;
return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (full, name: string) => {
return name in vars ? vars[name] : full;
});
}