From beaa1be4a55439b96049c5ef01db9a27fdc5f0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 14 Jul 2026 22:46:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20Metadaten=20direkt=20beim=20Pa?= =?UTF-8?q?perless-Upload=20=C3=BCbergeben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E-Mail-Import und External-Upload setzen ASN, Custom Fields (CF3/CF7/CF9), Dokumenttyp, Tags, Owner und Belegdatum jetzt bereits beim post_document, statt erst nachträglich über den Task-Processor-Cron (30 s + PATCH). - Neu: deriveTaskMetadata() in task-metadata.util.ts als gemeinsame Ableitung (Task-Felder → Paperless-Metadaten) für beide Upload-Pfade und den Task-Processor – identische Parsing-Regeln, kein Copy-Paste - Task-Processor nutzt den Helper (verhaltensidentischer Refactor) und patcht weiterhin idempotent nach; zuständig bleibt er für alles ID-Abhängige (CF8-Verknüpfungen, Anlage-Titel, Owner-Entfernung, Notizen) - FAILURE-Zweig loggt Paperless-Fehlertext und Belegnummer vor dem Löschen des Tasks (ASN-Kollision kann jetzt den Consume scheitern lassen) - Tests: 18 Unit-Tests für deriveTaskMetadata, 3 Charakterisierungstests pinnen den PATCH-Payload von processSuccessfulTask Co-Authored-By: Claude Fable 5 --- .../src/email/email-import.service.ts | 27 ++- .../paperless-task-processor.service.spec.ts | 201 ++++++++++++++++++ .../paperless-task-processor.service.ts | 141 ++++-------- .../src/paperless/paperless.controller.ts | 20 +- .../src/paperless/task-metadata.util.spec.ts | 139 ++++++++++++ .../src/paperless/task-metadata.util.ts | 102 +++++++++ 6 files changed, 517 insertions(+), 113 deletions(-) create mode 100644 paperless-backend/src/paperless/paperless-task-processor.service.spec.ts create mode 100644 paperless-backend/src/paperless/task-metadata.util.spec.ts create mode 100644 paperless-backend/src/paperless/task-metadata.util.ts diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index 38b2b47..884282b 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -10,6 +10,7 @@ import { Content } from '../database/entities/content.entity'; import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity'; import { Task } from '../database/entities/task.entity'; import { PaperlessService } from '../paperless/paperless.service'; +import { deriveTaskMetadata } from '../paperless/task-metadata.util'; import * as QRCode from 'qrcode'; import { EmailPageCacheService } from './email-page-cache.service'; import { ImapFolderService } from './imap-folder.service'; @@ -557,13 +558,29 @@ export class EmailImportService { // Upload all generated PDFs 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 = { filename: uploadItem.filename, title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename, created: createdDate, - owner: null, + documentType: derived.documentType, + archiveSerialNumber: derived.archiveSerialNumber, + customFields: derived.customFields, }; if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId; @@ -577,15 +594,9 @@ export class EmailImportService { // Create background task for enrichment (same logic as Inbox) const backgroundTask = this.taskRepo.create({ TaskId: paperlessTaskId, - InterneBelegnummer: att.belegnummer || '', - Eingangsdatum: att.barcode?.datum - ? new Date(att.barcode.datum) - : createdDate, - Belegdatum: createdDate, + ...taskFields, BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null, - BetriebID: null, // Owner Fertig: 0, - DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage SourceAttachmentID: att.attachmentId, SourceAttachmentRange: uploadItem.rangeKey, }); diff --git a/paperless-backend/src/paperless/paperless-task-processor.service.spec.ts b/paperless-backend/src/paperless/paperless-task-processor.service.spec.ts new file mode 100644 index 0000000..04a0d3b --- /dev/null +++ b/paperless-backend/src/paperless/paperless-task-processor.service.spec.ts @@ -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 { + 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 { + return ( + service as unknown as { + processSuccessfulTask: ( + t: Task, + a: { related_document: number }, + p: Task | null, + ) => Promise; + } + ).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] }, + ]); + }); +}); diff --git a/paperless-backend/src/paperless/paperless-task-processor.service.ts b/paperless-backend/src/paperless/paperless-task-processor.service.ts index 81cc97c..26507db 100644 --- a/paperless-backend/src/paperless/paperless-task-processor.service.ts +++ b/paperless-backend/src/paperless/paperless-task-processor.service.ts @@ -6,6 +6,7 @@ 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'; +import { deriveTaskMetadata } from './task-metadata.util'; @Injectable() export class PaperlessTaskProcessorService { @@ -74,7 +75,12 @@ export class PaperlessTaskProcessorService { await this.processSuccessfulTask(t, apiResponseTask, parentTask); } } 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); } } else { @@ -200,104 +206,34 @@ export class PaperlessTaskProcessorService { : [], }; - // 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 */ - } + // Gemeinsame Ableitung – identische Regeln wie beim direkten Upload (task-metadata.util) + const derived = deriveTaskMetadata(t); + + for (const [k, v] of Object.entries(derived.customFields)) { + const fieldId = parseInt(k, 10); + 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 }); } - 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) { + if (derived.archiveSerialNumber !== undefined) { this.logger.log( - `[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`, + `[Postprocessing] Task ${t.TaskId} - Setze ASN: ${derived.archiveSerialNumber}`, ); - 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, + updateData.archive_serial_number = derived.archiveSerialNumber; + } else if (t.Asn || t.InterneBelegnummer) { + this.logger.warn( + `[Postprocessing] Task ${t.TaskId} - ASN konnte nicht abgeleitet werden (Asn: ${t.Asn ?? '-'}, InterneBelegnummer: ${t.InterneBelegnummer || '-'})`, ); - 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( - `[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`, + `[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${derived.documentType}`, ); - 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; + updateData.document_type = derived.documentType; } // Parent Task / Attachment logic @@ -350,36 +286,33 @@ export class PaperlessTaskProcessorService { } } - if (t.Belegdatum) { + if (derived.created) { 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( - `[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`, + `[Postprocessing] Task ${t.TaskId} - Setze Owner: ${derived.owner}`, ); - updateData.owner = t.BetriebID; } else { this.logger.log( `[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`, ); - updateData.owner = null; } + updateData.owner = derived.owner ?? null; - // Tags - if (t.Tags) { + // Tags: Upload-/Consume-Tags des Dokuments bleiben erhalten (Merge statt Ersetzen) + if (derived.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; + updateData.tags = Array.from( + new Set([...currentTags, ...derived.tags]), + ); } // Agrarmonitor Link (Skip API call for now, but save the link if needed) diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index e9cec6f..60bbe19 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -23,6 +23,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PaperlessService } from './paperless.service'; +import { deriveTaskMetadata } from './task-metadata.util'; import { ApiKeyGuard } from '../auth/api-key.guard'; import { UploadExternalDto } from './dto/upload-external.dto'; import { Task } from '../database/entities/task.entity'; @@ -514,11 +515,28 @@ export class PaperlessController { // 0. Check if ASN already exists 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( file.path, { title: `Beleg ${dto.interneBelegnummer}`, + created: derived.created, + documentType: derived.documentType, + owner: derived.owner, + tags: derived.tags, + archiveSerialNumber: derived.archiveSerialNumber, + customFields: derived.customFields, }, ); diff --git a/paperless-backend/src/paperless/task-metadata.util.spec.ts b/paperless-backend/src/paperless/task-metadata.util.spec.ts new file mode 100644 index 0000000..ba29e1c --- /dev/null +++ b/paperless-backend/src/paperless/task-metadata.util.spec.ts @@ -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: {} }); + }); + }); +}); diff --git a/paperless-backend/src/paperless/task-metadata.util.ts b/paperless-backend/src/paperless/task-metadata.util.ts new file mode 100644 index 0000000..5820fac --- /dev/null +++ b/paperless-backend/src/paperless/task-metadata.util.ts @@ -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; + 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; + 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; +}