feat(paperless): Belegnummer gegen Paperless prüfen (CF7 + ASN)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-02 13:53:50 +01:00
1 parent da02baa2b9
commit 57a460ffa2
5 files changed
+187

No files matched your search

@@ -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],
});
});
});
@@ -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 };
}
@@ -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,
@@ -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<string, unknown>,
): Promise<PaperlessTrefferDokument[]> {
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<number[]> {
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<number[]> {
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<BelegnummerPruefung> {
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);
}
}
+11
View File
@@ -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<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
pruefeBelegnummer: (belegnummer: string) =>
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
};