feat(backend): Metadaten direkt beim Paperless-Upload übergeben
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:
2026-07-14 22:46:42 +02:00
co-authored by Claude Fable 5
parent 57c8964384
commit beaa1be4a5
6 changed files with 517 additions and 113 deletions
@@ -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] },
]);
});
});