feat: Duplikate im Import als neue Version behandeln #6

Merged
bjoernpoettker merged 12 commits from feat/duplikat-neue-version into main 2026-11-02 13:31:04 +00:00
10 changed files with 273 additions and 21 deletions
Showing only changes of commit 8e0c609802 - Show all commits

No files matched your search

@@ -14,6 +14,11 @@ import {
import type { Response } from 'express';
import { EmailImportService } from './email-import.service';
import { EmailPageCacheService } from './email-page-cache.service';
import { PaperlessService } from '../paperless/paperless.service';
import {
kennzahlenAusPdf,
type VergleichKennzahlen,
} from '../paperless/vergleich-kennzahlen.util';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@@ -24,6 +29,7 @@ export class EmailImportController {
constructor(
private readonly importService: EmailImportService,
private readonly pageCache: EmailPageCacheService,
private readonly paperlessService: PaperlessService,
) {}
// --- Korrespondenten Mapping ---
@@ -118,6 +124,51 @@ export class EmailImportController {
return { isDuplicate };
}
// --- Duplikatvergleich ---
/** PDF eines Anhangs bzw. Seitenbereichs ohne Stempel (rechte Seite im Vergleich). */
@Get('attachments/:attachmentId/pdf')
@RequirePermissions(Permission.VIEW_MAIL)
async getAttachmentPdf(
@Param('attachmentId') attachmentId: string,
@Query('start') start: string | undefined,
@Query('end') end: string | undefined,
@Res() res: Response,
) {
const pdf = await this.importService.getAttachmentPdf(
parseInt(attachmentId, 10),
seitenbereich(start, end),
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`inline; filename="anhang-${attachmentId}.pdf"`,
);
res.send(pdf);
}
@Post('attachments/:attachmentId/vergleich')
@RequirePermissions(Permission.VIEW_MAIL)
async vergleich(
@Param('attachmentId') attachmentId: string,
@Body()
body: {
paperlessDocumentId: number;
pages?: { start: number; end: number };
},
): Promise<VergleichKennzahlen> {
const pdf = await this.importService.getAttachmentPdf(
parseInt(attachmentId, 10),
body.pages,
);
const [vorhanden, neu] = await Promise.all([
this.paperlessService.getDocumentKennzahlen(
Number(body.paperlessDocumentId),
),
kennzahlenAusPdf(pdf),
]);
return { vorhanden, neu };
}
// --- Print Preview ---
@Post('attachments/:attachmentId/print-preview')
@RequirePermissions(Permission.VIEW_MAIL)
@@ -202,3 +253,12 @@ export class EmailImportController {
}
}
}
function seitenbereich(
start: string | undefined,
end: string | undefined,
): { start: number; end: number } | undefined {
const s = parseInt(start ?? '', 10);
const e = parseInt(end ?? '', 10);
return Number.isNaN(s) || Number.isNaN(e) ? undefined : { start: s, end: e };
}
@@ -348,9 +348,13 @@ export class EmailImportService {
}
// --- Print Preview ---
async generatePrintPdf(
/**
* PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999`
* = bis zum Ende) – ohne Stempel.
*/
async getAttachmentPdf(
attachmentId: number,
barcodeData: any,
pages?: { start: number; end: number },
): Promise<Buffer> {
const content = await this.contentRepo.findOne({
where: { AttachmentEntityId: attachmentId },
@@ -358,28 +362,33 @@ export class EmailImportService {
if (!content)
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
let pdfBytes: Buffer = content.Content1;
if (!pages) return content.Content1;
const pages: { start: number; end: number } | undefined =
barcodeData._pages;
if (pages) {
const pdfDoc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
});
const total = pdfDoc.getPageCount();
const startIdx = Math.max(1, pages.start) - 1;
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
const sliced = await PDFDocument.create();
const indices = Array.from(
{ length: endIdx - startIdx + 1 },
(_, i) => startIdx + i,
);
const copied = await sliced.copyPages(pdfDoc, indices);
copied.forEach((p) => sliced.addPage(p));
pdfBytes = Buffer.from(await sliced.save());
}
const pdfDoc = await PDFDocument.load(content.Content1, {
ignoreEncryption: true,
});
const total = pdfDoc.getPageCount();
const startIdx = Math.max(1, pages.start) - 1;
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
const sliced = await PDFDocument.create();
const indices = Array.from(
{ length: endIdx - startIdx + 1 },
(_, i) => startIdx + i,
);
const copied = await sliced.copyPages(pdfDoc, indices);
copied.forEach((p) => sliced.addPage(p));
return Buffer.from(await sliced.save());
}
async generatePrintPdf(
attachmentId: number,
barcodeData: any,
): Promise<Buffer> {
const { _pages, ...barcode } = barcodeData;
const pdfBytes = await this.getAttachmentPdf(
attachmentId,
_pages as { start: number; end: number } | undefined,
);
return this.applyBarcodeToPdf(pdfBytes, barcode);
}
@@ -21,6 +21,11 @@ import { BarcodeScannerService } from '../barcode/barcode-scanner.service';
import { UserSettingsService } from '../user-settings/user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { PaperlessService } from '../paperless/paperless.service';
import {
kennzahlenAusPdf,
type VergleichKennzahlen,
} from '../paperless/vergleich-kennzahlen.util';
@Controller('api/inbox')
@RequirePermissions(Permission.VIEW_SCANNER)
@@ -30,6 +35,7 @@ export class InboxController {
private readonly postprocessor: InboxPostprocessorService,
private readonly barcodeScanner: BarcodeScannerService,
private readonly userSettingsService: UserSettingsService,
private readonly paperlessService: PaperlessService,
) {}
@Get()
@@ -250,6 +256,29 @@ export class InboxController {
return new StreamableFile(Readable.from(buffer));
}
/** Seiten-/Wortzahl des Abschnitts und des vorhandenen Paperless-Dokuments. */
@Post(':id/vergleich')
async vergleich(
@Param('id') id: string,
@Body() body: { paperlessDocumentId: number; pages: number[] },
@Request() req: any,
): Promise<VergleichKennzahlen> {
const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
const { buffer } = await this.inboxService.getSegmentPdfBuffer(
id,
preferredUsername,
body.pages ?? [],
);
const [vorhanden, neu] = await Promise.all([
this.paperlessService.getDocumentKennzahlen(
Number(body.paperlessDocumentId),
),
kennzahlenAusPdf(buffer),
]);
return { vorhanden, neu };
}
@Post(':id/send-email')
@HttpCode(204)
async sendEmail(
@@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module';
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
import { UserSettingsModule } from '../user-settings/user-settings.module';
import { PaperlessModule } from '../paperless/paperless.module';
@Module({
imports: [
@@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
InboxPostprocessorModule,
PostprocessingModule,
UserSettingsModule,
PaperlessModule,
],
controllers: [InboxController, ClientsController],
providers: [InboxService, InboxMigrationService],
@@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import FormData = require('form-data');
import { type Kennzahlen, zaehleWoerter } from './vergleich-kennzahlen.util';
import {
type BelegnummerPruefung,
belegnummerZuAsn,
@@ -160,6 +161,18 @@ export class PaperlessService {
return response.data;
}
/** Seiten- und Wortzahl eines Paperless-Dokuments (aktuelle Fassung). */
async getDocumentKennzahlen(id: number): Promise<Kennzahlen> {
const response = await this.client.get<{
page_count?: number | null;
content?: string | null;
}>(`/documents/${id}/`);
return {
seiten: response.data.page_count ?? null,
woerter: zaehleWoerter(response.data.content),
};
}
async getInboxDocuments(): Promise<any[]> {
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
const response = await this.client.get('/documents/', {
@@ -0,0 +1,48 @@
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { kennzahlenAusPdf, zaehleWoerter } from './vergleich-kennzahlen.util';
describe('zaehleWoerter', () => {
it('zählt nur Wörter mit Buchstaben oder Ziffern', () => {
// „–“ und „€“ zählen nicht
expect(zaehleWoerter('Rechnung Nr. 123 – Betrag 4,50 €')).toBe(5);
});
it('ignoriert Seitenmarker von pdf-parse', () => {
expect(zaehleWoerter('-- 1 of 2 --')).toBeNull();
});
it('liefert null für leeren oder fehlenden Text', () => {
expect(zaehleWoerter('')).toBeNull();
expect(zaehleWoerter(null)).toBeNull();
});
});
describe('kennzahlenAusPdf', () => {
it('ermittelt Seiten und Wörter eines PDFs mit Textebene', async () => {
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
pdf.addPage().drawText('Hallo Welt', { x: 50, y: 700, font, size: 14 });
pdf.addPage();
const buffer = Buffer.from(await pdf.save());
expect(await kennzahlenAusPdf(buffer)).toEqual({ seiten: 2, woerter: 2 });
});
it('meldet bei PDFs ohne Text die Wortzahl als nicht ermittelbar', async () => {
const pdf = await PDFDocument.create();
pdf.addPage();
const buffer = Buffer.from(await pdf.save());
expect(await kennzahlenAusPdf(buffer)).toEqual({
seiten: 1,
woerter: null,
});
});
it('liefert für unlesbare Dateien keine Kennzahlen', async () => {
expect(await kennzahlenAusPdf(Buffer.from('kaputt'))).toEqual({
seiten: null,
woerter: null,
});
});
});
@@ -0,0 +1,56 @@
import { PDFDocument } from 'pdf-lib';
import { PDFParse } from 'pdf-parse';
/** Kennzahlen eines Belegs; `null` = nicht ermittelbar. */
export interface Kennzahlen {
seiten: number | null;
woerter: number | null;
}
/** Kennzahlen des vorhandenen (Paperless) und des neuen Belegs. */
export interface VergleichKennzahlen {
vorhanden: Kennzahlen;
neu: Kennzahlen;
}
/**
* Zählt Wörter: Tokens zwischen Leerraum, die mindestens einen Buchstaben oder
* eine Ziffer enthalten. Seitenmarker von pdf-parse („-- 1 of 2 --“) zählen
* nicht. Kein Wort → `null` (z. B. Scan ohne Textebene).
*/
export function zaehleWoerter(text: string | null | undefined): number | null {
if (!text) return null;
const ohneMarker = text.replace(/-- \d+ of \d+ --/g, ' ');
const anzahl = ohneMarker
.split(/\s+/)
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
return anzahl > 0 ? anzahl : null;
}
async function seitenzahl(pdf: Buffer): Promise<number | null> {
try {
const doc = await PDFDocument.load(pdf, { ignoreEncryption: true });
return doc.getPageCount();
} catch {
return null;
}
}
async function wortzahl(pdf: Buffer): Promise<number | null> {
// pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben
const parser = new PDFParse({ data: new Uint8Array(pdf) });
try {
const { text } = await parser.getText();
return zaehleWoerter(text);
} catch {
return null;
} finally {
await parser.destroy().catch(() => undefined);
}
}
/** Seiten- und Wortzahl eines PDFs; Lesefehler ergeben `null`. */
export async function kennzahlenAusPdf(pdf: Buffer): Promise<Kennzahlen> {
const [seiten, woerter] = await Promise.all([seitenzahl(pdf), wortzahl(pdf)]);
return { seiten, woerter };
}
@@ -1,4 +1,5 @@
import api from './client';
import type { VergleichKennzahlen } from './paperless';
export interface CorrespondentMapping {
Id: number;
@@ -50,6 +51,23 @@ export const emailImportApi = {
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
},
attachmentPdfBlob: async (attachmentId: number, pages?: { start: number; end: number }): Promise<Blob> => {
const res = await api.get(`/api/email-import/attachments/${attachmentId}/pdf`, {
params: pages ? { start: pages.start, end: pages.end } : undefined,
responseType: 'blob',
});
return res.data;
},
vergleich: async (
attachmentId: number,
paperlessDocumentId: number,
pages?: { start: number; end: number },
): Promise<VergleichKennzahlen> => {
const res = await api.post(`/api/email-import/attachments/${attachmentId}/vergleich`, { paperlessDocumentId, pages });
return res.data;
},
printPreview: async (attachmentId: number, barcodeData: any, pages?: { start: number; end: number }): Promise<Blob> => {
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
responseType: 'blob',
+6
View File
@@ -1,5 +1,6 @@
import api from './client';
import type { BarcodeActionType } from './barcode-templates';
import type { VergleichKennzahlen } from './paperless';
export type InboxSource = 'all' | 'user';
@@ -107,6 +108,11 @@ export const inboxApi = {
)
.then((r) => r.data),
vergleich: (id: string, paperlessDocumentId: number, pages: number[]) =>
api
.post<VergleichKennzahlen>(`/api/inbox/${encodeURIComponent(id)}/vergleich`, { paperlessDocumentId, pages })
.then((r) => r.data),
downloadSegmentBlob: (id: string, pages: number[]) =>
api
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
+11
View File
@@ -9,6 +9,17 @@ export interface BelegnummerPruefung {
kandidaten: number[];
}
/** Kennzahlen eines Belegs; `null` = nicht ermittelbar. */
export interface Kennzahlen {
seiten: number | null;
woerter: number | null;
}
export interface VergleichKennzahlen {
vorhanden: Kennzahlen;
neu: Kennzahlen;
}
export interface PaperlessTag {
id: number;
slug: string;