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 260 additions and 8 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 type { Response } from 'express';
import { EmailImportService } from './email-import.service'; import { EmailImportService } from './email-import.service';
import { EmailPageCacheService } from './email-page-cache.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 { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -24,6 +29,7 @@ export class EmailImportController {
constructor( constructor(
private readonly importService: EmailImportService, private readonly importService: EmailImportService,
private readonly pageCache: EmailPageCacheService, private readonly pageCache: EmailPageCacheService,
private readonly paperlessService: PaperlessService,
) {} ) {}
// --- Korrespondenten Mapping --- // --- Korrespondenten Mapping ---
@@ -118,6 +124,51 @@ export class EmailImportController {
return { isDuplicate }; 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 --- // --- Print Preview ---
@Post('attachments/:attachmentId/print-preview') @Post('attachments/:attachmentId/print-preview')
@RequirePermissions(Permission.VIEW_MAIL) @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 --- // --- Print Preview ---
async generatePrintPdf( /**
* PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999`
* = bis zum Ende) – ohne Stempel.
*/
async getAttachmentPdf(
attachmentId: number, attachmentId: number,
barcodeData: any, pages?: { start: number; end: number },
): Promise<Buffer> { ): Promise<Buffer> {
const content = await this.contentRepo.findOne({ const content = await this.contentRepo.findOne({
where: { AttachmentEntityId: attachmentId }, where: { AttachmentEntityId: attachmentId },
@@ -358,12 +362,9 @@ export class EmailImportService {
if (!content) if (!content)
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); 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 = const pdfDoc = await PDFDocument.load(content.Content1, {
barcodeData._pages;
if (pages) {
const pdfDoc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true, ignoreEncryption: true,
}); });
const total = pdfDoc.getPageCount(); const total = pdfDoc.getPageCount();
@@ -376,10 +377,18 @@ export class EmailImportService {
); );
const copied = await sliced.copyPages(pdfDoc, indices); const copied = await sliced.copyPages(pdfDoc, indices);
copied.forEach((p) => sliced.addPage(p)); copied.forEach((p) => sliced.addPage(p));
pdfBytes = Buffer.from(await sliced.save()); return Buffer.from(await sliced.save());
} }
async generatePrintPdf(
attachmentId: number,
barcodeData: any,
): Promise<Buffer> {
const { _pages, ...barcode } = barcodeData; const { _pages, ...barcode } = barcodeData;
const pdfBytes = await this.getAttachmentPdf(
attachmentId,
_pages as { start: number; end: number } | undefined,
);
return this.applyBarcodeToPdf(pdfBytes, barcode); 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 { UserSettingsService } from '../user-settings/user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
import { PaperlessService } from '../paperless/paperless.service';
import {
kennzahlenAusPdf,
type VergleichKennzahlen,
} from '../paperless/vergleich-kennzahlen.util';
@Controller('api/inbox') @Controller('api/inbox')
@RequirePermissions(Permission.VIEW_SCANNER) @RequirePermissions(Permission.VIEW_SCANNER)
@@ -30,6 +35,7 @@ export class InboxController {
private readonly postprocessor: InboxPostprocessorService, private readonly postprocessor: InboxPostprocessorService,
private readonly barcodeScanner: BarcodeScannerService, private readonly barcodeScanner: BarcodeScannerService,
private readonly userSettingsService: UserSettingsService, private readonly userSettingsService: UserSettingsService,
private readonly paperlessService: PaperlessService,
) {} ) {}
@Get() @Get()
@@ -250,6 +256,29 @@ export class InboxController {
return new StreamableFile(Readable.from(buffer)); 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') @Post(':id/send-email')
@HttpCode(204) @HttpCode(204)
async sendEmail( async sendEmail(
@@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module';
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module'; import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module'; import { PostprocessingModule } from '../postprocessing/postprocessing.module';
import { UserSettingsModule } from '../user-settings/user-settings.module'; import { UserSettingsModule } from '../user-settings/user-settings.module';
import { PaperlessModule } from '../paperless/paperless.module';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
InboxPostprocessorModule, InboxPostprocessorModule,
PostprocessingModule, PostprocessingModule,
UserSettingsModule, UserSettingsModule,
PaperlessModule,
], ],
controllers: [InboxController, ClientsController], controllers: [InboxController, ClientsController],
providers: [InboxService, InboxMigrationService], providers: [InboxService, InboxMigrationService],
@@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from 'axios';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import FormData = require('form-data'); import FormData = require('form-data');
import { type Kennzahlen, zaehleWoerter } from './vergleich-kennzahlen.util';
import { import {
type BelegnummerPruefung, type BelegnummerPruefung,
belegnummerZuAsn, belegnummerZuAsn,
@@ -160,6 +161,18 @@ export class PaperlessService {
return response.data; 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[]> { async getInboxDocuments(): Promise<any[]> {
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app) // API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
const response = await this.client.get('/documents/', { 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 api from './client';
import type { VergleichKennzahlen } from './paperless';
export interface CorrespondentMapping { export interface CorrespondentMapping {
Id: number; Id: number;
@@ -50,6 +51,23 @@ export const emailImportApi = {
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number }); 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> => { 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 }, { const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
responseType: 'blob', responseType: 'blob',
+6
View File
@@ -1,5 +1,6 @@
import api from './client'; import api from './client';
import type { BarcodeActionType } from './barcode-templates'; import type { BarcodeActionType } from './barcode-templates';
import type { VergleichKennzahlen } from './paperless';
export type InboxSource = 'all' | 'user'; export type InboxSource = 'all' | 'user';
@@ -107,6 +108,11 @@ export const inboxApi = {
) )
.then((r) => r.data), .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[]) => downloadSegmentBlob: (id: string, pages: number[]) =>
api api
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' }) .post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
+11
View File
@@ -9,6 +9,17 @@ export interface BelegnummerPruefung {
kandidaten: number[]; 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 { export interface PaperlessTag {
id: number; id: number;
slug: string; slug: string;