feat(backend): Metadaten direkt beim Paperless-Upload übergeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 { 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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user