@@ -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 { } ;
}
}