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,14 @@
export class UploadExternalDto {
interneBelegnummer!: string;
dokumentType?: number;
Eingangsdatum?: string;
tag?: number;
betriebId?: number;
lieferant?: string;
einkaufId?: number;
externeBelegnummer?: string;
belegdatum?: string;
parentId?: string;
barcodeJson?: string;
duplikatZu?: number;
}
@@ -0,0 +1,204 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentField } from '../database/entities/document-field.entity';
import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from './paperless.service';
import { PostprocessingService } from '../postprocessing/postprocessing.service';
@Injectable()
export class PaperlessProcessorService {
private readonly logger = new Logger(PaperlessProcessorService.name);
constructor(
private readonly configService: ConfigService,
private readonly paperlessService: PaperlessService,
private readonly postprocessingService: PostprocessingService,
@InjectRepository(DocumentType) private readonly docTypeRepo: Repository<DocumentType>,
@InjectRepository(DocumentField) private readonly docFieldRepo: Repository<DocumentField>,
) {}
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
async processDocuments() {
try {
const response = await this.paperlessService.getDocuments({ tags__id__all: 16, page_size: 9999 });
const documents: any[] = Array.isArray(response) ? response : (response?.results ?? []);
if (documents.length === 0) return;
const customFields = await this.paperlessService.getCustomFields();
const validFieldIds = new Set(customFields.map((f: any) => f.id));
this.logger.log(`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`);
for (const doc of documents) {
try {
const updatedDoc = await this.processSingleDocument(doc, validFieldIds);
// Postprocessing nach dem Speichern evaluieren
await this.postprocessingService.evaluate(updatedDoc || doc);
} catch (innerErr: any) {
this.logger.error(`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`);
if (innerErr.response?.data) {
this.logger.error(`Paperless API Response: ${JSON.stringify(innerErr.response.data)}`);
}
}
}
} catch (err) {
this.logger.error('Fehler bei der Dokumentenverarbeitung:', err);
}
}
private async processSingleDocument(doc: any, validFieldIds: Set<number>): Promise<any> {
this.logger.log(`Verarbeite Dokument ID: ${doc.id}`);
if (!doc.document_type) {
this.logger.warn(`Dokument ${doc.id} hat keinen Dokumenten-Typen setze Tag 17.`);
const tagsSet = new Set<number>(doc.tags || []);
tagsSet.add(17);
if (!tagsSet.has(1)) {
tagsSet.add(6);
}
const updated = await this.paperlessService.updateDocument(doc.id, { tags: Array.from(tagsSet) });
return updated;
}
const docTypeConfig = await this.docTypeRepo.findOne({
where: { DocumentTypeId: doc.document_type },
});
if (!docTypeConfig) {
this.logger.warn(`Konfiguration für DocumentType ${doc.document_type} nicht in der Datenbank gefunden.`);
return null;
}
const fieldsConfig = await this.docFieldRepo.find({
where: { DocumentType: doc.document_type },
});
if (fieldsConfig.length === 0) {
this.logger.log(`Dokument ${doc.id} (Typ ${doc.document_type}) hat keine Dokument-Felder in der DB konfiguriert.`);
const newTagsNoFields = Array.from(new Set([...(doc.tags || []), 17]));
const updated = await this.paperlessService.updateDocument(doc.id, { tags: newTagsNoFields });
return updated;
}
const currentCustomFields = doc.custom_fields || [];
const newCustomFields = [...currentCustomFields];
let isAllRequiredFilled = true;
for (const fieldConf of fieldsConfig) {
if (fieldConf.Type === 4) {
const customFieldId = fieldConf.TypeIndex;
if (!customFieldId) continue;
if (!validFieldIds.has(customFieldId)) {
this.logger.warn(`Überspringe ungültiges Custom Field (TypeIndex: ${customFieldId}) für Dokument ${doc.id} - in Paperless nicht vorhanden.`);
continue;
}
const existingField = newCustomFields.find(f => f.field === customFieldId);
let isFilled = false;
if (existingField) {
isFilled = existingField.value !== null && existingField.value !== '';
} else {
newCustomFields.push({ field: customFieldId, value: null });
}
if (fieldConf.IsRequired && !isFilled) {
isAllRequiredFilled = false;
}
} else {
if (fieldConf.IsRequired) {
let isFilled = false;
switch (fieldConf.Type) {
case 1:
isFilled = doc.correspondent !== null && doc.correspondent !== undefined;
break;
case 2:
isFilled = !!doc.created || !!doc.created_date;
break;
case 3:
isFilled = doc.archive_serial_number !== null && doc.archive_serial_number !== undefined;
break;
case 5:
isFilled = !!doc.title;
break;
default:
isFilled = true;
}
if (!isFilled) {
isAllRequiredFilled = false;
}
}
}
}
const tagsSet = new Set<number>(doc.tags || []);
if (isAllRequiredFilled) {
if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady);
if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady);
} else {
if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady);
if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady);
}
tagsSet.add(17);
// Titel-Template auflösen
const updatePayload: any = {
custom_fields: newCustomFields,
tags: Array.from(tagsSet),
};
if (docTypeConfig.TitelTemplate) {
let title = docTypeConfig.TitelTemplate;
// Prüfen ob alle im Template referenzierten Custom Fields ausgefüllt sind
const placeholderRegex = /\{\{CUSTOM\[(\d+)\]\}\}/g;
let allFilled = true;
let match: RegExpExecArray | null;
while ((match = placeholderRegex.exec(title)) !== null) {
const fieldId = parseInt(match[1], 10);
const cf = newCustomFields.find(f => f.field === fieldId);
if (!cf || cf.value == null || cf.value === '') {
allFilled = false;
break;
}
}
if (allFilled) {
for (const cf of newCustomFields) {
const placeholder = `{{CUSTOM[${cf.field}]}}`;
if (title.includes(placeholder)) {
title = title.replaceAll(placeholder, cf.value != null ? String(cf.value) : '');
}
}
if (title.includes('{{DATE}}')) {
const created = doc.created ? new Date(doc.created) : new Date();
const dateStr = `${String(created.getDate()).padStart(2, '0')}.${String(created.getMonth() + 1).padStart(2, '0')}.${created.getFullYear()}`;
title = title.replaceAll('{{DATE}}', dateStr);
}
if (title.includes('{{ZEITSTEMPEL}}')) {
const now = new Date();
const ts = `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
title = title.replaceAll('{{ZEITSTEMPEL}}', ts);
}
updatePayload.title = title;
} else {
this.logger.log(`Dokument ${doc.id}: Titel-Template nicht angewendet nicht alle referenzierten Custom Fields ausgefüllt.`);
}
}
const updated = await this.paperlessService.updateDocument(doc.id, updatePayload);
this.logger.log(`Dokument ${doc.id} erfolgreich aktualisiert (Alle Pflichtfelder vorhanden: ${isAllRequiredFilled}).`);
return updated;
}
}
@@ -0,0 +1,351 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity';
import { Attachment } from '../database/entities/attachment.entity';
import { PaperlessService } from './paperless.service';
@Injectable()
export class PaperlessTaskProcessorService {
private readonly logger = new Logger(PaperlessTaskProcessorService.name);
constructor(
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(Document)
private readonly documentRepo: Repository<Document>,
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
private readonly paperlessService: PaperlessService,
) {}
@Cron(CronExpression.EVERY_30_SECONDS)
async handleCron() {
try {
// Fetch tasks that are not finished
const tasks = await this.taskRepo.find({
where: [
{ Fertig: IsNull() },
{ Fertig: 0 },
],
take: 10,
});
if (tasks.length === 0) {
return;
}
this.logger.log(`${tasks.length} Tasks noch zu bearbeiten`);
const toDelete: Task[] = [];
for (const t of tasks) {
let parentTask: Task | null = null;
if (t.TaskReferenceID) {
parentTask = await this.taskRepo.findOne({
where: { TaskId: t.TaskReferenceID },
});
if (!parentTask) {
this.logger.error(`ParentTask not found - Task ${t.TaskId}`);
continue;
}
if (!parentTask.Fertig || parentTask.Fertig === 0) {
this.logger.log(`ParentTask not imported - Task ${t.TaskId}`);
continue;
}
}
// Fetch task status from Paperless
const paperlessTasks = await this.paperlessService.getTask(t.TaskId);
const apiResponseTask = Array.isArray(paperlessTasks) ? paperlessTasks[0] : null;
if (apiResponseTask) {
if (apiResponseTask.status === 'SUCCESS') {
const dateDone = apiResponseTask.date_done ? new Date(apiResponseTask.date_done) : new Date();
const now = new Date();
// Add 10 seconds buffer as in C#
if (dateDone.getTime() + 10000 < now.getTime()) {
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
}
} else if (apiResponseTask.status === 'FAILURE') {
this.logger.warn(`Task ${t.TaskId} failed in Paperless`);
toDelete.push(t);
}
} else {
this.logger.warn(`Task ${t.TaskId} not found in Paperless`);
toDelete.push(t);
}
}
if (toDelete.length > 0) {
await this.taskRepo.remove(toDelete);
this.logger.log(`${toDelete.length} Tasks gelöscht`);
}
} catch (error) {
this.logger.error(`Fehler bei der Task-Verarbeitung: ${error.message}`, error.stack);
}
}
private async processSuccessfulTask(t: Task, apiTask: any, parentTask: Task | null) {
const documentId = apiTask.related_document;
this.logger.log(`[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`);
if (!documentId) {
this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`);
return;
}
try {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Lade Dokument ${documentId} aus Paperless`);
const document = await this.paperlessService.getDocument(documentId);
if (!document) {
this.logger.warn(`Dokument mit ID ${documentId} nicht in Paperless gefunden.`);
return;
}
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Dokument geladen: ID=${document.id}, Titel="${document.title}"`);
// Handle Duplicate Link
if (t.DuplikatZU) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`);
const duplikatDoc = await this.paperlessService.getDocument(t.DuplikatZU);
if (duplikatDoc) {
// Update duplikatDoc metadata (Field 8 is for linked documents)
let duplikatCustomFields = Array.isArray(duplikatDoc.custom_fields) ? [...duplikatDoc.custom_fields] : [];
// Remove field 4 as in C#
duplikatCustomFields = duplikatCustomFields.filter((f: any) => f.field !== 4);
const field8 = duplikatCustomFields.find((f: any) => f.field === 8);
if (field8) {
const values = Array.isArray(field8.value) ? field8.value : [];
if (!values.includes(document.id)) {
values.push(document.id);
field8.value = values;
}
} else {
duplikatCustomFields.push({ field: 8, value: [document.id] });
}
await this.paperlessService.updateDocument(duplikatDoc.id, {
archive_serial_number: null,
document_type: 11,
custom_fields: duplikatCustomFields,
});
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${duplikatDoc.id} aktualisiert`);
// Update current document as well
const currentCustomFields = Array.isArray(document.custom_fields) ? [...document.custom_fields] : [];
const currentField8 = currentCustomFields.find((f: any) => f.field === 8);
if (currentField8) {
const values = Array.isArray(currentField8.value) ? currentField8.value : [];
if (!values.includes(duplikatDoc.id)) {
values.push(duplikatDoc.id);
currentField8.value = values;
}
} else {
currentCustomFields.push({ field: 8, value: [duplikatDoc.id] });
}
document.custom_fields = currentCustomFields;
} else {
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${t.DuplikatZU} nicht gefunden`);
}
}
// Enrich Document
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Reichere Dokument-Metadaten an`);
const updateData: any = {
custom_fields: Array.isArray(document.custom_fields) ? [...document.custom_fields] : [],
};
// CustomFieldsJson als Basis zuerst anwenden dedizierte Felder weiter unten überschreiben diese
if (t.CustomFieldsJson) {
try {
const extra = JSON.parse(t.CustomFieldsJson) as Record<string, string>;
for (const [k, v] of Object.entries(extra)) {
const fieldId = parseInt(k, 10);
if (!Number.isFinite(fieldId)) continue;
const idx = updateData.custom_fields.findIndex((f: any) => f.field === fieldId);
if (idx !== -1) updateData.custom_fields[idx].value = v;
else updateData.custom_fields.push({ field: fieldId, value: v });
}
} catch { /* JSON-Parse-Fehler ignorieren */ }
}
if (t.Asn) {
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
if (!isNaN(asnNum)) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`);
updateData.archive_serial_number = asnNum;
}
}
if (t.InterneBelegnummer) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`);
if (!t.Asn) {
const asnFromBelegnummer = parseInt(t.InterneBelegnummer.replace(/-/g, ''), 10);
if (!isNaN(asnFromBelegnummer)) {
updateData.archive_serial_number = asnFromBelegnummer;
} else {
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`);
}
}
const existingField7 = updateData.custom_fields.find((f: any) => f.field === 7);
if (existingField7) {
existingField7.value = t.InterneBelegnummer;
} else {
updateData.custom_fields.push({ field: 7, value: t.InterneBelegnummer });
}
}
if (t.externeBelegnummer) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`);
const existingField3 = updateData.custom_fields.find((f: any) => f.field === 3);
if (existingField3) {
existingField3.value = t.externeBelegnummer;
} else {
updateData.custom_fields.push({ field: 3, value: t.externeBelegnummer });
}
}
if (t.Eingangsdatum) {
const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0];
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`);
const existingField9 = updateData.custom_fields.find((f: any) => f.field === 9);
if (existingField9) {
existingField9.value = dateValue;
} else {
updateData.custom_fields.push({ field: 9, value: dateValue });
}
}
if (t.DocumentType) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`);
updateData.document_type = t.DocumentType;
}
// Parent Task / Attachment logic
if (parentTask) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`);
const parentPaperlessTasks = await this.paperlessService.getTask(parentTask.TaskId);
const apiParentTask = Array.isArray(parentPaperlessTasks) ? parentPaperlessTasks[0] : null;
if (apiParentTask && apiParentTask.related_document) {
const parentDoc = await this.paperlessService.getDocument(apiParentTask.related_document);
if (parentDoc) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Elterndokument ${parentDoc.id} gefunden, setze Anlage-Typ`);
updateData.document_type = 5; // Anlage
updateData.title = `Anlage zu ${parentTask.InterneBelegnummer}`;
const field8 = updateData.custom_fields.find((f: any) => f.field === 8);
if (field8) {
const values = Array.isArray(field8.value) ? field8.value : [];
if (!values.includes(parentDoc.id)) {
values.push(parentDoc.id);
field8.value = values;
}
} else {
updateData.custom_fields.push({ field: 8, value: [parentDoc.id] });
}
} else {
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`);
}
} else {
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - ParentTask hat kein related_document`);
}
}
if (t.Belegdatum) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`);
updateData.created = t.Belegdatum.toISOString();
}
if (t.BetriebID) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`);
updateData.owner = t.BetriebID;
} else {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`);
updateData.owner = null;
}
// Tags
if (t.Tags) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`);
const tagIds = t.Tags.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id));
const currentTags = document.tags || [];
const newTags = Array.from(new Set([...currentTags, ...tagIds]));
updateData.tags = newTags;
}
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
if (t.EinkaufID) {
const link = `https://admin7.agrarmonitor.de/rechnungen/detail/${t.EinkaufID}`;
this.logger.log(`Skipping Agrarmonitor details for EinkaufID ${t.EinkaufID}. Link: ${link}`);
}
if (t.Lieferant) {
this.logger.log(`Skipping Correspondent lookup/creation for Lieferant ID ${t.Lieferant} (Agrarmonitor part deferred).`);
}
// Update Document in Paperless
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Aktualisiere Dokument ${document.id} in Paperless. Payload: ${JSON.stringify(updateData)}`);
await this.paperlessService.updateDocument(document.id, updateData);
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Dokument ${document.id} erfolgreich aktualisiert`);
// Add Notes
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Füge Notizen hinzu`);
await this.paperlessService.addNote(document.id, `Task bearbeitet: ${new Date().toLocaleString('de-DE')}`);
if (t.BarcodeJson) {
await this.paperlessService.addNote(document.id, t.BarcodeJson);
this.logger.log(`[Postprocessing] Task ${t.TaskId} - BarcodeJson-Notiz hinzugefügt`);
}
// Sync local Documents table
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`);
const metadata = await this.paperlessService.getDocumentMetadata(document.id);
let localDoc = await this.documentRepo.findOne({ where: { documentId: document.id } });
if (!localDoc) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Erstelle neuen lokalen Dokument-Eintrag`);
localDoc = this.documentRepo.create({
documentId: document.id,
checksum: metadata.original_checksum,
filename: metadata.original_filename,
});
await this.documentRepo.save(localDoc);
} else {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Aktualisiere bestehenden lokalen Dokument-Eintrag`);
localDoc.checksum = metadata.original_checksum;
localDoc.filename = metadata.original_filename;
await this.documentRepo.save(localDoc);
}
// Update Task status
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Task-Status auf Fertig`);
t.Fertig = 1;
t.PaperlessDocumentID = document.id;
await this.taskRepo.save(t);
// Update source attachment if linked
if (t.SourceAttachmentID && t.SourceAttachmentRange) {
const attachment = await this.attachmentRepo.findOne({ where: { Id: t.SourceAttachmentID } });
if (attachment) {
const ids = attachment.PaperlessDocumentIds || {};
ids[t.SourceAttachmentRange] = document.id;
attachment.PaperlessDocumentIds = ids;
await this.attachmentRepo.save(attachment);
this.logger.log(`[Postprocessing] Anhang ${attachment.Id} mit PaperlessID ${document.id} (${t.SourceAttachmentRange}) aktualisiert`);
}
}
this.logger.log(`[Postprocessing] Task ${t.TaskId} erfolgreich abgeschlossen. Dokument ID: ${document.id}`);
} catch (error) {
this.logger.error(`Fehler bei der Verarbeitung von Task ${t.TaskId}: ${error.message}`, error.stack);
}
}
}
@@ -0,0 +1,432 @@
import { Controller, Get, Param, Post, Put, Delete, UseGuards, UseInterceptors, UploadedFile, Body, Logger, HttpException, HttpStatus, Res, Query } from '@nestjs/common';
import type { Response } from 'express';
import { Public } from '../auth/public.decorator';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { FileInterceptor } from '@nestjs/platform-express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PaperlessService } from './paperless.service';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { UploadExternalDto } from './dto/upload-external.dto';
import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity';
import { DocumentField } from '../database/entities/document-field.entity';
import { DocumentType } from '../database/entities/document-type.entity';
@Controller('api/paperless')
export class PaperlessController {
private readonly logger = new Logger(PaperlessController.name);
constructor(
private readonly paperlessService: PaperlessService,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(Document)
private readonly documentRepo: Repository<Document>,
@InjectRepository(DocumentField)
private readonly documentFieldRepo: Repository<DocumentField>,
@InjectRepository(DocumentType)
private readonly documentTypeRepo: Repository<DocumentType>,
) {}
@Post('checksum')
async checksumExists(@Body('checksum') checksum: string) {
const exists = await this.paperlessService.checksumExists(checksum);
return { exists };
}
@Get('documents')
async getDocuments(
@Query('search') search?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const params: any = {
page: page || '1',
page_size: pageSize || '20',
};
if (search) {
params.query = search; // Global search in Paperless
}
return this.paperlessService.getDocuments(params);
}
@Get('documents/:id')
async getDocument(@Param('id') id: string) {
return this.paperlessService.getDocument(parseInt(id, 10));
}
@Get('tags')
async getTags() {
return this.paperlessService.getTags();
}
@Get('tags/:id')
async getTag(@Param('id') id: string) {
// If the service doesn't have getTag(id), I should add it or just fetch all and find
const tags = await this.paperlessService.getTags();
return tags.find(t => t.id === parseInt(id, 10));
}
@Get('document-types')
async getDocumentTypes() {
return this.paperlessService.getDocumentTypes();
}
@Get('custom-fields')
async getCustomFields() {
return this.paperlessService.getCustomFields();
}
@Get('users')
async getUsers() {
return this.paperlessService.getUsers();
}
@Get('correspondents')
async getCorrespondents(@Query('search') search?: string) {
const params: any = { page_size: 100 };
if (search) {
params.name__icontains = search;
}
const response = await this.paperlessService.getCorrespondents(params);
return response.results;
}
@Get('correspondents/:id')
async getCorrespondent(@Param('id') id: string) {
return this.paperlessService.getCorrespondent(parseInt(id, 10));
}
@Get('inbox/list')
@RequirePermissions(Permission.VIEW_INBOX)
async getInboxList() {
const documents = await this.paperlessService.getInboxDocuments();
// In old C# logic: only return docs where archive_serial_number is not null
return documents
.filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined)
.map((doc: any) => ({
id: doc.id,
title: doc.title,
asn: doc.archive_serial_number,
documentType: doc.document_type,
correspondent: doc.correspondent,
created: doc.created_date,
added: doc.added,
tags: doc.tags,
customFields: doc.custom_fields,
owner: doc.owner,
}));
}
@Get('manuell/list')
@RequirePermissions(Permission.PROCESS_MANUALLY)
async getManuellList() {
const documents = await this.paperlessService.getManuellDocuments();
return documents
.filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined)
.map((doc: any) => ({
id: doc.id,
title: doc.title,
asn: doc.archive_serial_number,
documentType: doc.document_type,
correspondent: doc.correspondent,
created: doc.created_date,
added: doc.added,
tags: doc.tags,
customFields: doc.custom_fields,
owner: doc.owner,
}));
}
@Public()
@Get('inbox/preview/:id')
async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
try {
const stream = await this.paperlessService.getDocumentPreviewStream(parseInt(id, 10));
res.set({
'Content-Type': 'image/png',
'Content-Disposition': `inline; filename="${id}preview.png"`,
});
stream.pipe(res);
} catch (error) {
if (!res.headersSent) {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Error fetching preview');
}
}
}
@Public()
@Get('inbox/pdf/:id')
async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
try {
const stream = await this.paperlessService.getDocumentPdfStream(parseInt(id, 10));
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${id}.pdf"`,
});
stream.pipe(res);
} catch (error) {
if (!res.headersSent) {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Error fetching PDF');
}
}
}
@Get('requirements/:id')
async getRequirements(@Param('id') id: string, @Query('Posteingang') posteingang: string) {
const documentTypeId = parseInt(id, 10);
const isPosteingang = posteingang === '1';
const requirements = await this.documentFieldRepo.find({
where: { DocumentType: documentTypeId },
});
// Custom fields fetching inside here could be slow, but this is the simplest translation of the old API
// Actually, getting all CFs doesn't take too long in Paperless API.
const customFields = await this.paperlessService.getCustomFields();
const retVal: any[] = [];
for (const req of requirements) {
if (isPosteingang && !req.VisiblePosteingang) {
continue;
}
const tmp: any = {
id: req.Id,
feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''),
hinweis: req.Hinweis || '',
required: isPosteingang ? req.IsRequiredPosteingang : req.IsRequired,
};
if (req.Type === 4) {
// Custom Field
const cfId = Number(req.TypeIndex);
const cf = customFields.find((c: any) => c.id === cfId);
tmp.isCustomField = true;
tmp.customFieldIndex = cfId;
if (cf) {
tmp.feldName = cf.name;
tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type;
if (cf.extra_data && cf.extra_data.select_options) {
tmp.fieldOptions = cf.extra_data.select_options
.filter((o: any) => o !== null)
.map((o: any) => ({ id: o.id, label: o.label }));
}
} else {
tmp.feldName = `Feld ${req.TypeIndex}`;
tmp.feldTyp = 'string';
}
} else if (req.Type === 1) {
tmp.feldName = 'Absender';
tmp.feldTyp = 'select';
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 });
const correspondents = response.results;
tmp.fieldOptions = correspondents.map((c: any) => ({ id: c.id.toString(), label: c.name }));
} else if (req.Type === 2) {
tmp.feldName = 'Belegdatum';
tmp.feldTyp = 'date';
} else if (req.Type === 3) {
tmp.feldName = 'Ablagenummer';
tmp.feldTyp = 'string';
} else if (req.Type === 5) {
tmp.feldName = 'Titel';
tmp.feldTyp = 'string';
} else {
tmp.feldName = tmp.feldId;
tmp.feldTyp = 'string';
}
retVal.push(tmp);
}
return retVal;
}
@Put('inbox/:id')
async putInboxDocument(@Param('id') id: string, @Body() body: any) {
const documentId = parseInt(id, 10);
// Fetch from paperless
const oldDocument = await this.paperlessService.getDocument(documentId);
oldDocument.document_type = body.documentType ?? oldDocument.document_type;
oldDocument.owner = body.mandant ?? oldDocument.owner;
oldDocument.correspondent = body.correspondent ?? oldDocument.correspondent;
oldDocument.title = body.title ?? oldDocument.title;
if (body.date) {
let docDate = new Date(body.date);
if (docDate.getHours() > 22) {
docDate = new Date(docDate.getTime() + 24 * 60 * 60 * 1000 - docDate.getHours() * 60 * 60 * 1000);
}
oldDocument.created_date = docDate.toISOString().split('T')[0];
}
const cfDefinitions = await this.paperlessService.getCustomFields();
// update custom fields
if (body.customFields) {
for (const [key, value] of Object.entries(body.customFields)) {
const fieldId = parseInt(key, 10);
const cfDef = cfDefinitions.find((c: any) => c.id === fieldId);
let processedValue = value;
if (cfDef?.data_type === 'documentlink' && value !== null && value !== '' && !Array.isArray(value)) {
processedValue = [value];
}
const existingFieldIndex = oldDocument.custom_fields.findIndex((f: any) => f.field === fieldId);
if (existingFieldIndex !== -1) {
if (processedValue === null || processedValue === '') {
oldDocument.custom_fields.splice(existingFieldIndex, 1);
} else {
oldDocument.custom_fields[existingFieldIndex].value = processedValue;
}
} else if (processedValue !== null && processedValue !== '') {
oldDocument.custom_fields.push({ field: fieldId, value: processedValue });
}
}
}
// Requirements check
const reqs = await this.documentFieldRepo.find({
where: { DocumentType: oldDocument.document_type },
});
let isReady = true;
let isReadyPosteingang = true;
for (const req of reqs) {
let isFieldValid = false;
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
if (req.Type === 3) isFieldValid = oldDocument.archive_serial_number !== null;
if (req.Type === 4) isFieldValid = !!oldDocument.custom_fields.find((cf: any) => cf.field === req.TypeIndex && cf.value !== null && cf.value !== '');
if (req.Type === 5) isFieldValid = oldDocument.title !== null && oldDocument.title !== '';
if (req.IsRequired && !isFieldValid) isReady = false;
if (req.IsRequiredPosteingang && !isFieldValid) isReadyPosteingang = false;
}
const docType = await this.documentTypeRepo.findOne({
where: { DocumentTypeId: oldDocument.document_type },
});
oldDocument.tags = oldDocument.tags || [];
if (isReady) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
if (docType?.TagNotReady) oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagNotReady);
if (docType?.TagReady && !oldDocument.tags.includes(docType.TagReady)) {
oldDocument.tags.push(docType.TagReady);
}
let titleTemplate = docType?.TitelTemplate || '';
if (titleTemplate) {
for (const cf of oldDocument.custom_fields) {
const placeholder = `{{CUSTOM[${cf.field}]}}`;
if (titleTemplate.includes(placeholder)) {
titleTemplate = titleTemplate.replace(placeholder, cf.value?.toString() ?? '');
}
}
titleTemplate = titleTemplate.replace('{{DATE}}', oldDocument.created_date);
oldDocument.title = titleTemplate;
}
} else {
if (docType?.TagNotReady) {
if (isReadyPosteingang) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
if (!oldDocument.tags.includes(docType.TagNotReady)) oldDocument.tags.push(docType.TagNotReady);
} else {
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
}
} else {
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
}
if (docType?.TagReady) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagReady);
}
}
await this.paperlessService.updateDocument(documentId, oldDocument);
return { success: true };
}
@Get('tasks')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getAllTasks() {
return this.taskRepo.find({ order: { Fertig: 'ASC' } });
}
@Delete('tasks/fertig')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async deleteFertigeTasks() {
const result = await this.taskRepo.delete({ Fertig: 1 });
return { deleted: result.affected ?? 0 };
}
@Delete('tasks/:taskId')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async deleteTask(@Param('taskId') taskId: string) {
const result = await this.taskRepo.delete({ TaskId: taskId });
if (!result.affected) {
throw new HttpException('Task nicht gefunden', HttpStatus.NOT_FOUND);
}
return { deleted: result.affected };
}
@Post('external-upload')
@UseGuards(ApiKeyGuard)
@UseInterceptors(FileInterceptor('document'))
async externalUpload(
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadExternalDto,
) {
this.logger.log(`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`);
try {
// 0. Check if ASN already exists
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
// 1. Forward to Paperless
const paperlessTaskId = await this.paperlessService.uploadDocument(file.path, {
title: `Beleg ${dto.interneBelegnummer}`,
});
// 2. Create local Task
const task = this.taskRepo.create({
TaskId: paperlessTaskId.replace(/"/g, ''),
InterneBelegnummer: dto.interneBelegnummer,
DocumentType: dto.dokumentType,
Eingangsdatum: dto.Eingangsdatum ? new Date(dto.Eingangsdatum) : null,
Tags: dto.tag ? String(dto.tag) : null,
BetriebID: dto.betriebId,
Lieferant: dto.lieferant,
EinkaufID: dto.einkaufId,
externeBelegnummer: dto.externeBelegnummer,
Belegdatum: dto.belegdatum ? new Date(dto.belegdatum) : null,
TaskReferenceID: dto.parentId || '',
BarcodeJson: dto.barcodeJson || '',
DuplikatZU: dto.duplikatZu,
Fertig: 0,
});
await this.taskRepo.save(task);
this.logger.log(`Externer Upload erfolgreich: ${task.TaskId} für Beleg ${dto.interneBelegnummer}`);
return task.TaskId;
} catch (err) {
this.logger.error(`Fehler beim externen Upload für Beleg ${dto.interneBelegnummer}: ${err.message}`, err.stack);
throw new HttpException(
`Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
@@ -0,0 +1,25 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PaperlessService } from './paperless.service';
import { PaperlessController } from './paperless.controller';
import { PaperlessProcessorService } from './paperless-processor.service';
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
import { DocumentType } from '../database/entities/document-type.entity';
import { DocumentField } from '../database/entities/document-field.entity';
import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity';
import { Attachment } from '../database/entities/attachment.entity';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([DocumentType, DocumentField, Task, Document, Attachment]),
forwardRef(() => PostprocessingModule),
AuthModule,
],
controllers: [PaperlessController],
providers: [PaperlessService, PaperlessProcessorService, PaperlessTaskProcessorService],
exports: [PaperlessService],
})
export class PaperlessModule {}
@@ -0,0 +1,287 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { type AxiosInstance } from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import FormData = require('form-data');
@Injectable()
export class PaperlessService {
private readonly logger = new Logger(PaperlessService.name);
private readonly client: AxiosInstance;
constructor(private readonly configService: ConfigService) {
const baseURL = this.configService.get<string>('PAPERLESS_URL', 'http://localhost:8000');
const token = this.configService.get<string>('PAPERLESS_TOKEN', '');
this.client = axios.create({
baseURL: `${baseURL}/api`,
headers: {
Authorization: `Token ${token}`,
},
timeout: 30000,
});
}
async uploadDocument(
filePath: string,
options?: {
title?: string;
filename?: string;
created?: string;
documentType?: number;
correspondent?: number;
storagePath?: number;
tags?: number[];
owner?: number;
archiveSerialNumber?: number;
customFields?: Record<string | number, string>;
},
): Promise<string> {
const form = new FormData();
const uploadFilename = options?.filename
? `${options.filename}.pdf`
: path.basename(filePath);
form.append('document', fs.createReadStream(filePath), {
filename: uploadFilename,
contentType: 'application/pdf',
});
if (options?.title) form.append('title', options.title);
if (options?.created) form.append('created', options.created);
if (options?.documentType) form.append('document_type', String(options.documentType));
if (options?.correspondent) form.append('correspondent', String(options.correspondent));
if (options?.storagePath) form.append('storage_path', String(options.storagePath));
if (options?.owner !== undefined && options.owner !== null) {
form.append('owner', String(options.owner));
}
if (options?.tags) {
options.tags.forEach((tag) => form.append('tags', String(tag)));
}
if (options?.archiveSerialNumber !== undefined && !Number.isNaN(options.archiveSerialNumber)) {
form.append('archive_serial_number', String(options.archiveSerialNumber));
}
if (options?.customFields && Object.keys(options.customFields).length > 0) {
form.append('custom_fields', JSON.stringify(options.customFields));
}
const response = await this.client.post('/documents/post_document/', form, {
headers: form.getHeaders(),
timeout: 120000,
});
this.logger.log(`Dokument hochgeladen: ${response.data}`);
return response.data;
}
async getUsers(): Promise<any[]> {
const response = await this.client.get('/users/');
return response.data?.results ?? [];
}
async getDocuments(params?: Record<string, any>): Promise<any> {
const response = await this.client.get('/documents/', { params });
return response.data;
}
async getDocument(id: number): Promise<any> {
const response = await this.client.get(`/documents/${id}/`);
return response.data;
}
async getInboxDocuments(): Promise<any[]> {
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
const response = await this.client.get('/documents/', {
params: {
page: 1,
page_size: 9999,
ordering: '-added',
truncate_content: true,
tags__id__all: 1
}
});
return response.data.results;
}
async getManuellDocuments(): Promise<any[]> {
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6);
const response = await this.client.get('/documents/', {
params: {
page: 1,
page_size: 9999,
ordering: '-added',
truncate_content: true,
tags__id__all: errorTag
}
});
return response.data.results;
}
async updateDocument(id: number, data: Record<string, any>): Promise<any> {
try {
const response = await this.client.patch(`/documents/${id}/`, data);
return response.data;
} catch (err: any) {
const body = err?.response?.data;
if (body) {
this.logger.error(`Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`);
}
throw err;
}
}
async getDocumentTypes(): Promise<any[]> {
const response = await this.client.get('/document_types/', {
params: { page_size: 9999 }
});
return response.data.results;
}
async getTags(): Promise<any[]> {
const response = await this.client.get('/tags/', {
params: { page_size: 9999 }
});
return response.data.results;
}
async getCorrespondents(params?: Record<string, any>): Promise<any> {
const response = await this.client.get('/correspondents/', { params });
return response.data;
}
async getCorrespondent(id: number): Promise<any> {
const response = await this.client.get(`/correspondents/${id}/`);
return response.data;
}
async getCustomFields(): Promise<any[]> {
const response = await this.client.get('/custom_fields/', {
params: { page_size: 9999 }
});
return response.data.results;
}
async getTask(taskId: string): Promise<any> {
const response = await this.client.get('/tasks/', {
params: { task_id: taskId },
});
return response.data;
}
async getCorrespondentByName(name: string): Promise<any> {
const response = await this.client.get('/correspondents/', {
params: { name__icontains: name },
});
return response.data.results.find((c: any) => c.name === name);
}
async addCorrespondent(data: any): Promise<any> {
const response = await this.client.post('/correspondents/', data);
return response.data;
}
async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise<Buffer> {
const endpoint = type === 'original'
? `/documents/${id}/download/`
: `/documents/${id}/download/`;
const response = await this.client.get(endpoint, {
responseType: 'arraybuffer',
params: type === 'original' ? { original: true } : {},
timeout: 120000,
});
return Buffer.from(response.data);
}
async getDocumentPreviewStream(id: number): Promise<any> {
const response = await this.client.get(`/documents/${id}/thumb/`, {
responseType: 'stream',
timeout: 30000,
});
return response.data;
}
async getDocumentPdfStream(id: number, type: 'original' | 'archive' = 'archive'): Promise<any> {
const endpoint = type === 'original'
? `/documents/${id}/download/`
: `/documents/${id}/download/`;
const response = await this.client.get(endpoint, {
responseType: 'stream',
params: type === 'original' ? { original: true } : {},
timeout: 120000,
});
return response.data;
}
async getDocumentMetadata(id: number): Promise<any> {
const response = await this.client.get(`/documents/${id}/metadata/`);
return response.data;
}
async addNote(id: number, note: string): Promise<any> {
const response = await this.client.post(`/documents/${id}/notes/`, { note });
return response.data;
}
async checksumExists(checksum: string): Promise<boolean> {
const response = await this.client.get('/documents/', {
params: { checksum__iexact: checksum },
});
return response.data.count > 0;
}
/**
* Prüft, ob eine ASN bereits vergeben ist und wirft einen Fehler, falls ja.
*/
async validateAsnNotExists(interneBelegnummer: string): Promise<void> {
if (!interneBelegnummer) return;
// Logic like in PaperlessTaskProcessorService
const asnNum = parseInt(interneBelegnummer.replace(/-/g, ''), 10);
if (isNaN(asnNum)) return;
const existingDocId = await this.findDocumentIdByAsn(asnNum);
if (existingDocId) {
throw new HttpException(
`Die ASN ${asnNum} (aus Belegnummer ${interneBelegnummer}) existiert bereits in Paperless (Dokument ID: ${existingDocId}).`,
HttpStatus.CONFLICT,
);
}
}
/**
* Liefert die Paperless-Doc-ID des passenden Dokuments oder null.
*/
async findDocumentIdByAsn(asn: number): Promise<number | null> {
if (!Number.isFinite(asn)) return null;
const response = await this.client.get('/documents/', {
params: { archive_serial_number: asn, page_size: 5 },
});
if ((response.data.count ?? 0) === 0) return null;
const match = (response.data.results as any[] ?? []).find(
(doc: any) => Number(doc.archive_serial_number) === asn,
);
return match ? Number(match.id) : null;
}
/**
* Liefert die Paperless-Doc-ID des passenden Dokuments oder null.
* Paperless kann den custom_fields-Filter ignorieren — daher manuell verifizieren.
*/
async findDocumentIdByCustomField(fieldId: number, value: string): Promise<number | null> {
const response = await this.client.get('/documents/', {
params: {
[`custom_fields__${fieldId}__value__iexact`]: value,
page_size: 25,
truncate_content: true,
},
});
if ((response.data.count ?? 0) === 0) return null;
const valueLower = value.toLowerCase();
const match = (response.data.results as any[] ?? []).find((doc: any) =>
(Array.isArray(doc.custom_fields) ? doc.custom_fields as any[] : []).some(
(cf: any) => cf.field === fieldId && String(cf.value ?? '').toLowerCase() === valueLower,
),
);
return match ? Number(match.id) : null;
}
}