From 57a460ffa276bef9ecdf23724e61291220fd3246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 2 Nov 2026 13:53:50 +0100 Subject: [PATCH] =?UTF-8?q?feat(paperless):=20Belegnummer=20gegen=20Paperl?= =?UTF-8?q?ess=20pr=C3=BCfen=20(CF7=20+=20ASN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../belegnummer-pruefung.util.spec.ts | 57 ++++++++++++++ .../paperless/belegnummer-pruefung.util.ts | 35 +++++++++ .../src/paperless/paperless.controller.ts | 7 ++ .../src/paperless/paperless.service.ts | 77 +++++++++++++++++++ paperless-frontend/src/api/paperless.ts | 11 +++ 5 files changed, 187 insertions(+) create mode 100644 paperless-backend/src/paperless/belegnummer-pruefung.util.spec.ts create mode 100644 paperless-backend/src/paperless/belegnummer-pruefung.util.ts diff --git a/paperless-backend/src/paperless/belegnummer-pruefung.util.spec.ts b/paperless-backend/src/paperless/belegnummer-pruefung.util.spec.ts new file mode 100644 index 0000000..086ddb9 --- /dev/null +++ b/paperless-backend/src/paperless/belegnummer-pruefung.util.spec.ts @@ -0,0 +1,57 @@ +import { + belegnummerZuAsn, + klassifiziereTreffer, +} from './belegnummer-pruefung.util'; + +describe('belegnummerZuAsn', () => { + it('entfernt Bindestriche und liefert die Zahl', () => { + expect(belegnummerZuAsn('2026-000123')).toBe(2026000123); + }); + + it('liefert null für leere oder nicht numerische Belegnummern', () => { + expect(belegnummerZuAsn('')).toBeNull(); + expect(belegnummerZuAsn('abc')).toBeNull(); + }); +}); + +describe('klassifiziereTreffer', () => { + it('ohne Treffer ist die Belegnummer frei', () => { + expect(klassifiziereTreffer([], [])).toEqual({ + status: 'frei', + documentId: null, + kandidaten: [], + }); + }); + + it('CF7 und ASN auf demselben Dokument sind eindeutig', () => { + expect(klassifiziereTreffer([42], [42])).toEqual({ + status: 'eindeutig', + documentId: 42, + kandidaten: [42], + }); + }); + + it('Stamm und Version (gleiche Stamm-ID) zählen als ein Dokument', () => { + expect(klassifiziereTreffer([42, 42], [])).toEqual({ + status: 'eindeutig', + documentId: 42, + kandidaten: [42], + }); + }); + + it('verschiedene Dokumente sind mehrdeutig', () => { + expect(klassifiziereTreffer([42], [7])).toEqual({ + status: 'mehrdeutig', + documentId: null, + kandidaten: [7, 42], + }); + }); + + it('ein Treffer nur über die ASN ist eindeutig', () => { + expect(klassifiziereTreffer([], [7])).toEqual({ + status: 'eindeutig', + documentId: 7, + kandidaten: [7], + }); + }); +}); diff --git a/paperless-backend/src/paperless/belegnummer-pruefung.util.ts b/paperless-backend/src/paperless/belegnummer-pruefung.util.ts new file mode 100644 index 0000000..84183d9 --- /dev/null +++ b/paperless-backend/src/paperless/belegnummer-pruefung.util.ts @@ -0,0 +1,35 @@ +export type BelegnummerStatus = 'frei' | 'eindeutig' | 'mehrdeutig'; + +export interface BelegnummerPruefung { + status: BelegnummerStatus; + /** Stammdokument, an das eine neue Version gehängt werden kann (nur bei „eindeutig“) */ + documentId: number | null; + /** Alle gefundenen Stammdokumente, aufsteigend */ + kandidaten: number[]; +} + +/** ASN aus der internen Belegnummer: `2026-000123` → `2026000123`. */ +export function belegnummerZuAsn(belegnummer: string): number | null { + const ziffern = (belegnummer ?? '').replace(/-/g, '').trim(); + if (!ziffern) return null; + const asn = parseInt(ziffern, 10); + return Number.isNaN(asn) ? null : asn; +} + +/** + * Führt die Treffer der CF7- und ASN-Suche (bereits auf Stammdokumente + * normalisiert) zusammen. Nur genau ein Stammdokument ist „eindeutig“. + */ +export function klassifiziereTreffer( + cf7RootIds: number[], + asnRootIds: number[], +): BelegnummerPruefung { + const kandidaten = [...new Set([...cf7RootIds, ...asnRootIds])].sort( + (a, b) => a - b, + ); + if (kandidaten.length === 0) + return { status: 'frei', documentId: null, kandidaten }; + if (kandidaten.length === 1) + return { status: 'eindeutig', documentId: kandidaten[0], kandidaten }; + return { status: 'mehrdeutig', documentId: null, kandidaten }; +} diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index c794292..d62d796 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -81,6 +81,13 @@ export class PaperlessController { return { exists }; } + /** Ist die interne Belegnummer in Paperless schon vergeben (CF7 + ASN)? */ + @Get('belegnummer-pruefung') + @RequirePermissions(Permission.VIEW_MAIL) + async pruefeBelegnummer(@Query('belegnummer') belegnummer: string) { + return this.paperlessService.pruefeBelegnummer(belegnummer ?? ''); + } + @Get('documents') async getDocuments( @Query('search') search?: string, diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index 783635d..bb484e8 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -4,6 +4,19 @@ import axios, { type AxiosInstance } from 'axios'; import * as fs from 'fs'; import * as path from 'path'; import FormData = require('form-data'); +import { + type BelegnummerPruefung, + belegnummerZuAsn, + klassifiziereTreffer, +} from './belegnummer-pruefung.util'; + +/** Ausschnitt eines Paperless-Dokuments, wie ihn die Belegnummer-Suche braucht. */ +interface PaperlessTrefferDokument { + id: number; + root_document?: number | null; + archive_serial_number?: number | null; + custom_fields?: { field: number; value: string | number | boolean | null }[]; +} export interface PaperlessTaskStatus { taskId: string; @@ -396,4 +409,68 @@ export class PaperlessService { ); return match ? Number(match.id) : null; } + + /** Stamm-ID eines Treffers – Versionen zeigen über `root_document` auf ihren Stamm. */ + private static rootId(doc: PaperlessTrefferDokument): number { + return Number(doc.root_document ?? doc.id); + } + + private async sucheDokumente( + params: Record, + ): Promise { + const response = await this.client.get<{ + results?: PaperlessTrefferDokument[]; + }>('/documents/', { + params: { ...params, page_size: 100, truncate_content: true }, + }); + return response.data.results ?? []; + } + + /** + * Alle Stammdokumente, deren Custom Field exakt (case-insensitiv) den Wert hat. + */ + async findRootDocumentIdsByCustomField( + fieldId: number, + value: string, + ): Promise { + const treffer = await this.sucheDokumente({ + custom_field_query: JSON.stringify([fieldId, 'icontains', value]), + }); + const valueLower = value.toLowerCase(); + return treffer + .filter((doc) => + (doc.custom_fields ?? []).some( + (cf) => + cf.field === fieldId && + String(cf.value ?? '').toLowerCase() === valueLower, + ), + ) + .map((doc) => PaperlessService.rootId(doc)); + } + + /** Alle Stammdokumente mit genau dieser ASN. */ + async findRootDocumentIdsByAsn(asn: number): Promise { + if (!Number.isFinite(asn)) return []; + const treffer = await this.sucheDokumente({ archive_serial_number: asn }); + return treffer + .filter((doc) => Number(doc.archive_serial_number) === asn) + .map((doc) => PaperlessService.rootId(doc)); + } + + /** + * Prüft, ob die interne Belegnummer in Paperless schon vergeben ist – über + * CF7 und die daraus abgeleitete ASN. + */ + async pruefeBelegnummer(belegnummer: string): Promise { + const nummer = (belegnummer ?? '').trim(); + if (!nummer) return klassifiziereTreffer([], []); + const asn = belegnummerZuAsn(nummer); + const [cf7, asnTreffer] = await Promise.all([ + this.findRootDocumentIdsByCustomField(7, nummer), + asn !== null + ? this.findRootDocumentIdsByAsn(asn) + : Promise.resolve([] as number[]), + ]); + return klassifiziereTreffer(cf7, asnTreffer); + } } diff --git a/paperless-frontend/src/api/paperless.ts b/paperless-frontend/src/api/paperless.ts index 62cd1a9..7e239d5 100644 --- a/paperless-frontend/src/api/paperless.ts +++ b/paperless-frontend/src/api/paperless.ts @@ -1,5 +1,14 @@ import api from './client'; +export type BelegnummerStatus = 'frei' | 'eindeutig' | 'mehrdeutig'; + +export interface BelegnummerPruefung { + status: BelegnummerStatus; + /** Stammdokument für eine neue Version (nur bei „eindeutig“) */ + documentId: number | null; + kandidaten: number[]; +} + export interface PaperlessTag { id: number; slug: string; @@ -62,4 +71,6 @@ export const paperlessApi = { api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists), getDocumentPdfBlob: (id: number) => api.get(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data), + pruefeBelegnummer: (belegnummer: string) => + api.get('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data), };