Compare commits
6 Commits
8390d03869
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 32ba772e4e | |||
| 095cc4bb02 | |||
| 7b2a79be2a | |||
| 1c70473cef | |||
| beaa1be4a5 | |||
| 57c8964384 |
@@ -20,6 +20,14 @@ import { Attachment } from '../database/entities/attachment.entity';
|
|||||||
import { Content } from '../database/entities/content.entity';
|
import { Content } from '../database/entities/content.entity';
|
||||||
import { isERechnung } from './zugferd.util';
|
import { isERechnung } from './zugferd.util';
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[/\\]/g, '-')
|
||||||
|
.replace(/\.\./g, '.')
|
||||||
|
.replace(/\x00/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailDownloadService {
|
export class EmailDownloadService {
|
||||||
private readonly logger = new Logger(EmailDownloadService.name);
|
private readonly logger = new Logger(EmailDownloadService.name);
|
||||||
@@ -291,7 +299,7 @@ export class EmailDownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachment = new Attachment();
|
const attachment = new Attachment();
|
||||||
attachment.FileName = filename.slice(0, 255);
|
attachment.FileName = sanitizeFilename(filename).slice(0, 255);
|
||||||
attachment.ContentType = contentType.slice(0, 100);
|
attachment.ContentType = contentType.slice(0, 100);
|
||||||
attachment.IsEmbedded = isEmbedded;
|
attachment.IsEmbedded = isEmbedded;
|
||||||
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Content } from '../database/entities/content.entity';
|
|||||||
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
import { deriveTaskMetadata } from '../paperless/task-metadata.util';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { EmailPageCacheService } from './email-page-cache.service';
|
import { EmailPageCacheService } from './email-page-cache.service';
|
||||||
import { ImapFolderService } from './imap-folder.service';
|
import { ImapFolderService } from './imap-folder.service';
|
||||||
@@ -19,6 +20,14 @@ import * as os from 'os';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[/\\]/g, '-')
|
||||||
|
.replace(/\.\./g, '.')
|
||||||
|
.replace(/\x00/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailImportService {
|
export class EmailImportService {
|
||||||
private readonly logger = new Logger(EmailImportService.name);
|
private readonly logger = new Logger(EmailImportService.name);
|
||||||
@@ -491,7 +500,7 @@ export class EmailImportService {
|
|||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
const originalPdfBytes = content.Content1;
|
const originalPdfBytes = content.Content1;
|
||||||
const baseFilename = attachmentEntity.FileName.replace(/\.pdf$/i, '');
|
const baseFilename = sanitizeFilename(attachmentEntity.FileName.replace(/\.pdf$/i, ''));
|
||||||
const paperlessIds: any = {};
|
const paperlessIds: any = {};
|
||||||
|
|
||||||
const uploadPromises = [];
|
const uploadPromises = [];
|
||||||
@@ -557,13 +566,29 @@ export class EmailImportService {
|
|||||||
|
|
||||||
// Upload all generated PDFs
|
// Upload all generated PDFs
|
||||||
for (const uploadItem of uploadPromises) {
|
for (const uploadItem of uploadPromises) {
|
||||||
|
// Einmal definieren, zweimal verwenden: Upload-Metadaten und Task
|
||||||
|
// tragen garantiert dieselben Werte
|
||||||
|
const taskFields = {
|
||||||
|
InterneBelegnummer: att.belegnummer || '',
|
||||||
|
Eingangsdatum: att.barcode?.datum
|
||||||
|
? new Date(att.barcode.datum)
|
||||||
|
: createdDate,
|
||||||
|
Belegdatum: createdDate,
|
||||||
|
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
|
||||||
|
BetriebID: null, // Owner-Entfernung erledigt der Task-Processor
|
||||||
|
};
|
||||||
|
// Metadaten direkt beim Upload mitgeben; der Task-Processor patcht
|
||||||
|
// später idempotent nach (Sicherheitsnetz)
|
||||||
|
const derived = deriveTaskMetadata(taskFields);
|
||||||
const options: any = {
|
const options: any = {
|
||||||
filename: uploadItem.filename,
|
filename: uploadItem.filename,
|
||||||
title: att.belegnummer
|
title: att.belegnummer
|
||||||
? `Beleg ${att.belegnummer}`
|
? `Beleg ${att.belegnummer}`
|
||||||
: uploadItem.filename,
|
: uploadItem.filename,
|
||||||
created: createdDate,
|
created: createdDate,
|
||||||
owner: null,
|
documentType: derived.documentType,
|
||||||
|
archiveSerialNumber: derived.archiveSerialNumber,
|
||||||
|
customFields: derived.customFields,
|
||||||
};
|
};
|
||||||
if (att.paperlessCorrespondentId)
|
if (att.paperlessCorrespondentId)
|
||||||
options.correspondent = att.paperlessCorrespondentId;
|
options.correspondent = att.paperlessCorrespondentId;
|
||||||
@@ -577,15 +602,9 @@ export class EmailImportService {
|
|||||||
// Create background task for enrichment (same logic as Inbox)
|
// Create background task for enrichment (same logic as Inbox)
|
||||||
const backgroundTask = this.taskRepo.create({
|
const backgroundTask = this.taskRepo.create({
|
||||||
TaskId: paperlessTaskId,
|
TaskId: paperlessTaskId,
|
||||||
InterneBelegnummer: att.belegnummer || '',
|
...taskFields,
|
||||||
Eingangsdatum: att.barcode?.datum
|
|
||||||
? new Date(att.barcode.datum)
|
|
||||||
: createdDate,
|
|
||||||
Belegdatum: createdDate,
|
|
||||||
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
|
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
|
||||||
BetriebID: null, // Owner
|
|
||||||
Fertig: 0,
|
Fertig: 0,
|
||||||
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
|
|
||||||
SourceAttachmentID: att.attachmentId,
|
SourceAttachmentID: att.attachmentId,
|
||||||
SourceAttachmentRange: uploadItem.rangeKey,
|
SourceAttachmentRange: uploadItem.rangeKey,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
|
||||||
|
import { Task } from '../database/entities/task.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charakterisierungstest für processSuccessfulTask: pinnt den PATCH-Payload
|
||||||
|
* fest, damit der Refactor auf deriveTaskMetadata() verhaltensidentisch bleibt.
|
||||||
|
* custom_fields wird ordnungsunabhängig verglichen (Paperless behandelt das
|
||||||
|
* Array als Menge).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
TaskId: 'task-uuid-1',
|
||||||
|
InterneBelegnummer: '',
|
||||||
|
DocumentType: null,
|
||||||
|
Eingangsdatum: null,
|
||||||
|
Fertig: 0,
|
||||||
|
Tags: null,
|
||||||
|
BetriebID: null,
|
||||||
|
Lieferant: null,
|
||||||
|
externeBelegnummer: null,
|
||||||
|
EinkaufID: null,
|
||||||
|
Belegdatum: null,
|
||||||
|
PaperlessDocumentID: null,
|
||||||
|
TaskReferenceID: null,
|
||||||
|
BarcodeJson: null,
|
||||||
|
DuplikatZU: null,
|
||||||
|
CustomFieldsJson: null,
|
||||||
|
Asn: null,
|
||||||
|
SourceAttachmentID: null,
|
||||||
|
SourceAttachmentRange: null,
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByField(fields: Array<{ field: number }>) {
|
||||||
|
return [...fields].sort((a, b) => a.field - b.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erwartete Form des PATCH-Payloads an updateDocument. */
|
||||||
|
interface PatchPayload {
|
||||||
|
custom_fields: Array<{ field: number; value: unknown }>;
|
||||||
|
archive_serial_number?: number;
|
||||||
|
document_type?: number;
|
||||||
|
created?: string;
|
||||||
|
owner?: number | null;
|
||||||
|
tags?: number[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
|
||||||
|
let paperlessService: {
|
||||||
|
getDocument: jest.Mock;
|
||||||
|
updateDocument: jest.Mock;
|
||||||
|
addNote: jest.Mock;
|
||||||
|
getDocumentMetadata: jest.Mock;
|
||||||
|
getTask: jest.Mock;
|
||||||
|
};
|
||||||
|
let taskRepo: { save: jest.Mock };
|
||||||
|
let documentRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock };
|
||||||
|
let attachmentRepo: { findOne: jest.Mock; save: jest.Mock };
|
||||||
|
let service: PaperlessTaskProcessorService;
|
||||||
|
|
||||||
|
/** Zugriff auf die private Methode, typsicher für den Test gekapselt. */
|
||||||
|
function runProcessSuccessfulTask(
|
||||||
|
task: Task,
|
||||||
|
apiTask: { related_document: number },
|
||||||
|
parentTask: Task | null,
|
||||||
|
): Promise<void> {
|
||||||
|
return (
|
||||||
|
service as unknown as {
|
||||||
|
processSuccessfulTask: (
|
||||||
|
t: Task,
|
||||||
|
a: { related_document: number },
|
||||||
|
p: Task | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).processSuccessfulTask(task, apiTask, parentTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastPatchPayload(): PatchPayload {
|
||||||
|
const calls = paperlessService.updateDocument.mock.calls as Array<
|
||||||
|
[number, PatchPayload]
|
||||||
|
>;
|
||||||
|
return calls[calls.length - 1][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
paperlessService = {
|
||||||
|
getDocument: jest.fn(),
|
||||||
|
updateDocument: jest.fn().mockResolvedValue(undefined),
|
||||||
|
addNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getDocumentMetadata: jest.fn().mockResolvedValue({
|
||||||
|
original_checksum: 'abc',
|
||||||
|
original_filename: 'test.pdf',
|
||||||
|
}),
|
||||||
|
getTask: jest.fn(),
|
||||||
|
};
|
||||||
|
taskRepo = { save: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
documentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn((v: object) => v),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
attachmentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
service = new PaperlessTaskProcessorService(
|
||||||
|
taskRepo as any,
|
||||||
|
documentRepo as any,
|
||||||
|
attachmentRepo as any,
|
||||||
|
paperlessService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('baut den PATCH-Payload mit ASN, CF3/CF7/CF9, Typ, created, owner und Tag-Merge', async () => {
|
||||||
|
paperlessService.getDocument.mockResolvedValue({
|
||||||
|
id: 101,
|
||||||
|
title: 'Testdokument',
|
||||||
|
custom_fields: [{ field: 7, value: 'alt' }],
|
||||||
|
tags: [99],
|
||||||
|
});
|
||||||
|
const task = createTask({
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
externeBelegnummer: 'RE-9',
|
||||||
|
Eingangsdatum: new Date('2026-07-14T00:00:00Z'),
|
||||||
|
Belegdatum: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
DocumentType: 2,
|
||||||
|
BetriebID: 3,
|
||||||
|
Tags: '1,2',
|
||||||
|
CustomFieldsJson: JSON.stringify({ '4': 'x' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runProcessSuccessfulTask(task, { related_document: 101 }, null);
|
||||||
|
|
||||||
|
expect(paperlessService.updateDocument).toHaveBeenCalledTimes(1);
|
||||||
|
expect(paperlessService.updateDocument).toHaveBeenCalledWith(
|
||||||
|
101,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
const payload = lastPatchPayload();
|
||||||
|
expect(payload.archive_serial_number).toBe(2026000123);
|
||||||
|
expect(payload.document_type).toBe(2);
|
||||||
|
expect(payload.created).toBe('2026-07-01T00:00:00.000Z');
|
||||||
|
expect(payload.owner).toBe(3);
|
||||||
|
expect(payload.tags).toEqual([99, 1, 2]);
|
||||||
|
expect(sortByField(payload.custom_fields)).toEqual([
|
||||||
|
{ field: 3, value: 'RE-9' },
|
||||||
|
{ field: 4, value: 'x' },
|
||||||
|
{ field: 7, value: '2026-000123' },
|
||||||
|
{ field: 9, value: '2026-07-14' },
|
||||||
|
]);
|
||||||
|
expect(task.Fertig).toBe(1);
|
||||||
|
expect(task.PaperlessDocumentID).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt den Owner (null) bei leerer BetriebID', async () => {
|
||||||
|
paperlessService.getDocument.mockResolvedValue({
|
||||||
|
id: 102,
|
||||||
|
title: 'Ohne Betrieb',
|
||||||
|
custom_fields: [],
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
const task = createTask({ InterneBelegnummer: '2026-000124' });
|
||||||
|
|
||||||
|
await runProcessSuccessfulTask(task, { related_document: 102 }, null);
|
||||||
|
|
||||||
|
expect(lastPatchPayload().owner).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('überschreibt bei Anlagen document_type/title und verknüpft CF8 zum Elterndokument', async () => {
|
||||||
|
paperlessService.getDocument
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 103,
|
||||||
|
title: 'Anlage-Roh',
|
||||||
|
custom_fields: [],
|
||||||
|
tags: [],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ id: 55, title: 'Eltern' });
|
||||||
|
paperlessService.getTask.mockResolvedValue([{ related_document: 55 }]);
|
||||||
|
const task = createTask({
|
||||||
|
DocumentType: 2,
|
||||||
|
TaskReferenceID: 'parent-uuid',
|
||||||
|
});
|
||||||
|
const parentTask = createTask({
|
||||||
|
TaskId: 'parent-uuid',
|
||||||
|
InterneBelegnummer: '2026-000100',
|
||||||
|
Fertig: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runProcessSuccessfulTask(task, { related_document: 103 }, parentTask);
|
||||||
|
|
||||||
|
const payload = lastPatchPayload();
|
||||||
|
expect(payload.document_type).toBe(5);
|
||||||
|
expect(payload.title).toBe('Anlage zu 2026-000100');
|
||||||
|
expect(sortByField(payload.custom_fields)).toEqual([
|
||||||
|
{ field: 8, value: [55] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { Task } from '../database/entities/task.entity';
|
|||||||
import { Document } from '../database/entities/document.entity';
|
import { Document } from '../database/entities/document.entity';
|
||||||
import { Attachment } from '../database/entities/attachment.entity';
|
import { Attachment } from '../database/entities/attachment.entity';
|
||||||
import { PaperlessService } from './paperless.service';
|
import { PaperlessService } from './paperless.service';
|
||||||
|
import { deriveTaskMetadata } from './task-metadata.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaperlessTaskProcessorService {
|
export class PaperlessTaskProcessorService {
|
||||||
@@ -74,7 +75,12 @@ export class PaperlessTaskProcessorService {
|
|||||||
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
|
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
|
||||||
}
|
}
|
||||||
} else if (apiResponseTask.status === 'FAILURE') {
|
} else if (apiResponseTask.status === 'FAILURE') {
|
||||||
this.logger.warn(`Task ${t.TaskId} failed in Paperless`);
|
// Seit Metadaten (inkl. ASN) direkt beim Upload gesetzt werden, kann
|
||||||
|
// z.B. eine ASN-Kollision bereits den Consume scheitern lassen –
|
||||||
|
// vor dem Löschen die Diagnose-Infos festhalten
|
||||||
|
this.logger.error(
|
||||||
|
`Task ${t.TaskId} in Paperless fehlgeschlagen (Beleg: ${t.InterneBelegnummer || '-'}, Attachment: ${t.SourceAttachmentID ?? '-'}). Paperless-Meldung: ${apiResponseTask.result ?? 'keine'}`,
|
||||||
|
);
|
||||||
toDelete.push(t);
|
toDelete.push(t);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -200,104 +206,34 @@ export class PaperlessTaskProcessorService {
|
|||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// CustomFieldsJson als Basis zuerst anwenden – dedizierte Felder weiter unten überschreiben diese
|
// Gemeinsame Ableitung – identische Regeln wie beim direkten Upload (task-metadata.util)
|
||||||
if (t.CustomFieldsJson) {
|
const derived = deriveTaskMetadata(t);
|
||||||
try {
|
|
||||||
const extra = JSON.parse(t.CustomFieldsJson) as Record<
|
for (const [k, v] of Object.entries(derived.customFields)) {
|
||||||
string,
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
for (const [k, v] of Object.entries(extra)) {
|
|
||||||
const fieldId = parseInt(k, 10);
|
const fieldId = parseInt(k, 10);
|
||||||
if (!Number.isFinite(fieldId)) continue;
|
|
||||||
const idx = updateData.custom_fields.findIndex(
|
const idx = updateData.custom_fields.findIndex(
|
||||||
(f: any) => f.field === fieldId,
|
(f: any) => f.field === fieldId,
|
||||||
);
|
);
|
||||||
if (idx !== -1) updateData.custom_fields[idx].value = v;
|
if (idx !== -1) updateData.custom_fields[idx].value = v;
|
||||||
else updateData.custom_fields.push({ field: fieldId, value: v });
|
else updateData.custom_fields.push({ field: fieldId, value: v });
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* JSON-Parse-Fehler ignorieren */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.Asn) {
|
if (derived.archiveSerialNumber !== undefined) {
|
||||||
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
|
|
||||||
if (!isNaN(asnNum)) {
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze ASN: ${derived.archiveSerialNumber}`,
|
||||||
);
|
);
|
||||||
updateData.archive_serial_number = asnNum;
|
updateData.archive_serial_number = derived.archiveSerialNumber;
|
||||||
}
|
} else if (t.Asn || t.InterneBelegnummer) {
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
this.logger.warn(
|
||||||
`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`,
|
`[Postprocessing] Task ${t.TaskId} - ASN konnte nicht abgeleitet werden (Asn: ${t.Asn ?? '-'}, InterneBelegnummer: ${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) {
|
if (derived.documentType !== undefined) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${derived.documentType}`,
|
||||||
);
|
);
|
||||||
const existingField3 = updateData.custom_fields.find(
|
updateData.document_type = derived.documentType;
|
||||||
(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
|
// Parent Task / Attachment logic
|
||||||
@@ -350,36 +286,33 @@ export class PaperlessTaskProcessorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.Belegdatum) {
|
if (derived.created) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${derived.created}`,
|
||||||
);
|
);
|
||||||
updateData.created = t.Belegdatum.toISOString();
|
updateData.created = derived.created;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.BetriebID) {
|
if (derived.owner !== undefined) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${derived.owner}`,
|
||||||
);
|
);
|
||||||
updateData.owner = t.BetriebID;
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`,
|
`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`,
|
||||||
);
|
);
|
||||||
updateData.owner = null;
|
|
||||||
}
|
}
|
||||||
|
updateData.owner = derived.owner ?? null;
|
||||||
|
|
||||||
// Tags
|
// Tags: Upload-/Consume-Tags des Dokuments bleiben erhalten (Merge statt Ersetzen)
|
||||||
if (t.Tags) {
|
if (derived.tags) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`,
|
`[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 currentTags = document.tags || [];
|
||||||
const newTags = Array.from(new Set([...currentTags, ...tagIds]));
|
updateData.tags = Array.from(
|
||||||
updateData.tags = newTags;
|
new Set([...currentTags, ...derived.tags]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
|
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PaperlessService } from './paperless.service';
|
import { PaperlessService } from './paperless.service';
|
||||||
|
import { deriveTaskMetadata } from './task-metadata.util';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||||
import { UploadExternalDto } from './dto/upload-external.dto';
|
import { UploadExternalDto } from './dto/upload-external.dto';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
@@ -514,11 +515,28 @@ export class PaperlessController {
|
|||||||
// 0. Check if ASN already exists
|
// 0. Check if ASN already exists
|
||||||
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
|
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
|
||||||
|
|
||||||
// 1. Forward to Paperless
|
// 1. Forward to Paperless – Metadaten direkt beim Upload mitgeben;
|
||||||
|
// der Task-Processor patcht später idempotent nach (Sicherheitsnetz)
|
||||||
|
const derived = deriveTaskMetadata({
|
||||||
|
InterneBelegnummer: dto.interneBelegnummer,
|
||||||
|
externeBelegnummer: dto.externeBelegnummer ?? null,
|
||||||
|
Eingangsdatum: dto.Eingangsdatum ? new Date(dto.Eingangsdatum) : null,
|
||||||
|
Belegdatum: dto.belegdatum ? new Date(dto.belegdatum) : null,
|
||||||
|
// Anlagen (parentId gesetzt) bekommen Typ 5 sofort; Titel/CF8 folgen im Processor
|
||||||
|
DocumentType: dto.parentId ? 5 : (dto.dokumentType ?? null),
|
||||||
|
Tags: dto.tag ? String(dto.tag) : null,
|
||||||
|
BetriebID: dto.betriebId ?? null,
|
||||||
|
});
|
||||||
const paperlessTaskId = await this.paperlessService.uploadDocument(
|
const paperlessTaskId = await this.paperlessService.uploadDocument(
|
||||||
file.path,
|
file.path,
|
||||||
{
|
{
|
||||||
title: `Beleg ${dto.interneBelegnummer}`,
|
title: `Beleg ${dto.interneBelegnummer}`,
|
||||||
|
created: derived.created,
|
||||||
|
documentType: derived.documentType,
|
||||||
|
owner: derived.owner,
|
||||||
|
tags: derived.tags,
|
||||||
|
archiveSerialNumber: derived.archiveSerialNumber,
|
||||||
|
customFields: derived.customFields,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { deriveTaskMetadata } from './task-metadata.util';
|
||||||
|
|
||||||
|
describe('deriveTaskMetadata', () => {
|
||||||
|
describe('archiveSerialNumber', () => {
|
||||||
|
it('parst ASN aus Asn und entfernt alle Nicht-Ziffern', () => {
|
||||||
|
const result = deriveTaskMetadata({ Asn: 'ASN-123' });
|
||||||
|
expect(result.archiveSerialNumber).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leitet ASN aus InterneBelegnummer ab (nur Bindestriche entfernen)', () => {
|
||||||
|
const result = deriveTaskMetadata({ InterneBelegnummer: '2026-000123' });
|
||||||
|
expect(result.archiveSerialNumber).toBe(2026000123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bevorzugt Asn vor InterneBelegnummer', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Asn: '42',
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
});
|
||||||
|
expect(result.archiveSerialNumber).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nutzt keinen Belegnummer-Fallback, wenn Asn gesetzt aber unparsebar ist', () => {
|
||||||
|
// Entspricht exakt der Processor-Logik: Fallback nur bei leerem Asn
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Asn: 'abc',
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
});
|
||||||
|
expect(result.archiveSerialNumber).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ASN weg, wenn InterneBelegnummer nicht parsebar ist', () => {
|
||||||
|
const result = deriveTaskMetadata({ InterneBelegnummer: 'Beleg-X' });
|
||||||
|
expect(result.archiveSerialNumber).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customFields (CF7/CF3/CF9)', () => {
|
||||||
|
it('setzt CF7 aus InterneBelegnummer', () => {
|
||||||
|
const result = deriveTaskMetadata({ InterneBelegnummer: '2026-000123' });
|
||||||
|
expect(result.customFields['7']).toBe('2026-000123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt CF3 aus externeBelegnummer', () => {
|
||||||
|
const result = deriveTaskMetadata({ externeBelegnummer: 'RE-4711' });
|
||||||
|
expect(result.customFields['3']).toBe('RE-4711');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt CF9 aus Eingangsdatum als YYYY-MM-DD', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Eingangsdatum: new Date('2026-07-14T10:30:00Z'),
|
||||||
|
});
|
||||||
|
expect(result.customFields['9']).toBe('2026-07-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('übernimmt CustomFieldsJson als Basis mit normalisierten numerischen Keys', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
CustomFieldsJson: JSON.stringify({ '4': 'wert4', abc: 'ignoriert' }),
|
||||||
|
});
|
||||||
|
expect(result.customFields).toEqual({ '4': 'wert4' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('überschreibt CustomFieldsJson-Basis mit dedizierten Feldern CF7/CF3/CF9', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
externeBelegnummer: 'RE-4711',
|
||||||
|
Eingangsdatum: new Date('2026-07-14T00:00:00Z'),
|
||||||
|
CustomFieldsJson: JSON.stringify({
|
||||||
|
'7': 'alt7',
|
||||||
|
'3': 'alt3',
|
||||||
|
'9': 'alt9',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(result.customFields['7']).toBe('2026-000123');
|
||||||
|
expect(result.customFields['3']).toBe('RE-4711');
|
||||||
|
expect(result.customFields['9']).toBe('2026-07-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert ungültiges CustomFieldsJson still', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
CustomFieldsJson: '{kein json',
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
});
|
||||||
|
expect(result.customFields).toEqual({ '7': '2026-000123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leeres customFields-Objekt ohne Eingaben', () => {
|
||||||
|
const result = deriveTaskMetadata({});
|
||||||
|
expect(result.customFields).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documentType / created / owner / tags', () => {
|
||||||
|
it('übernimmt DocumentType nur wenn truthy', () => {
|
||||||
|
expect(deriveTaskMetadata({ DocumentType: 5 }).documentType).toBe(5);
|
||||||
|
expect(
|
||||||
|
deriveTaskMetadata({ DocumentType: 0 }).documentType,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
deriveTaskMetadata({ DocumentType: null }).documentType,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leitet created als ISO-String aus Belegdatum ab', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Belegdatum: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
});
|
||||||
|
expect(result.created).toBe('2026-07-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt owner nur bei truthy BetriebID', () => {
|
||||||
|
expect(deriveTaskMetadata({ BetriebID: 3 }).owner).toBe(3);
|
||||||
|
expect(deriveTaskMetadata({ BetriebID: null }).owner).toBeUndefined();
|
||||||
|
expect(deriveTaskMetadata({}).owner).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parst Tags-CSV und filtert Ungültiges', () => {
|
||||||
|
const result = deriveTaskMetadata({ Tags: '1, 2, x' });
|
||||||
|
expect(result.tags).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt tags bei leerem Tags-Feld weg', () => {
|
||||||
|
expect(deriveTaskMetadata({ Tags: null }).tags).toBeUndefined();
|
||||||
|
expect(deriveTaskMetadata({}).tags).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leere Eingaben', () => {
|
||||||
|
it('lässt bei leeren Strings alle Felder weg', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
InterneBelegnummer: '',
|
||||||
|
Asn: '',
|
||||||
|
externeBelegnummer: '',
|
||||||
|
Tags: '',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ customFields: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Leitet Paperless-Metadaten aus Task-Feldern ab.
|
||||||
|
*
|
||||||
|
* Wird sowohl von den Upload-Pfaden (external-upload, E-Mail-Import) als auch
|
||||||
|
* vom PaperlessTaskProcessorService genutzt, damit beim Upload und beim
|
||||||
|
* nachgelagerten PATCH garantiert dieselben Ableitungsregeln gelten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Teilmenge der Task-Entity-Felder, aus denen Metadaten abgeleitet werden. */
|
||||||
|
export interface TaskMetadataInput {
|
||||||
|
InterneBelegnummer?: string | null;
|
||||||
|
Asn?: string | null;
|
||||||
|
externeBelegnummer?: string | null;
|
||||||
|
Eingangsdatum?: Date | string | null;
|
||||||
|
Belegdatum?: Date | string | null;
|
||||||
|
DocumentType?: number | null;
|
||||||
|
/** CSV wie Task.Tags, z.B. "1,2,3" */
|
||||||
|
Tags?: string | null;
|
||||||
|
BetriebID?: number | null;
|
||||||
|
CustomFieldsJson?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DerivedTaskMetadata {
|
||||||
|
archiveSerialNumber?: number;
|
||||||
|
/** Basis aus CustomFieldsJson; CF7/CF3/CF9 überschreiben die Basis. */
|
||||||
|
customFields: Record<string, string>;
|
||||||
|
documentType?: number;
|
||||||
|
/** ISO-String aus Belegdatum */
|
||||||
|
created?: string;
|
||||||
|
/** Nur gesetzt, wenn BetriebID truthy — Owner-Entfernung bleibt Sache des Processors. */
|
||||||
|
owner?: number;
|
||||||
|
tags?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTaskMetadata(t: TaskMetadataInput): DerivedTaskMetadata {
|
||||||
|
const result: DerivedTaskMetadata = { customFields: {} };
|
||||||
|
|
||||||
|
// Basis aus CustomFieldsJson – dedizierte Felder 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;
|
||||||
|
result.customFields[String(fieldId)] = v;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* JSON-Parse-Fehler ignorieren */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Asn) {
|
||||||
|
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
|
||||||
|
if (!isNaN(asnNum)) {
|
||||||
|
result.archiveSerialNumber = asnNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.InterneBelegnummer) {
|
||||||
|
// ASN-Fallback nur bei leerem Asn; bewusst nur Bindestriche entfernen
|
||||||
|
if (!t.Asn) {
|
||||||
|
const asnFromBelegnummer = parseInt(
|
||||||
|
t.InterneBelegnummer.replace(/-/g, ''),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!isNaN(asnFromBelegnummer)) {
|
||||||
|
result.archiveSerialNumber = asnFromBelegnummer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.customFields['7'] = t.InterneBelegnummer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.externeBelegnummer) {
|
||||||
|
result.customFields['3'] = t.externeBelegnummer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Eingangsdatum) {
|
||||||
|
result.customFields['9'] = new Date(t.Eingangsdatum)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.DocumentType) {
|
||||||
|
result.documentType = t.DocumentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Belegdatum) {
|
||||||
|
result.created = new Date(t.Belegdatum).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.BetriebID) {
|
||||||
|
result.owner = t.BetriebID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Tags) {
|
||||||
|
result.tags = t.Tags.split(',')
|
||||||
|
.map((id) => parseInt(id.trim(), 10))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { WebhookQueueService } from './webhook-queue.service';
|
|||||||
* FIFO-Array simuliert: `INSERT IGNORE` (Dedup), FIFO-`find` und `delete`.
|
* FIFO-Array simuliert: `INSERT IGNORE` (Dedup), FIFO-`find` und `delete`.
|
||||||
*/
|
*/
|
||||||
function createQueueRepoMock() {
|
function createQueueRepoMock() {
|
||||||
const store: number[] = [];
|
const store: { documentId: number; createdAt: Date }[] = [];
|
||||||
return {
|
return {
|
||||||
store,
|
store,
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
@@ -22,8 +22,15 @@ function createQueueRepoMock() {
|
|||||||
values: (v: { documentId: number }) => ({
|
values: (v: { documentId: number }) => ({
|
||||||
orIgnore: () => ({
|
orIgnore: () => ({
|
||||||
execute: () => {
|
execute: () => {
|
||||||
const added = !store.includes(v.documentId);
|
const added = !store.some((s) => s.documentId === v.documentId);
|
||||||
if (added) store.push(v.documentId);
|
if (added) {
|
||||||
|
// Standardmäßig "alt genug" (60s), damit die Mindest-Wartezeit
|
||||||
|
// die Verhaltenstests nicht blockiert.
|
||||||
|
store.push({
|
||||||
|
documentId: v.documentId,
|
||||||
|
createdAt: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
}
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
raw: { affectedRows: added ? 1 : 0 },
|
raw: { affectedRows: added ? 1 : 0 },
|
||||||
});
|
});
|
||||||
@@ -33,15 +40,25 @@ function createQueueRepoMock() {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
find: jest.fn(() =>
|
find: jest.fn(() => {
|
||||||
Promise.resolve(
|
// Simuliert den DB-seitigen Alters-Filter (createdAt <= NOW() - 5000 ms).
|
||||||
store.length
|
const eligible = [...store]
|
||||||
? [{ documentId: store[0], action: null, createdAt: new Date() }]
|
.filter((s) => Date.now() - s.createdAt.getTime() >= 5000)
|
||||||
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
|
return Promise.resolve(
|
||||||
|
eligible.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
documentId: eligible[0].documentId,
|
||||||
|
action: null,
|
||||||
|
createdAt: eligible[0].createdAt,
|
||||||
|
},
|
||||||
|
]
|
||||||
: [],
|
: [],
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
delete: jest.fn((criteria: { documentId: number }) => {
|
delete: jest.fn((criteria: { documentId: number }) => {
|
||||||
const idx = store.indexOf(criteria.documentId);
|
const idx = store.findIndex((s) => s.documentId === criteria.documentId);
|
||||||
if (idx >= 0) store.splice(idx, 1);
|
if (idx >= 0) store.splice(idx, 1);
|
||||||
return Promise.resolve({ affected: 1 });
|
return Promise.resolve({ affected: 1 });
|
||||||
}),
|
}),
|
||||||
@@ -75,7 +92,7 @@ describe('WebhookQueueService', () => {
|
|||||||
it('reiht jede ID nur einmal ein (Dedup)', async () => {
|
it('reiht jede ID nur einmal ein (Dedup)', async () => {
|
||||||
await service.enqueue(5);
|
await service.enqueue(5);
|
||||||
await service.enqueue(5);
|
await service.enqueue(5);
|
||||||
expect(queueRepo.store).toEqual([5]);
|
expect(queueRepo.store.map((s) => s.documentId)).toEqual([5]);
|
||||||
expect(await service.count()).toBe(1);
|
expect(await service.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,13 +105,15 @@ describe('WebhookQueueService', () => {
|
|||||||
expect(processor.processDocumentById).toHaveBeenCalledWith(1);
|
expect(processor.processDocumentById).toHaveBeenCalledWith(1);
|
||||||
expect(processor.processDocumentById).toHaveBeenCalledWith(2);
|
expect(processor.processDocumentById).toHaveBeenCalledWith(2);
|
||||||
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
|
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
|
||||||
expect(queueRepo.store).toEqual([]);
|
expect(queueRepo.store).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => {
|
it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => {
|
||||||
let containedWhileProcessing = true;
|
let containedWhileProcessing = true;
|
||||||
processor.processDocumentById.mockImplementation((id: number) => {
|
processor.processDocumentById.mockImplementation((id: number) => {
|
||||||
containedWhileProcessing = queueRepo.store.includes(id);
|
containedWhileProcessing = queueRepo.store.some(
|
||||||
|
(s) => s.documentId === id,
|
||||||
|
);
|
||||||
return Promise.resolve({ processed: true });
|
return Promise.resolve({ processed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +139,7 @@ describe('WebhookQueueService', () => {
|
|||||||
await service.processQueue();
|
await service.processQueue();
|
||||||
|
|
||||||
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
|
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
|
||||||
expect(queueRepo.store).toEqual([]);
|
expect(queueRepo.store).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => {
|
it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => {
|
||||||
@@ -147,4 +166,19 @@ describe('WebhookQueueService', () => {
|
|||||||
resolveFirst?.();
|
resolveFirst?.();
|
||||||
await firstRun;
|
await firstRun;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verarbeitet einen Eintrag erst nach der Mindest-Wartezeit', async () => {
|
||||||
|
// Frisch eingereihter Eintrag (Alter ~0) darf noch nicht verarbeitet werden.
|
||||||
|
queueRepo.store.push({ documentId: 99, createdAt: new Date() });
|
||||||
|
|
||||||
|
await service.processQueue();
|
||||||
|
expect(processor.processDocumentById).not.toHaveBeenCalled();
|
||||||
|
expect(queueRepo.store).toHaveLength(1);
|
||||||
|
|
||||||
|
// Nach Überschreiten der Mindest-Wartezeit (5000 ms) wird verarbeitet.
|
||||||
|
queueRepo.store[0].createdAt = new Date(Date.now() - 6000);
|
||||||
|
await service.processQueue();
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledWith(99);
|
||||||
|
expect(queueRepo.store).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Interval } from '@nestjs/schedule';
|
import { Interval } from '@nestjs/schedule';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Raw, Repository } from 'typeorm';
|
||||||
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
||||||
import { Setting } from '../database/entities/setting.entity';
|
import { Setting } from '../database/entities/setting.entity';
|
||||||
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
|
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
|
||||||
@@ -12,6 +12,11 @@ const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call';
|
|||||||
// Prüfintervall der Warteschlange (sehr kurz). Über ENV überschreibbar.
|
// Prüfintervall der Warteschlange (sehr kurz). Über ENV überschreibbar.
|
||||||
const QUEUE_INTERVAL_MS = Number(process.env.WEBHOOK_QUEUE_INTERVAL_MS) || 1000;
|
const QUEUE_INTERVAL_MS = Number(process.env.WEBHOOK_QUEUE_INTERVAL_MS) || 1000;
|
||||||
|
|
||||||
|
// Mindest-Verweildauer zwischen Einreihen und Verarbeitung. Ein Eintrag wird
|
||||||
|
// frühestens verarbeitet, wenn er so lange in der Warteschlange lag. Über ENV
|
||||||
|
// überschreibbar (Default 5000 ms).
|
||||||
|
const MIN_QUEUE_AGE_MS = Number(process.env.WEBHOOK_QUEUE_MIN_AGE_MS) || 5000;
|
||||||
|
|
||||||
interface WebhookStatusInfo {
|
interface WebhookStatusInfo {
|
||||||
documentId: number | null;
|
documentId: number | null;
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -34,6 +39,8 @@ interface WebhookStatusInfo {
|
|||||||
* erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer
|
* erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer
|
||||||
* Lauf folgt danach).
|
* Lauf folgt danach).
|
||||||
* - Es läuft immer nur eine Verarbeitung gleichzeitig (`isProcessing`-Guard).
|
* - Es läuft immer nur eine Verarbeitung gleichzeitig (`isProcessing`-Guard).
|
||||||
|
* - Zwischen Einreihen und Verarbeitung liegen mindestens `MIN_QUEUE_AGE_MS`
|
||||||
|
* (Default 5000 ms); jüngere Einträge warten bis zum nächsten Tick.
|
||||||
*
|
*
|
||||||
* Da die Warteschlange in der Datenbank liegt, überstehen ausstehende IDs einen
|
* Da die Warteschlange in der Datenbank liegt, überstehen ausstehende IDs einen
|
||||||
* Neustart und werden nach dem Boot weiterverarbeitet.
|
* Neustart und werden nach dem Boot weiterverarbeitet.
|
||||||
@@ -92,15 +99,25 @@ export class WebhookQueueService {
|
|||||||
if (this.isProcessing) return;
|
if (this.isProcessing) return;
|
||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
try {
|
try {
|
||||||
// Solange Einträge vorhanden sind, einzeln und sequenziell abarbeiten.
|
// Solange (alte genug) Einträge vorhanden sind, sequenziell abarbeiten.
|
||||||
for (;;) {
|
for (;;) {
|
||||||
// Ältesten Eintrag (FIFO) holen ...
|
// Ältesten Eintrag (FIFO) holen, der die Mindest-Wartezeit erfüllt.
|
||||||
|
// Der Altersvergleich läuft bewusst in der DB (NOW() vs. createdAt) und
|
||||||
|
// ist damit unabhängig von Zeitzone/Uhr des Node-Prozesses – ein Node/DB-
|
||||||
|
// Zeitversatz würde sonst den Vergleich verfälschen (Einträge nie "alt
|
||||||
|
// genug"). Nur ein numerisches Konstantenliteral wird interpoliert.
|
||||||
const [next] = await this.queueRepo.find({
|
const [next] = await this.queueRepo.find({
|
||||||
|
where: {
|
||||||
|
createdAt: Raw(
|
||||||
|
(alias) =>
|
||||||
|
`${alias} <= (NOW(6) - INTERVAL ${MIN_QUEUE_AGE_MS * 1000} MICROSECOND)`,
|
||||||
|
),
|
||||||
|
},
|
||||||
order: { createdAt: 'ASC' },
|
order: { createdAt: 'ASC' },
|
||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
if (!next) break;
|
if (!next) break; // Warteschlange leer oder noch nichts alt genug
|
||||||
// ... und SOFORT (vor Verarbeitungsstart) aus der Tabelle entfernen.
|
// SOFORT (vor Verarbeitungsstart) aus der Tabelle entfernen.
|
||||||
await this.queueRepo.delete({ documentId: next.documentId });
|
await this.queueRepo.delete({ documentId: next.documentId });
|
||||||
await this.handleDocument(next.documentId);
|
await this.handleDocument(next.documentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { saveReturnUrl } from './auth/sessionRedirect';
|
|||||||
import { ThemeProvider, useTheme } from './theme/ThemeContext';
|
import { ThemeProvider, useTheme } from './theme/ThemeContext';
|
||||||
import AuthCallback from './auth/AuthCallback';
|
import AuthCallback from './auth/AuthCallback';
|
||||||
import AppLayout from './layouts/AppLayout';
|
import AppLayout from './layouts/AppLayout';
|
||||||
|
import AppErrorBoundary from './components/AppErrorBoundary';
|
||||||
import { Spin, Result, Button } from 'antd';
|
import { Spin, Result, Button } from 'antd';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ function ThemedApp() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AppErrorBoundary>
|
||||||
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}><Spin size="large" /></div>}>
|
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}><Spin size="large" /></div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -143,6 +145,7 @@ function ThemedApp() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</AppErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erkennt fehlgeschlagene Lazy-Chunk-Imports:
|
||||||
|
// Safari: "Importing a module script failed."
|
||||||
|
// Chrome: "Failed to fetch dynamically imported module: …"
|
||||||
|
// Firefox: "error loading dynamically imported module"
|
||||||
|
function istChunkLadefehler(error: Error): boolean {
|
||||||
|
return /module script failed|dynamically imported module/i.test(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fängt Renderfehler ab, die sonst den kompletten React-Baum aushängen und
|
||||||
|
* nur eine weiße Seite hinterlassen — insbesondere fehlgeschlagene
|
||||||
|
* React.lazy-Chunk-Imports nach einem Deployment oder bei beschädigtem
|
||||||
|
* Browser-Cache (Assets werden mit "immutable" gecacht und nie revalidiert).
|
||||||
|
*/
|
||||||
|
export default class AppErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
console.error('AppErrorBoundary:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Die Seite konnte nicht geladen werden"
|
||||||
|
subTitle={
|
||||||
|
istChunkLadefehler(error)
|
||||||
|
? 'Ein Teil der Anwendung konnte nicht nachgeladen werden — vermutlich wurde die ' +
|
||||||
|
'Anwendung aktualisiert oder eine zwischengespeicherte Datei ist beschädigt. ' +
|
||||||
|
'Bitte neu laden. Hilft das nicht, den Browser-Cache leeren ' +
|
||||||
|
'(Safari: Verlauf → Websitedaten löschen).'
|
||||||
|
: error.message
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
Seite neu laden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,28 +294,51 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (isNext: boolean = false) => {
|
const runSave = async (values: any, isNext: boolean) => {
|
||||||
try {
|
// Kontonummer-Logik (unverändert): neue Kontonummer ggf. bestätigen lassen.
|
||||||
const values = await form.validateFields();
|
|
||||||
|
|
||||||
// Kontonummer Logic
|
|
||||||
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
|
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
|
||||||
if (kontonummerReq && values[`cf_5`] && values.correspondent) {
|
if (kontonummerReq && values['cf_5'] && values.correspondent) {
|
||||||
const kNummer = values[`cf_5`];
|
const kNummer = values['cf_5'];
|
||||||
const knData = await posteingangApi.getKontonummern(values.correspondent);
|
const knData = await posteingangApi.getKontonummern(values.correspondent);
|
||||||
const exists = knData.some(k => k.Nummer === kNummer);
|
const exists = knData.some(k => k.Nummer === kNummer);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
// Prompt user to save kontonummer
|
// Prompt user to save kontonummer
|
||||||
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
|
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
|
||||||
return; // Stop saving, wait for confirmation
|
return; // Stop saving, wait for confirmation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleSaveDocument(values, isNext);
|
await handleSaveDocument(values, isNext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (isNext: boolean = false) => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await runSave(values, isNext);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Validation failed
|
const errorFields =
|
||||||
|
(e as { errorFields?: { name: unknown; errors: string[] }[] })?.errorFields ?? [];
|
||||||
|
|
||||||
|
// Kein Validierungsfehler, oder Posteingang → bisheriges Verhalten (stiller Abbruch).
|
||||||
|
if (errorFields.length === 0 || isPosteingang) {
|
||||||
console.error("Form validation failed:", e);
|
console.error("Form validation failed:", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Manuell bearbeiten": Speichern trotz fehlender Pflichtfelder mit Hinweis anbieten.
|
||||||
|
const missing = Array.from(new Set(errorFields.flatMap((f) => f.errors)));
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Pflichtfelder unvollständig',
|
||||||
|
okText: 'Trotzdem speichern',
|
||||||
|
cancelText: 'Abbrechen',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>Nicht alle Pflichtfelder sind ausgefüllt:</p>
|
||||||
|
<ul>{missing.map((m, i) => <li key={i}>{m}</li>)}</ul>
|
||||||
|
<p>Möchtest du trotzdem speichern?</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onOk: () => runSave(form.getFieldsValue(), isNext),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
// Nach einem Deployment existieren die alten, inhalts-gehashten Chunks nicht
|
||||||
|
// mehr; zudem bleibt ein einmal fehlerhaft zwischengespeicherter Chunk wegen
|
||||||
|
// "Cache-Control: immutable" dauerhaft defekt. Vite meldet fehlgeschlagene
|
||||||
|
// dynamische Imports als 'vite:preloadError' — dann automatisch neu laden,
|
||||||
|
// damit der Browser die aktuelle index.html samt gültiger Chunk-Namen holt.
|
||||||
|
// Höchstens ein Versuch pro Minute, sonst droht eine Reload-Schleife; danach
|
||||||
|
// zeigt der AppErrorBoundary eine sichtbare Fehlermeldung.
|
||||||
|
window.addEventListener('vite:preloadError', (event) => {
|
||||||
|
const KEY = 'vite-preload-error-reload';
|
||||||
|
const letzterVersuch = Number(sessionStorage.getItem(KEY) ?? 0);
|
||||||
|
if (Date.now() - letzterVersuch < 60_000) return;
|
||||||
|
sessionStorage.setItem(KEY, String(Date.now()));
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
Reference in New Issue
Block a user