feat: Seiten- und Wortzahl für den Duplikatvergleich
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a460ffa2
commit
8e0c609802
10 files changed
+260
-8
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,12 +362,9 @@ 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, {
|
||||
const pdfDoc = await PDFDocument.load(content.Content1, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
const total = pdfDoc.getPageCount();
|
||||
@@ -376,10 +377,18 @@ export class EmailImportService {
|
||||
);
|
||||
const copied = await sliced.copyPages(pdfDoc, indices);
|
||||
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 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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user