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'; 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; // 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; } export interface RunForDocumentResult { results: ActionResult[]; totalSections: number; } interface PaperlessRunResult { skipped?: boolean; message?: string; duplicateOfDocumentId?: number; versionMoeglich?: boolean; duplikatKandidaten?: number[]; } @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, @InjectRepository(InboxDocument) private readonly docRepo: Repository, @InjectRepository(BarcodeTemplate) private readonly templateRepo: Repository, @InjectRepository(Task) private readonly taskRepo: Repository, ) {} async runForDocument( documentId: string, preferredUsername: string | null, sectionOffset: number = 0, processOnlyOne: boolean = false, entscheidung?: PaperlessEntscheidung, ): Promise { 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(); 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(); 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, }); 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, defaultFilenameBase?: string, ): Promise { 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, pdfPath: string, doc: InboxDocument, variables: Record, defaultFilenameBase?: string, ): Promise { 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, pdfPath: string, variables: Record, defaultFilenameBase?: string, ): Promise { 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, pdfPath: string, variables: Record, defaultFilenameBase?: string, entscheidung?: PaperlessEntscheidung, ): Promise { // 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. 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; 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; } // 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(', ')})`, ); 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, }; } // 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, }; } // 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 | null = content.customFields && typeof content.customFields === 'object' ? Object.fromEntries( Object.entries(content.customFields as Record).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 = {}; 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 {}; } }