Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user