Compare commits

...
10 Commits
Author SHA1 Message Date
bjoernpoettkerandClaude Opus 5.5 c1afa02ee2 fix: Duplikatprüfung immer gegen die ASN, die das Dokument beim Upload bekäme
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Im Posteingang kann das ASN-Feld der Aktion von der Belegnummer abweichen.
Prüfung, Versions-Upload und normaler Upload nutzen jetzt dieselbe ASN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:27:21 +01:00
bjoernpoettkerandClaude Opus 5.5 79dadb9c93 fix: Kennzahlen mit Timeout laden, ungültige Seitenbereiche als 400 melden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:22:44 +01:00
bjoernpoettkerandClaude Opus 5.5 32b5228f68 fix: Zieldokument prüfen, Versions-Verarbeitung abwarten, Abschnittsseiten vom Backend
- Neue Version nur, wenn die Belegnummer in Paperless eindeutig zu genau
  diesem Dokument gehört (serverseitig geprüft)
- Auf die Paperless-Task der Version warten; Ablehnung → Fehler, Tag 21 weg
- Entscheidung im Posteingang nur zusammen mit processOnlyOne
- Vergleichsdialog im Posteingang zeigt die Seiten, die tatsächlich
  hochgeladen werden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:14:01 +01:00
bjoernpoettkerandClaude Opus 5.5 98021fdcbf docs: offene Punkte zu Versionen und Task-Bearbeitung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:04:58 +01:00
bjoernpoettkerandClaude Opus 5.5 fffbf692b2 feat(frontend): Vergleichsdialog für Duplikate mit neuer Version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:04:42 +01:00
bjoernpoettkerandClaude Opus 5.5 21a447e915 feat(mail): Beleg beim Import als neue Version anhängen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:01:47 +01:00
bjoernpoettkerandClaude Opus 5.5 bdc85f5f1f feat(posteingang): Duplikat als neue Version oder verwerfen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:58:31 +01:00
bjoernpoettkerandClaude Opus 5.5 170eb72c70 feat(paperless): Belege als neue Version anhängen und mit Tag 21 markieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:57:02 +01:00
bjoernpoettkerandClaude Opus 5.5 8e0c609802 feat: Seiten- und Wortzahl für den Duplikatvergleich
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:55:51 +01:00
bjoernpoettkerandClaude Opus 5.5 57a460ffa2 feat(paperless): Belegnummer gegen Paperless prüfen (CF7 + ASN)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:53:50 +01:00
27 changed files with 1708 additions and 246 deletions

No files matched your search

@@ -166,8 +166,11 @@ Geprüft wird immer gegen Paperless, und zwar über **beide** Merkmale, die aus
der internen Belegnummer folgen: der internen Belegnummer folgen:
- CF7 = interne Belegnummer (z. B. `2026-000123`) - CF7 = interne Belegnummer (z. B. `2026-000123`)
- ASN = interne Belegnummer ohne Bindestrich (z. B. `2026000123`) – auch im - ASN = die ASN, die das Dokument beim Upload **tatsächlich bekäme**: im
Posteingang, unabhängig von einem im Template separat hinterlegten ASN-Feld. Posteingang aus dem ASN-Feld der Aktion, falls gesetzt, sonst aus der
Belegnummer ohne Bindestrich (z. B. `2026000123`); im E-Mail-Import immer aus
der Belegnummer. Prüfung, Versions-Upload und normaler Upload verwenden
dieselbe ASN.
„Als neue Version hinzufügen“ wird nur angeboten, wenn alle Treffer auf **genau „Als neue Version hinzufügen“ wird nur angeboten, wenn alle Treffer auf **genau
ein** Dokument zeigen. Zeigen CF7- und ASN-Treffer auf verschiedene Dokumente ein** Dokument zeigen. Zeigen CF7- und ASN-Treffer auf verschiedene Dokumente
+7
View File
@@ -6,3 +6,10 @@ Hier sind die Todos, die wir vorerst zurückgestellt haben:
- [ ] **Korrespondenten-Sync**: Kunden aus Agrarmonitor abfragen und automatisch Korrespondenten in Paperless anlegen, falls diese nicht existieren (basierend auf der Nummer). - [ ] **Korrespondenten-Sync**: Kunden aus Agrarmonitor abfragen und automatisch Korrespondenten in Paperless anlegen, falls diese nicht existieren (basierend auf der Nummer).
- [ ] **Erweiterte Titel-Logik**: Implementierung der spezifischen Präfixe (ERG, EGU, etc.) basierend auf dem Dokumententyp, falls die automatischen Titel-Templates von Paperless nicht ausreichen. - [ ] **Erweiterte Titel-Logik**: Implementierung der spezifischen Präfixe (ERG, EGU, etc.) basierend auf dem Dokumententyp, falls die automatischen Titel-Templates von Paperless nicht ausreichen.
- [ ] **Vollständige Kunden-Synchronisation**: Regelmäßiger Abgleich der Kundendaten zwischen Agrarmonitor und Paperless. - [ ] **Vollständige Kunden-Synchronisation**: Regelmäßiger Abgleich der Kundendaten zwischen Agrarmonitor und Paperless.
- [ ] **Checksummen-Duplikate gezielt umgehen**: Duplikatprüfung per Checksumme gezielt umgehen können, wenn ein bereits importiertes Dokument bewusst erneut importiert (z. B. geteilt) werden soll.
- [ ] **Neue Version eines Belegs in Agrarmonitor nachziehen** (derzeit über Tag 21 „neue Version hochgeladen“ von den Postprocessing-Regeln ausgenommen):
- Beleg noch im AM-Posteingang → dort löschen und neue Fassung senden.
- Beleg verarbeitet, aber nicht gebucht → im jeweiligen Einkauf das vorhandene Dokument löschen und die neue Fassung hochladen.
- Beleg bereits gebucht → Storno-Beleg zum gebuchten Beleg erstellen und die neue Fassung über die jeweilige E-Mail-Adresse neu in den AM-Posteingang senden.
- Außerdem klären, ob Postprocessing-Regeln (Mail an Buchhaltung, Export) für Versionen laufen sollen.
- [ ] **Task-Bearbeitung auf Webhook umstellen**: `PaperlessTaskProcessorService` (30-s-Cron) durch den Paperless-Webhook ersetzen. Beim Upload werden die Metadaten schon gesetzt; übrig bleiben Owner entfernen, Notizen, Anlage-Verknüpfung (Feld 8) und die lokale Buchführung (Task/Attachment/documents) – die brauchen die Dokument-ID nach dem Consume.
@@ -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 };
}
@@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common'; import { BadRequestException, ConflictException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
@@ -14,6 +14,7 @@ import { PdfService } from '../preprocessing/pdf.service';
import { EmailPageCacheService } from './email-page-cache.service'; import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service'; import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service'; import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { DokumentVersionService } from '../paperless/dokument-version.service';
async function pdfMitSeiten(anzahl: number): Promise<Buffer> { async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
const doc = await PDFDocument.create(); const doc = await PDFDocument.create();
@@ -29,11 +30,19 @@ describe('EmailImportService – Duplikatprüfung', () => {
andWhere: jest.Mock; andWhere: jest.Mock;
getCount: jest.Mock; getCount: jest.Mock;
}; };
let attachmentRepo: { findOne: jest.Mock }; let attachmentRepo: { findOne: jest.Mock; save: jest.Mock };
let taskRepo: {
createQueryBuilder: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};
let emailRepo: { update: jest.Mock; findOne: jest.Mock };
let dokumentVersion: { haengeVersionAn: jest.Mock };
let contentRepo: { findOne: jest.Mock }; let contentRepo: { findOne: jest.Mock };
let paperlessService: { let paperlessService: {
checksumExists: jest.Mock; checksumExists: jest.Mock;
uploadDocument: jest.Mock; uploadDocument: jest.Mock;
validateAsnNotExists: jest.Mock;
}; };
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock }; let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
@@ -50,11 +59,25 @@ describe('EmailImportService – Duplikatprüfung', () => {
Checksum: 'abc123', Checksum: 'abc123',
FileName: 'rechnung.pdf', FileName: 'rechnung.pdf',
}), }),
save: jest.fn().mockImplementation((a: unknown) => Promise.resolve(a)),
};
taskRepo = {
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
create: jest.fn().mockImplementation((t: unknown) => t),
save: jest.fn(),
};
emailRepo = {
update: jest.fn().mockResolvedValue({}),
findOne: jest.fn().mockResolvedValue(null),
};
dokumentVersion = {
haengeVersionAn: jest.fn().mockResolvedValue(undefined),
}; };
contentRepo = { findOne: jest.fn() }; contentRepo = { findOne: jest.fn() };
paperlessService = { paperlessService = {
checksumExists: jest.fn().mockResolvedValue(false), checksumExists: jest.fn().mockResolvedValue(false),
uploadDocument: jest.fn(), uploadDocument: jest.fn(),
validateAsnNotExists: jest.fn(),
}; };
fingerprints = { fingerprints = {
ensure: jest.fn().mockResolvedValue(null), ensure: jest.fn().mockResolvedValue(null),
@@ -65,24 +88,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
providers: [ providers: [
EmailImportService, EmailImportService,
{ provide: ConfigService, useValue: { get: jest.fn() } }, { provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: getRepositoryToken(Email), useValue: {} }, { provide: getRepositoryToken(Email), useValue: emailRepo },
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo }, { provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
{ provide: getRepositoryToken(Content), useValue: contentRepo }, { provide: getRepositoryToken(Content), useValue: contentRepo },
{ {
provide: getRepositoryToken(CorrespondentEmailMapping), provide: getRepositoryToken(CorrespondentEmailMapping),
useValue: {}, useValue: {},
}, },
{ { provide: getRepositoryToken(Task), useValue: taskRepo },
provide: getRepositoryToken(Task),
useValue: {
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
},
},
{ provide: PaperlessService, useValue: paperlessService }, { provide: PaperlessService, useValue: paperlessService },
{ provide: PdfService, useValue: {} }, { provide: PdfService, useValue: {} },
{ provide: EmailPageCacheService, useValue: {} }, { provide: EmailPageCacheService, useValue: {} },
{ provide: ImapFolderService, useValue: {} }, { provide: ImapFolderService, useValue: {} },
{ provide: AttachmentFingerprintService, useValue: fingerprints }, { provide: AttachmentFingerprintService, useValue: fingerprints },
{ provide: DokumentVersionService, useValue: dokumentVersion },
], ],
}).compile(); }).compile();
@@ -189,6 +208,76 @@ describe('EmailImportService – Duplikatprüfung', () => {
}); });
}); });
describe('getAttachmentPdf', () => {
beforeEach(async () => {
contentRepo.findOne.mockResolvedValue({
Content1: await pdfMitSeiten(3),
});
});
it('liefert den gewünschten Seitenbereich', async () => {
const pdf = await service.getAttachmentPdf(7, { start: 2, end: 3 });
expect((await PDFDocument.load(pdf)).getPageCount()).toBe(2);
});
it.each([
{ start: 3, end: 2 },
{ start: 0, end: 2 },
{ start: 5, end: 6 },
{ start: Number.NaN, end: 2 },
])(
'meldet einen ungültigen Seitenbereich $start–$end als 400',
async (pages) => {
await expect(service.getAttachmentPdf(7, pages)).rejects.toThrow(
BadRequestException,
);
},
);
});
describe('Import als Version', () => {
it('hängt den Beleg als Version an, statt ein neues Dokument anzulegen', async () => {
contentRepo.findOne.mockResolvedValue({
Content1: await pdfMitSeiten(1),
});
await service.executeImport({
emailDate: '2026-09-25',
attachments: [
{
attachmentId: 7,
type: 'MAIN',
belegnummer: '2026-000123',
barcode: {
x: 0,
y: 0,
nummer: '000123',
datum: '2026-09-25',
jahr: '2026',
},
versionZuDokumentId: 42,
},
],
});
expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledTimes(1);
expect(dokumentVersion.haengeVersionAn).toHaveBeenCalledWith(
42,
expect.stringMatching(/\.pdf$/),
'2026-000123',
);
expect(paperlessService.uploadDocument).not.toHaveBeenCalled();
expect(paperlessService.validateAsnNotExists).not.toHaveBeenCalled();
expect(taskRepo.save).not.toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
PaperlessDocumentIds: { full: 42 },
ImportStatus: 1,
}),
);
});
});
describe('executeImport', () => { describe('executeImport', () => {
const importDaten = (splitRanges?: { start: number; end: number }[]) => ({ const importDaten = (splitRanges?: { start: number; end: number }[]) => ({
emailDate: '2026-09-25', emailDate: '2026-09-25',
@@ -4,6 +4,7 @@ import {
HttpException, HttpException,
HttpStatus, HttpStatus,
ConflictException, ConflictException,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -21,6 +22,7 @@ import * as QRCode from 'qrcode';
import { EmailPageCacheService } from './email-page-cache.service'; import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service'; import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service'; import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { DokumentVersionService } from '../paperless/dokument-version.service';
import { PdfService } from '../preprocessing/pdf.service'; import { PdfService } from '../preprocessing/pdf.service';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
@@ -73,6 +75,7 @@ export class EmailImportService {
private readonly pageCache: EmailPageCacheService, private readonly pageCache: EmailPageCacheService,
private readonly imapFolderService: ImapFolderService, private readonly imapFolderService: ImapFolderService,
private readonly fingerprints: AttachmentFingerprintService, private readonly fingerprints: AttachmentFingerprintService,
private readonly dokumentVersion: DokumentVersionService,
) {} ) {}
async ensurePreviews(emailId: number): Promise<void> { async ensurePreviews(emailId: number): Promise<void> {
@@ -348,9 +351,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,28 +365,44 @@ 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; ignoreEncryption: true,
if (pages) { });
const pdfDoc = await PDFDocument.load(pdfBytes, { const total = pdfDoc.getPageCount();
ignoreEncryption: true, const ende = pages.end === 999 ? total : Math.min(pages.end, total);
}); if (
const total = pdfDoc.getPageCount(); !Number.isInteger(pages.start) ||
const startIdx = Math.max(1, pages.start) - 1; !Number.isInteger(ende) ||
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; pages.start < 1 ||
const sliced = await PDFDocument.create(); pages.start > ende
const indices = Array.from( ) {
{ length: endIdx - startIdx + 1 }, throw new BadRequestException(
(_, i) => startIdx + i, `Ungültiger Seitenbereich ${pages.start}–${pages.end} (Dokument hat ${total} Seiten)`,
); );
const copied = await sliced.copyPages(pdfDoc, indices);
copied.forEach((p) => sliced.addPage(p));
pdfBytes = Buffer.from(await sliced.save());
} }
const startIdx = pages.start - 1;
const endIdx = ende - 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 { _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);
} }
@@ -559,6 +582,8 @@ export class EmailImportService {
jahr: string; jahr: string;
}; };
belegnummer?: string; belegnummer?: string;
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
versionZuDokumentId?: number | null;
}[]; }[];
emailDate: string; emailDate: string;
}): Promise<{ success: boolean; results: any[] }> { }): Promise<{ success: boolean; results: any[] }> {
@@ -625,6 +650,31 @@ export class EmailImportService {
} }
} }
/**
* Hängt jede Upload-Datei als neue Version an das Stammdokument. Kein Task,
* keine Belegnummer-Bestätigung – Metadaten des Dokuments bleiben unverändert.
*/
private async importiereAlsVersion(
documentId: number,
belegnummer: string,
uploadItems: { path: string; filename: string; rangeKey: string }[],
paperlessIds: Record<string, number>,
jobId?: string,
): Promise<void> {
for (const item of uploadItems) {
this.setJobStatus(
jobId,
`Hänge ${item.filename} als neue Version an Dokument ${documentId}...`,
);
await this.dokumentVersion.haengeVersionAn(
documentId,
item.path,
belegnummer,
);
paperlessIds[item.rangeKey] = documentId;
}
}
private async runImport(data: { private async runImport(data: {
jobId?: string; jobId?: string;
attachments: { attachments: {
@@ -641,6 +691,8 @@ export class EmailImportService {
jahr: string; jahr: string;
}; };
belegnummer?: string; belegnummer?: string;
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
versionZuDokumentId?: number | null;
}[]; }[];
emailDate: string; emailDate: string;
}): Promise<{ success: boolean; results: any[] }> { }): Promise<{ success: boolean; results: any[] }> {
@@ -668,7 +720,7 @@ export class EmailImportService {
const baseFilename = sanitizeFilename( const baseFilename = sanitizeFilename(
attachmentEntity.FileName.replace(/\.pdf$/i, ''), attachmentEntity.FileName.replace(/\.pdf$/i, ''),
); );
const paperlessIds: any = {}; const paperlessIds: Record<string, number> = {};
const uploadPromises = []; const uploadPromises = [];
@@ -726,6 +778,29 @@ export class EmailImportService {
}); });
} }
// Vom Benutzer bestätigt: als neue Version an das vorhandene Dokument
if (att.versionZuDokumentId) {
await this.importiereAlsVersion(
att.versionZuDokumentId,
att.belegnummer ?? '',
uploadPromises,
paperlessIds,
data.jobId,
);
attachmentEntity.PaperlessDocumentIds = paperlessIds;
attachmentEntity.ImportStatus = 1;
if (att.belegnummer) {
attachmentEntity.InterneBelegnummer = att.belegnummer;
}
await this.attachmentRepo.save(attachmentEntity);
results.push({
attachmentId: att.attachmentId,
paperlessIds,
alsVersion: true,
});
continue;
}
// 0. Check if ASN already exists // 0. Check if ASN already exists
if (att.belegnummer) { if (att.belegnummer) {
await this.paperlessService.validateAsnNotExists(att.belegnummer); await this.paperlessService.validateAsnNotExists(att.belegnummer);
@@ -0,0 +1,45 @@
import { BadRequestException } from '@nestjs/common';
import { InboxPostprocessorService } from './inbox-postprocessor.service';
import {
ermittleUploadAsn,
originalSeitenDesAbschnitts,
} from './paperless-entscheidung.util';
describe('InboxPostprocessorService.runForDocument', () => {
it('nimmt eine Duplikat-Entscheidung nur für genau einen Abschnitt an', async () => {
const docRepo = { findOne: jest.fn() };
const service = new (InboxPostprocessorService as unknown as new (
...args: unknown[]
) => InboxPostprocessorService)({}, {}, {}, {}, {}, {}, docRepo, {}, {});
await expect(
service.runForDocument('doc-1', null, 0, false, {
aktion: 'version',
documentId: 42,
}),
).rejects.toThrow(BadRequestException);
expect(docRepo.findOne).not.toHaveBeenCalled();
});
});
describe('originalSeitenDesAbschnitts', () => {
it('bildet Indizes der bearbeiteten PDF auf Original-Seiten ab', () => {
// Seite 2 gelöscht: bearbeitete PDF = Original 1, 3, 4, 5
expect(originalSeitenDesAbschnitts([1, 3, 4, 5], [1, 2])).toEqual([3, 4]);
});
});
describe('ermittleUploadAsn', () => {
it('nimmt die ASN aus dem Template-Feld, wenn gesetzt', () => {
expect(ermittleUploadAsn('ASN 555', '2026-000123')).toBe(555);
});
it('bildet sie sonst aus der Belegnummer', () => {
expect(ermittleUploadAsn('', '2026-000123')).toBe(2026000123);
expect(ermittleUploadAsn(null, '2026-000123')).toBe(2026000123);
});
it('liefert undefined, wenn keine Ziffern vorhanden sind', () => {
expect(ermittleUploadAsn(null, 'abc')).toBeUndefined();
});
});
@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
@@ -18,6 +23,13 @@ import {
extractSectionToTemp, extractSectionToTemp,
} from './edit-applier'; } from './edit-applier';
import { applyTemplate, buildVariables } from './variable-resolver'; import { applyTemplate, buildVariables } from './variable-resolver';
import { DokumentVersionService } from '../paperless/dokument-version.service';
import {
aktionenAbEntscheidung,
ermittleUploadAsn,
originalSeitenDesAbschnitts,
type PaperlessEntscheidung,
} from './paperless-entscheidung.util';
function parseFlexDate(s: string): Date | null { function parseFlexDate(s: string): Date | null {
if (!s) return null; if (!s) return null;
@@ -45,6 +57,16 @@ export interface ActionResult {
skipped?: boolean; skipped?: boolean;
message?: string; message?: string;
duplicateOfDocumentId?: number; duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
/** Alle gefundenen Stammdokumente (bei mehrdeutigen Treffern) */
duplikatKandidaten?: number[];
/** Der Benutzer hat das Duplikat verworfen */
verworfen?: boolean;
/** Aufgelöste interne Belegnummer (bei Duplikaten) */
belegnummer?: string;
/** Original-Seiten des Abschnitts (bei Duplikaten, für den Vergleich) */
seiten?: number[];
} }
export interface RunForDocumentResult { export interface RunForDocumentResult {
@@ -56,6 +78,9 @@ interface PaperlessRunResult {
skipped?: boolean; skipped?: boolean;
message?: string; message?: string;
duplicateOfDocumentId?: number; duplicateOfDocumentId?: number;
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
belegnummer?: string;
} }
@Injectable() @Injectable()
@@ -65,6 +90,7 @@ export class InboxPostprocessorService {
constructor( constructor(
private readonly pageCache: PageCacheService, private readonly pageCache: PageCacheService,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly dokumentVersion: DokumentVersionService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly exportService: ExportService, private readonly exportService: ExportService,
@InjectRepository(InboxPostprocessingAction) @InjectRepository(InboxPostprocessingAction)
@@ -82,8 +108,14 @@ export class InboxPostprocessorService {
preferredUsername: string | null, preferredUsername: string | null,
sectionOffset: number = 0, sectionOffset: number = 0,
processOnlyOne: boolean = false, processOnlyOne: boolean = false,
replaceDuplicate: boolean = false, entscheidung?: PaperlessEntscheidung,
): Promise<RunForDocumentResult> { ): Promise<RunForDocumentResult> {
// Eine Duplikat-Entscheidung gilt immer nur für den einen Abschnitt
if (entscheidung && !processOnlyOne) {
throw new BadRequestException(
'Eine Duplikat-Entscheidung ist nur zusammen mit processOnlyOne erlaubt',
);
}
const doc = await this.docRepo.findOne({ where: { Id: documentId } }); const doc = await this.docRepo.findOne({ where: { Id: documentId } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden'); if (!doc) throw new NotFoundException('Dokument nicht gefunden');
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) { if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
@@ -252,16 +284,31 @@ export class InboxPostprocessorService {
: undefined; : undefined;
try { try {
for (const action of tplActions) { for (const action of aktionenAbEntscheidung(
tplActions,
entscheidung,
)) {
if (abortProcessing) break; if (abortProcessing) break;
if (action.ActionType === 'PAPERLESS') { if (
action.ActionType === 'PAPERLESS' &&
entscheidung?.aktion === 'verwerfen'
) {
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
verworfen: true,
message: 'Verworfen',
});
} else if (action.ActionType === 'PAPERLESS') {
try { try {
const res = await this.runPaperless( const res = await this.runPaperless(
action.Content ?? {}, action.Content ?? {},
sectionPdfPath, sectionPdfPath,
variables, variables,
defaultFilenameBase, defaultFilenameBase,
replaceDuplicate, entscheidung,
); );
results.push({ results.push({
sectionIndex: currentSectionIndex, sectionIndex: currentSectionIndex,
@@ -271,6 +318,15 @@ export class InboxPostprocessorService {
skipped: res.skipped, skipped: res.skipped,
message: res.message, message: res.message,
duplicateOfDocumentId: res.duplicateOfDocumentId, duplicateOfDocumentId: res.duplicateOfDocumentId,
versionMoeglich: res.versionMoeglich,
duplikatKandidaten: res.duplikatKandidaten,
belegnummer: res.belegnummer,
seiten: res.skipped
? originalSeitenDesAbschnitts(
survivingOriginalPages,
pageIndices,
)
: undefined,
}); });
if (res.skipped) { if (res.skipped) {
abortProcessing = true; abortProcessing = true;
@@ -404,7 +460,7 @@ export class InboxPostprocessorService {
pdfPath: string, pdfPath: string,
variables: Record<string, string>, variables: Record<string, string>,
defaultFilenameBase?: string, defaultFilenameBase?: string,
replaceDuplicate: boolean = false, entscheidung?: PaperlessEntscheidung,
): Promise<PaperlessRunResult> { ): Promise<PaperlessRunResult> {
// 1. Interne Belegnummer auflösen (Pflicht) // 1. Interne Belegnummer auflösen (Pflicht)
const intNrTpl = String(content.interneBelegnummer ?? '').trim(); const intNrTpl = String(content.interneBelegnummer ?? '').trim();
@@ -416,84 +472,77 @@ export class InboxPostprocessorService {
if (!interneBelegnummer) if (!interneBelegnummer)
throw new Error('Interne Belegnummer konnte nicht aufgelöst werden'); throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
// 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann) // 2. ASN, die das Dokument beim Upload bekäme – gegen genau diese wird geprüft
const asnTpl = String(content.asn ?? '').trim(); const asnTpl = String(content.asn ?? '').trim();
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null; const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
let archiveSerialNumber: number | undefined; const archiveSerialNumber = ermittleUploadAsn(asn, interneBelegnummer);
if (asn) {
const n = parseInt(asn.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) archiveSerialNumber = n;
}
if (archiveSerialNumber === undefined) {
const n = parseInt(interneBelegnummer.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) archiveSerialNumber = n;
}
if (replaceDuplicate) { // 3. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen
this.logger.log( if (entscheidung?.aktion === 'version') {
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`, await this.dokumentVersion.haengeVersionAn(
); entscheidung.documentId,
} else { pdfPath,
// 3. Duplikat-Check lokal (tasks-Tabelle)
const existingTask = await this.taskRepo.findOneBy({
InterneBelegnummer: interneBelegnummer,
});
if (existingTask) {
this.logger.warn(
`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`,
);
return {
skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
};
}
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(
7,
interneBelegnummer, interneBelegnummer,
archiveSerialNumber ?? null,
); );
if (cf7DocId !== null) { this.logger.log(
this.logger.warn( `Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`, );
); return {
return { message: `Als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
skipped: true, };
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`, }
duplicateOfDocumentId: cf7DocId,
};
}
// 5. Duplikat-Check Paperless API (archive_serial_number) // 4. Duplikat-Check Paperless: CF7 + Upload-ASN
if (archiveSerialNumber !== undefined) { const pruefung = await this.paperlessService.pruefeBelegnummer(
const asnDocId = interneBelegnummer,
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber); archiveSerialNumber ?? null,
if (asnDocId !== null) { );
this.logger.warn( if (pruefung.status !== 'frei') {
`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`, this.logger.warn(
); `Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
return { );
skipped: true, return {
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`, skipped: true,
duplicateOfDocumentId: asnDocId, message:
}; pruefung.status === 'eindeutig'
} ? `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`
} : `Duplikat – Belegnummer ${interneBelegnummer} mehrfach in Paperless`,
duplicateOfDocumentId: pruefung.documentId ?? pruefung.kandidaten[0],
versionMoeglich: pruefung.status === 'eindeutig',
duplikatKandidaten: pruefung.kandidaten,
belegnummer: interneBelegnummer,
};
}
// 6. Checksum berechnen und prüfen // 5. Duplikat-Check lokal (tasks-Tabelle)
const buffer = await fs.readFile(pdfPath); const existingTask = await this.taskRepo.findOneBy({
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen InterneBelegnummer: interneBelegnummer,
const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); });
const checksumExists = if (existingTask) {
await this.paperlessService.checksumExists(checksum); this.logger.warn(
if (checksumExists) { `Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`,
this.logger.warn(`Duplikat (Checksum): ${checksum}`); );
return { return {
skipped: true, skipped: true,
message: 'Duplikat (Checksum-Übereinstimmung)', message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
}; duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
} versionMoeglich: false,
belegnummer: interneBelegnummer,
};
}
// 6. Checksum berechnen und prüfen
const buffer = await fs.readFile(pdfPath);
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const checksumExists = await this.paperlessService.checksumExists(checksum);
if (checksumExists) {
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
return {
skipped: true,
message: 'Duplikat (Checksum-Übereinstimmung)',
};
} }
// 7. Restliche Metadaten auflösen // 7. Restliche Metadaten auflösen
@@ -0,0 +1,38 @@
import { aktionenAbEntscheidung } from './paperless-entscheidung.util';
const MAIL = { ActionType: 'MAIL' };
const PAPERLESS = { ActionType: 'PAPERLESS' };
const EXPORT = { ActionType: 'EXPORT' };
describe('aktionenAbEntscheidung', () => {
it('ohne Entscheidung laufen alle Aktionen', () => {
expect(aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT])).toEqual([
MAIL,
PAPERLESS,
EXPORT,
]);
});
it('nach „Verwerfen“ laufen Aktionen vor PAPERLESS nicht doppelt, die danach schon', () => {
expect(
aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT], {
aktion: 'verwerfen',
}),
).toEqual([PAPERLESS, EXPORT]);
});
it('nach „Version“ geht es ab der PAPERLESS-Aktion weiter', () => {
expect(
aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT], {
aktion: 'version',
documentId: 1,
}),
).toEqual([PAPERLESS, EXPORT]);
});
it('ohne PAPERLESS-Aktion laufen trotz Entscheidung alle', () => {
expect(
aktionenAbEntscheidung([MAIL, EXPORT], { aktion: 'verwerfen' }),
).toEqual([MAIL, EXPORT]);
});
});
@@ -0,0 +1,45 @@
/** Entscheidung des Benutzers zu einem Duplikat bei der Aktion „An Paperless senden“. */
export type PaperlessEntscheidung =
| { aktion: 'version'; documentId: number }
| { aktion: 'verwerfen' };
/**
* Aktionen eines Abschnitts, die nach einer Duplikat-Entscheidung noch laufen:
* alles ab der ersten PAPERLESS-Aktion – die davor liefen schon beim ersten
* Durchlauf. Ohne Entscheidung oder ohne PAPERLESS-Aktion laufen alle.
*/
export function aktionenAbEntscheidung<T extends { ActionType: string }>(
aktionen: T[],
entscheidung?: PaperlessEntscheidung,
): T[] {
if (!entscheidung) return aktionen;
const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS');
return start === -1 ? aktionen : aktionen.slice(start);
}
/**
* Original-Seitennummern (1-basiert) eines Abschnitts: `pageIndices` sind
* 0-basierte Indizes in der bearbeiteten PDF (gelöschte Seiten fehlen dort).
*/
export function originalSeitenDesAbschnitts(
survivingOriginalPages: number[],
pageIndices: number[],
): number[] {
return pageIndices.map((idx) => survivingOriginalPages[idx]);
}
/**
* ASN, die das Dokument beim Upload bekommt: aus dem ASN-Feld der Aktion
* (bereits aufgelöst), sonst aus der internen Belegnummer – jeweils nur die Ziffern.
*/
export function ermittleUploadAsn(
asnAusTemplate: string | null,
interneBelegnummer: string,
): number | undefined {
for (const quelle of [asnAusTemplate, interneBelegnummer]) {
if (!quelle) continue;
const n = parseInt(quelle.replace(/[^0-9]/g, ''), 10);
if (!Number.isNaN(n)) return n;
}
return undefined;
}
@@ -17,10 +17,16 @@ import type { Response } from 'express';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { InboxService } from './inbox.service'; import { InboxService } from './inbox.service';
import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service'; import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service';
import type { PaperlessEntscheidung } from '../inbox-postprocessor/paperless-entscheidung.util';
import { BarcodeScannerService } from '../barcode/barcode-scanner.service'; 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 +36,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()
@@ -137,7 +144,7 @@ export class InboxController {
body: { body: {
sectionOffset?: number; sectionOffset?: number;
processOnlyOne?: boolean; processOnlyOne?: boolean;
replaceDuplicate?: boolean; entscheidung?: PaperlessEntscheidung;
}, },
) { ) {
const preferredUsername: string | null = const preferredUsername: string | null =
@@ -147,7 +154,7 @@ export class InboxController {
preferredUsername, preferredUsername,
body?.sectionOffset ?? 0, body?.sectionOffset ?? 0,
body?.processOnlyOne ?? false, body?.processOnlyOne ?? false,
body?.replaceDuplicate ?? false, body?.entscheidung,
); );
return { results, totalSections }; return { results, totalSections };
} }
@@ -250,6 +257,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],
@@ -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 };
}
@@ -0,0 +1,164 @@
import {
DokumentVersionService,
versionLabel,
} from './dokument-version.service';
import { PaperlessService } from './paperless.service';
describe('versionLabel', () => {
it('benennt die Version nach dem Datum', () => {
expect(versionLabel(new Date(2026, 10, 2))).toBe(
'Korrektur vom 02.11.2026',
);
});
});
describe('DokumentVersionService.haengeVersionAn', () => {
let paperless: {
getDocument: jest.Mock;
updateDocument: jest.Mock;
uploadDocumentVersion: jest.Mock;
pruefeBelegnummer: jest.Mock;
getTask: jest.Mock;
};
let service: DokumentVersionService;
beforeEach(() => {
paperless = {
getDocument: jest.fn(),
updateDocument: jest.fn().mockResolvedValue({}),
uploadDocumentVersion: jest.fn().mockResolvedValue('task-1'),
pruefeBelegnummer: jest.fn().mockResolvedValue({
status: 'eindeutig',
documentId: 42,
kandidaten: [42],
}),
getTask: jest.fn().mockResolvedValue({ status: 'SUCCESS' }),
};
service = new DokumentVersionService(
paperless as unknown as PaperlessService,
);
jest
.spyOn(service as unknown as { warte: () => Promise<void> }, 'warte')
.mockResolvedValue(undefined);
});
it('lehnt ab, wenn die Belegnummer nicht (mehr) eindeutig zu diesem Dokument gehört', async () => {
paperless.pruefeBelegnummer.mockResolvedValue({
status: 'eindeutig',
documentId: 7,
kandidaten: [7],
});
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toThrow(/Dokument 42/);
expect(paperless.updateDocument).not.toHaveBeenCalled();
expect(paperless.uploadDocumentVersion).not.toHaveBeenCalled();
});
it('prüft die Upload-ASN, wenn sie übergeben wird', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [] });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123', 555);
expect(paperless.pruefeBelegnummer).toHaveBeenCalledWith(
'2026-000123',
555,
);
});
it('wartet auf die Paperless-Verarbeitung der Version', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [] });
paperless.getTask
.mockResolvedValueOnce({ status: 'STARTED' })
.mockResolvedValueOnce({ status: 'SUCCESS' });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123');
expect(paperless.getTask).toHaveBeenCalledWith('task-1');
expect(paperless.getTask).toHaveBeenCalledTimes(2);
});
it('meldet einen Fehler und entfernt Tag 21, wenn Paperless die Version ablehnt', async () => {
paperless.getDocument
.mockResolvedValueOnce({ id: 42, tags: [3] })
.mockResolvedValueOnce({ id: 42, tags: [3, 21] });
paperless.getTask.mockResolvedValue({
status: 'FAILURE',
message: 'kaputt',
});
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toThrow(/kaputt/);
expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, {
tags: [3],
});
});
it('setzt Tag 21 vor dem Hochladen der Version', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [3, 17] });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123');
expect(paperless.updateDocument).toHaveBeenCalledWith(42, {
tags: [3, 17, 21],
});
expect(paperless.uploadDocumentVersion).toHaveBeenCalledWith(
42,
'/tmp/x.pdf',
expect.stringMatching(/^Korrektur vom \d{2}\.\d{2}\.\d{4}$/),
);
expect(paperless.updateDocument.mock.invocationCallOrder[0]).toBeLessThan(
paperless.uploadDocumentVersion.mock.invocationCallOrder[0],
);
});
it('setzt Tag 21 nicht doppelt', async () => {
paperless.getDocument.mockResolvedValue({ id: 42, tags: [21, 5] });
await service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123');
expect(paperless.updateDocument).not.toHaveBeenCalled();
expect(paperless.uploadDocumentVersion).toHaveBeenCalled();
});
it('entfernt den neu gesetzten Tag, wenn der Upload scheitert', async () => {
const fehler = new Error('Upload kaputt');
paperless.getDocument
.mockResolvedValueOnce({ id: 42, tags: [3] })
.mockResolvedValueOnce({ id: 42, tags: [3, 21] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler);
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toBe(fehler);
expect(paperless.updateDocument).toHaveBeenLastCalledWith(42, {
tags: [3],
});
});
it('lässt einen schon vorhandenen Tag 21 stehen, wenn der Upload scheitert', async () => {
const fehler = new Error('Upload kaputt');
paperless.getDocument.mockResolvedValue({ id: 42, tags: [21] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler);
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toBe(fehler);
expect(paperless.updateDocument).not.toHaveBeenCalled();
});
it('meldet den Upload-Fehler, auch wenn das Entfernen des Tags scheitert', async () => {
const fehler = new Error('Upload kaputt');
paperless.getDocument.mockResolvedValue({ id: 42, tags: [3] });
paperless.uploadDocumentVersion.mockRejectedValue(fehler);
paperless.updateDocument
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('Tag entfernen kaputt'));
await expect(
service.haengeVersionAn(42, '/tmp/x.pdf', '2026-000123'),
).rejects.toBe(fehler);
});
});
@@ -0,0 +1,131 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { PaperlessService } from './paperless.service';
/** Tag „neue Version hochgeladen“ – Postprocessing-Regeln ignorieren solche Belege. */
export const NEUE_VERSION_TAG_ID = 21;
const pad = (n: number) => String(n).padStart(2, '0');
/** Wie beim normalen Import: bis zu 30 × 2 s auf die Paperless-Verarbeitung warten. */
const MAX_ABFRAGEN = 30;
const ABFRAGE_ABSTAND_MS = 2000;
/** Bezeichnung der Version in Paperless, z. B. „Korrektur vom 02.11.2026“. */
export function versionLabel(datum: Date): string {
return `Korrektur vom ${pad(datum.getDate())}.${pad(datum.getMonth() + 1)}.${datum.getFullYear()}`;
}
@Injectable()
export class DokumentVersionService {
private readonly logger = new Logger(DokumentVersionService.name);
constructor(private readonly paperlessService: PaperlessService) {}
/**
* Hängt eine PDF als neue Version an ein Stammdokument. Vorher wird geprüft,
* dass die Belegnummer in Paperless eindeutig zu genau diesem Dokument
* gehört. Erst wird Tag 21 gesetzt, dann die Version hochgeladen und auf die
* Verarbeitung gewartet; scheitert das, wird ein hier neu gesetzter Tag 21
* wieder entfernt.
*/
async haengeVersionAn(
rootDocumentId: number,
pdfPath: string,
belegnummer: string,
uploadAsn?: number | null,
): Promise<void> {
const pruefung = await this.paperlessService.pruefeBelegnummer(
belegnummer,
uploadAsn,
);
if (
pruefung.status !== 'eindeutig' ||
pruefung.documentId !== rootDocumentId
) {
throw new ConflictException(
`Belegnummer ${belegnummer} gehört in Paperless nicht eindeutig zu Dokument ${rootDocumentId} (gefunden: ${pruefung.kandidaten.join(', ') || 'keins'}) – keine neue Version angelegt.`,
);
}
const tagNeuGesetzt = await this.setzeTag(rootDocumentId);
try {
const taskId = await this.paperlessService.uploadDocumentVersion(
rootDocumentId,
pdfPath,
versionLabel(new Date()),
);
await this.warteAufVerarbeitung(rootDocumentId, taskId);
this.logger.log(
`Neue Version für Dokument ${rootDocumentId} hochgeladen.`,
);
} catch (err) {
if (tagNeuGesetzt) await this.entferneTag(rootDocumentId);
throw err;
}
}
/**
* Wartet, bis Paperless die Version verarbeitet hat. FAILURE/REVOKED → Fehler;
* bleibt die Task offen, wird nur gewarnt (Ergebnis unbekannt).
*/
private async warteAufVerarbeitung(
documentId: number,
taskId: string,
): Promise<void> {
if (!taskId) {
this.logger.warn(
`Keine Task-ID für die Version von Dokument ${documentId} – Verarbeitung nicht überprüfbar.`,
);
return;
}
for (let i = 0; i < MAX_ABFRAGEN; i++) {
const task = await this.paperlessService
.getTask(taskId)
.catch(() => null);
if (task?.status === 'SUCCESS') return;
if (task?.status === 'FAILURE' || task?.status === 'REVOKED') {
throw new Error(
`Paperless hat die neue Version für Dokument ${documentId} abgelehnt: ${task.message ?? 'keine Meldung'}`,
);
}
await this.warte(ABFRAGE_ABSTAND_MS);
}
this.logger.warn(
`Version für Dokument ${documentId} (Task ${taskId}) nach ${(MAX_ABFRAGEN * ABFRAGE_ABSTAND_MS) / 1000} s noch nicht verarbeitet.`,
);
}
protected warte(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Setzt Tag 21; liefert true, wenn er vorher fehlte. */
private async setzeTag(documentId: number): Promise<boolean> {
const tags = await this.leseTags(documentId);
if (tags.includes(NEUE_VERSION_TAG_ID)) return false;
await this.paperlessService.updateDocument(documentId, {
tags: [...tags, NEUE_VERSION_TAG_ID],
});
return true;
}
private async entferneTag(documentId: number): Promise<void> {
try {
const tags = await this.leseTags(documentId);
await this.paperlessService.updateDocument(documentId, {
tags: tags.filter((t) => t !== NEUE_VERSION_TAG_ID),
});
} catch (err) {
this.logger.error(
`Tag ${NEUE_VERSION_TAG_ID} konnte nach fehlgeschlagenem Versions-Upload nicht von Dokument ${documentId} entfernt werden: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private async leseTags(documentId: number): Promise<number[]> {
const doc = (await this.paperlessService.getDocument(documentId)) as {
tags?: number[];
};
return Array.isArray(doc?.tags) ? doc.tags.map(Number) : [];
}
}
@@ -0,0 +1,101 @@
import { ConfigService } from '@nestjs/config';
import { PaperlessService } from './paperless.service';
describe('PaperlessService – Belegnummer-Suche', () => {
let service: PaperlessService;
let get: jest.Mock;
beforeEach(() => {
service = new PaperlessService({
get: (_k: string, d?: unknown) => d,
} as unknown as ConfigService);
get = jest.fn();
(service as unknown as { client: { get: jest.Mock } }).client = { get };
});
it('führt Stamm und Version auf das Stammdokument zusammen (CF7)', async () => {
get.mockResolvedValue({
data: {
results: [
{
id: 42,
root_document: null,
custom_fields: [{ field: 7, value: '2026-000123' }],
},
{
id: 99,
root_document: 42,
custom_fields: [{ field: 7, value: '2026-000123' }],
},
{
id: 50,
root_document: null,
custom_fields: [{ field: 7, value: '2026-0001234' }],
},
],
},
});
expect(
await service.findRootDocumentIdsByCustomField(7, '2026-000123'),
).toEqual([42, 42]);
});
it('führt Stamm und Version auf das Stammdokument zusammen (ASN)', async () => {
get.mockResolvedValue({
data: {
results: [
{ id: 42, archive_serial_number: 2026000123 },
{ id: 99, root_document: 42, archive_serial_number: 2026000123 },
],
},
});
expect(await service.findRootDocumentIdsByAsn(2026000123)).toEqual([
42, 42,
]);
});
it('ein Dokument mit Versionen gilt als eindeutig', async () => {
get.mockImplementation(
(_url: string, cfg: { params: Record<string, unknown> }) =>
Promise.resolve({
data: {
results: cfg.params.archive_serial_number
? [
{
id: 99,
root_document: 42,
archive_serial_number: 2026000123,
},
]
: [
{
id: 42,
custom_fields: [{ field: 7, value: '2026-000123' }],
},
],
},
}),
);
expect(await service.pruefeBelegnummer('2026-000123')).toEqual({
status: 'eindeutig',
documentId: 42,
kandidaten: [42],
});
});
it('prüft die übergebene Upload-ASN statt der aus der Belegnummer gebildeten', async () => {
get.mockResolvedValue({ data: { results: [] } });
await service.pruefeBelegnummer('2026-000123', 555);
const asnWerte = (
get.mock.calls as [string, { params: Record<string, unknown> }][]
)
.map(([, cfg]) => cfg.params.archive_serial_number)
.filter((wert) => wert !== undefined);
expect(asnWerte).toEqual([555]);
});
});
@@ -81,6 +81,13 @@ export class PaperlessController {
return { exists }; 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') @Get('documents')
async getDocuments( async getDocuments(
@Query('search') search?: string, @Query('search') search?: string,
@@ -4,6 +4,7 @@ import { PaperlessService } from './paperless.service';
import { PaperlessController } from './paperless.controller'; import { PaperlessController } from './paperless.controller';
import { PaperlessProcessorService } from './paperless-processor.service'; import { PaperlessProcessorService } from './paperless-processor.service';
import { PaperlessTaskProcessorService } from './paperless-task-processor.service'; import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
import { DokumentVersionService } from './dokument-version.service';
import { DocumentType } from '../database/entities/document-type.entity'; import { DocumentType } from '../database/entities/document-type.entity';
import { DocumentField } from '../database/entities/document-field.entity'; import { DocumentField } from '../database/entities/document-field.entity';
import { Task } from '../database/entities/task.entity'; import { Task } from '../database/entities/task.entity';
@@ -33,7 +34,12 @@ import { ErechnungModule } from '../erechnung/erechnung.module';
PaperlessService, PaperlessService,
PaperlessProcessorService, PaperlessProcessorService,
PaperlessTaskProcessorService, PaperlessTaskProcessorService,
DokumentVersionService,
],
exports: [
PaperlessService,
PaperlessProcessorService,
DokumentVersionService,
], ],
exports: [PaperlessService, PaperlessProcessorService],
}) })
export class PaperlessModule {} export class PaperlessModule {}
@@ -4,6 +4,20 @@ 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 {
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 { export interface PaperlessTaskStatus {
taskId: string; taskId: string;
@@ -132,6 +146,34 @@ export class PaperlessService {
return response.data; return response.data;
} }
/**
* Lädt eine PDF als neue Version eines vorhandenen Dokuments hoch
* (`update_version`, Paperless-NGX API v10). Liefert die Consume-Task-ID.
*/
async uploadDocumentVersion(
documentId: number,
filePath: string,
versionLabel: string,
): Promise<string> {
const form = new FormData();
form.append('document', fs.createReadStream(filePath), {
filename: path.basename(filePath),
contentType: 'application/pdf',
});
form.append('version_label', versionLabel.slice(0, 64));
const response = await this.client.post<string>(
`/documents/${documentId}/update_version/`,
form,
{ headers: form.getHeaders(), timeout: 120000 },
);
const taskId = (response.data ?? '').replace(/"/g, '');
this.logger.log(
`Version für Dokument ${documentId} hochgeladen: ${taskId || '(keine Task-ID)'}`,
);
return taskId;
}
async getUsers(): Promise<any[]> { async getUsers(): Promise<any[]> {
const response = await this.client.get('/users/'); const response = await this.client.get('/users/');
return response.data?.results ?? []; return response.data?.results ?? [];
@@ -147,6 +189,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/', {
@@ -396,4 +450,72 @@ export class PaperlessService {
); );
return match ? Number(match.id) : null; 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 ASN, die das Dokument beim Upload bekäme (`uploadAsn`; ohne
* Angabe die aus der Belegnummer gebildete).
*/
async pruefeBelegnummer(
belegnummer: string,
uploadAsn?: number | null,
): Promise<BelegnummerPruefung> {
const nummer = (belegnummer ?? '').trim();
if (!nummer) return klassifiziereTreffer([], []);
const asn = uploadAsn ?? 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);
}
} }
@@ -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;
@@ -19,6 +20,8 @@ export interface AttachmentImportData {
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string }; barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
belegnummer?: string; belegnummer?: string;
isDuplicate?: boolean; isDuplicate?: boolean;
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
versionZuDokumentId?: number | null;
} }
export const emailImportApi = { export const emailImportApi = {
@@ -50,6 +53,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',
+17 -1
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';
@@ -86,7 +87,7 @@ export const inboxApi = {
postprocess: ( postprocess: (
id: string, id: string,
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean }, opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung },
) => ) =>
api api
.post<{ results: PostprocessActionResult[]; totalSections: number }>( .post<{ results: PostprocessActionResult[]; totalSections: number }>(
@@ -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' })
@@ -126,6 +132,9 @@ export const inboxApi = {
api.post(`/api/inbox/${encodeURIComponent(id)}/send-email`, body).then(() => {}), api.post(`/api/inbox/${encodeURIComponent(id)}/send-email`, body).then(() => {}),
}; };
/** Entscheidung des Benutzers zu einem Duplikat bei „An Paperless senden“ */
export type PaperlessEntscheidung = { aktion: 'version'; documentId: number } | { aktion: 'verwerfen' };
export interface PostprocessActionResult { export interface PostprocessActionResult {
sectionIndex: number; sectionIndex: number;
actionId: number; actionId: number;
@@ -134,6 +143,13 @@ export interface PostprocessActionResult {
skipped?: boolean; skipped?: boolean;
message?: string; message?: string;
duplicateOfDocumentId?: number; duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
verworfen?: boolean;
belegnummer?: string;
/** Original-Seiten des Abschnitts laut Backend (bei Duplikaten) */
seiten?: number[];
} }
export const clientsApi = { export const clientsApi = {
+22
View File
@@ -1,5 +1,25 @@
import api from './client'; 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[];
}
/** 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;
@@ -62,4 +82,6 @@ export const paperlessApi = {
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists), api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
getDocumentPdfBlob: (id: number) => getDocumentPdfBlob: (id: number) =>
api.get<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data), 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),
}; };
@@ -0,0 +1,198 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Alert, Button, Modal, Space, Spin, Tag, Typography, message } from 'antd';
import { paperlessApi, type BelegnummerPruefung, type VergleichKennzahlen } from '../api/paperless';
import { useIsMobile } from '../hooks/useIsMobile';
/** Länger wird auf Seiten-/Wortzahl nicht gewartet – der Vergleich bleibt trotzdem möglich. */
const KENNZAHLEN_TIMEOUT_MS = 30000;
function mitTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Zeitüberschreitung')), ms);
promise.then(
(wert) => {
clearTimeout(timer);
resolve(wert);
},
(err: unknown) => {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
},
);
});
}
export interface DuplikatVergleichModalProps {
open: boolean;
belegnummer: string;
/** eindeutig → Version möglich; mehrdeutig → nur Verwerfen */
pruefung: BelegnummerPruefung | null;
/** Vorhandenes Dokument (linke Seite) */
paperlessDocumentId: number | null;
/** Neuer Beleg (rechte Seite) */
ladeNeuesPdf: () => Promise<Blob>;
ladeKennzahlen: () => Promise<VergleichKennzahlen>;
onVersion: () => void;
onVerwerfen: () => void;
onClose: () => void;
}
function KennzahlZeile({ label, vorhanden, neu }: { label: string; vorhanden: number | null; neu: number | null }) {
const anzeige = (wert: number | null) => (wert === null ? 'nicht ermittelbar' : String(wert));
const vergleichbar = vorhanden !== null && neu !== null;
const abweichung = vergleichbar && vorhanden !== neu;
return (
<div>
<Typography.Text strong>{label}:</Typography.Text> {anzeige(vorhanden)} (vorhanden) → {anzeige(neu)} (neu){' '}
{abweichung && <Tag color="orange">abweichend</Tag>}
{!vergleichbar && <Tag>nicht vergleichbar</Tag>}
</div>
);
}
function PdfSpalte({ titel, url, loading }: { titel: string; url: string | null; loading: boolean }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
{titel}
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !url ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={url} title={titel} style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
);
}
/**
* Vergleich eines Belegs mit einem vorhandenen Paperless-Dokument derselben
* internen Belegnummer: Auffälligkeiten (Seiten-/Wortzahl) oben, beide PDFs
* nebeneinander, Entscheidung „neue Version“ oder „verwerfen“.
*/
export function DuplikatVergleichModal({
open,
belegnummer,
pruefung,
paperlessDocumentId,
ladeNeuesPdf,
ladeKennzahlen,
onVersion,
onVerwerfen,
onClose,
}: DuplikatVergleichModalProps) {
const isMobile = useIsMobile();
const [vorhandenUrl, setVorhandenUrl] = useState<string | null>(null);
const [neuUrl, setNeuUrl] = useState<string | null>(null);
const [kennzahlen, setKennzahlen] = useState<VergleichKennzahlen | null>(null);
const [kennzahlenFehler, setKennzahlenFehler] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || paperlessDocumentId === null) return;
let cancelled = false;
let urlAlt: string | null = null;
let urlNeu: string | null = null;
setLoading(true);
setKennzahlen(null);
setKennzahlenFehler(false);
(async () => {
try {
const [altBlob, neuBlob] = await Promise.all([paperlessApi.getDocumentPdfBlob(paperlessDocumentId), ladeNeuesPdf()]);
if (cancelled) return;
urlAlt = URL.createObjectURL(altBlob);
urlNeu = URL.createObjectURL(neuBlob);
setVorhandenUrl(urlAlt);
setNeuUrl(urlNeu);
} catch {
if (!cancelled) message.error('Vorschau konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
mitTimeout(ladeKennzahlen(), KENNZAHLEN_TIMEOUT_MS)
.then((k) => {
if (!cancelled) setKennzahlen(k);
})
.catch(() => {
if (!cancelled) setKennzahlenFehler(true);
});
return () => {
cancelled = true;
if (urlAlt) URL.revokeObjectURL(urlAlt);
if (urlNeu) URL.revokeObjectURL(urlNeu);
setVorhandenUrl(null);
setNeuUrl(null);
};
// Lade-Funktionen ändern sich bei jedem Render; neu laden nur bei anderem Dokument
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, paperlessDocumentId]);
const versionMoeglich = pruefung?.status === 'eindeutig';
let auffaelligkeiten: ReactNode = <Spin size="small" />;
if (kennzahlenFehler) {
auffaelligkeiten = (
<Alert type="warning" showIcon message="Seiten- und Wortzahl konnten nicht ermittelt werden – bitte die PDFs direkt vergleichen." />
);
} else if (kennzahlen) {
const { vorhanden, neu } = kennzahlen;
const alleBekannt = [vorhanden.seiten, neu.seiten, vorhanden.woerter, neu.woerter].every((w) => w !== null);
const gleich = alleBekannt && vorhanden.seiten === neu.seiten && vorhanden.woerter === neu.woerter;
auffaelligkeiten = gleich ? (
<Alert type="success" showIcon message="Seiten- und Wortzahl gleich" />
) : (
<Alert
type="info"
showIcon
message="Auffälligkeiten"
description={
<Space direction="vertical" size={2}>
<KennzahlZeile label="Seiten" vorhanden={vorhanden.seiten} neu={neu.seiten} />
<KennzahlZeile label="Wörter" vorhanden={vorhanden.woerter} neu={neu.woerter} />
</Space>
}
/>
);
}
return (
<Modal
open={open}
title={`Belegnummer ${belegnummer} existiert bereits`}
onCancel={onClose}
width="90vw"
style={{ top: 20 }}
destroyOnClose
footer={[
<Button key="verwerfen" danger onClick={onVerwerfen}>
Verwerfen
</Button>,
versionMoeglich && (
<Button key="version" type="primary" onClick={onVersion}>
Als neue Version hinzufügen
</Button>
),
]}
>
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{pruefung?.status === 'mehrdeutig' && (
<Alert
type="warning"
showIcon
message={`Mehrere Dokumente in Paperless mit dieser Belegnummer (IDs: ${pruefung.kandidaten.join(', ')}) – eine neue Version ist nicht möglich.`}
/>
)}
{auffaelligkeiten}
</Space>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 12, height: '70vh' }}>
<PdfSpalte titel={`Vorhanden (Paperless #${paperlessDocumentId ?? '–'})`} url={vorhandenUrl} loading={loading} />
<PdfSpalte titel="Neuer Beleg" url={neuUrl} loading={loading} />
</div>
</Modal>
);
}
@@ -6,13 +6,14 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { emailImportApi, type AttachmentImportData } from '../api/email-import'; import { emailImportApi, type AttachmentImportData } from '../api/email-import';
import { emailsApi } from '../api/emails'; import { emailsApi } from '../api/emails';
import { paperlessApi, type PaperlessCorrespondent } from '../api/paperless'; import { paperlessApi, type BelegnummerPruefung, type PaperlessCorrespondent } from '../api/paperless';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import PdfSplitViewer from './PdfSplitViewer'; import PdfSplitViewer from './PdfSplitViewer';
import BarcodePositioner from './BarcodePositioner'; import BarcodePositioner from './BarcodePositioner';
import { DuplikatVergleichModal } from './DuplikatVergleichModal';
const { Text } = Typography; const { Text } = Typography;
@@ -43,6 +44,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
// Eingangsdatum per item // Eingangsdatum per item
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({}); const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
// Duplikatvergleich: Entscheidung „neue Version“ je Beleg (gilt nur für diese Belegnummer)
const [versionEntscheidung, setVersionEntscheidung] = useState<Record<string, { belegnummer: string; documentId: number }>>({});
// Noch zu entscheidende Treffer, werden nacheinander im Vergleichsdialog gezeigt
const [vergleichQueue, setVergleichQueue] = useState<{ virtualId: string; belegnummer: string; pruefung: BelegnummerPruefung }[]>([]);
// Step 3 specific state // Step 3 specific state
const [importSuccess, setImportSuccess] = useState(false); const [importSuccess, setImportSuccess] = useState(false);
const [importStatus, setImportStatus] = useState(''); const [importStatus, setImportStatus] = useState('');
@@ -78,6 +84,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
}); });
setEingangsdaten(initialDates); setEingangsdaten(initialDates);
setBarcodes(initialBarcodes); setBarcodes(initialBarcodes);
setVersionEntscheidung({});
setVergleichQueue([]);
// Load correspondents and try to find suggestion // Load correspondents and try to find suggestion
loadCorrespondents(); loadCorrespondents();
@@ -349,6 +357,72 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
} }
}; };
// Manuell eingegebene Belegnummer im Format JJJJ-NNNNNN (wie beim Import), sonst null
const manuelleBelegnummer = (virtualId: string): string | null => {
if ((belegnummerMode[virtualId] || 'neu') !== 'manuell') return null;
let num = belegnummern[virtualId] || '';
if (num.includes('-')) num = num.split('-')[1];
if (!num || /^0+$/.test(num)) return null;
const yr = barcodes[virtualId]?.jahr || (eingangsdaten[virtualId] || dayjs(email.Date)).format('YYYY');
return `${yr}-${String(num).padStart(6, '0')}`;
};
// Prüft manuelle Belegnummern gegen Paperless; liefert die Treffer ohne gültige Entscheidung
const pruefeManuelleBelegnummern = async () => {
const kandidaten = importData
.filter(i => i.type !== 'IGNORE')
.map(i => ({ virtualId: i.virtualId, belegnummer: manuelleBelegnummer(i.virtualId) }))
.filter((k): k is { virtualId: string; belegnummer: string } => k.belegnummer !== null);
// Entscheidungen für inzwischen geänderte Belegnummern verfallen
const gueltig: typeof versionEntscheidung = {};
for (const k of kandidaten) {
const e = versionEntscheidung[k.virtualId];
if (e && e.belegnummer === k.belegnummer) gueltig[k.virtualId] = e;
}
setVersionEntscheidung(gueltig);
const offen = kandidaten.filter(k => !gueltig[k.virtualId]);
const pruefungen = await Promise.all(offen.map(k => paperlessApi.pruefeBelegnummer(k.belegnummer)));
return offen
.map((k, idx) => ({ ...k, pruefung: pruefungen[idx] }))
.filter(k => k.pruefung.status !== 'frei');
};
const weiterZuAbschluss = async (daten: AttachmentImportData[]) => {
if (daten.length > 0 && daten.every(i => i.type === 'IGNORE')) {
await ignoreEmail();
return;
}
await preFetchBelegnummern();
setCurrentStep(2);
};
// Nächsten Treffer zeigen; nach dem letzten geht es zu Schritt 3
const naechsterVergleich = (daten: AttachmentImportData[]) => {
const rest = vergleichQueue.slice(1);
setVergleichQueue(rest);
if (rest.length === 0) void weiterZuAbschluss(daten);
};
const handleVergleichVersion = () => {
const aktuell = vergleichQueue[0];
if (!aktuell?.pruefung.documentId) return;
setVersionEntscheidung(prev => ({
...prev,
[aktuell.virtualId]: { belegnummer: aktuell.belegnummer, documentId: aktuell.pruefung.documentId! },
}));
naechsterVergleich(importData);
};
const handleVergleichVerwerfen = () => {
const aktuell = vergleichQueue[0];
if (!aktuell) return;
const daten = importData.map(i => (i.virtualId === aktuell.virtualId ? { ...i, type: 'IGNORE' as const } : i));
setImportData(daten);
naechsterVergleich(daten);
};
const nextStep = async () => { const nextStep = async () => {
if (currentStep === 0) { if (currentStep === 0) {
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) { if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
@@ -372,6 +446,20 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
} finally { } finally {
setLoading(false); setLoading(false);
} }
// Manuelle Belegnummern, die es in Paperless schon gibt → erst vergleichen
setLoading(true);
try {
const treffer = await pruefeManuelleBelegnummern();
if (treffer.length > 0) {
setVergleichQueue(treffer);
return;
}
} catch {
message.error('Belegnummer-Prüfung fehlgeschlagen – bitte erneut versuchen.');
return;
} finally {
setLoading(false);
}
await preFetchBelegnummern(); await preFetchBelegnummern();
} }
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
@@ -448,9 +536,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
} }
const finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`; const finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
const entscheidung = versionEntscheidung[item.virtualId];
finalData.push({ finalData.push({
...item, ...item,
versionZuDokumentId: entscheidung?.belegnummer === finalBelegnummer ? entscheidung.documentId : null,
splitRanges: item.pages ? [item.pages] : undefined, splitRanges: item.pages ? [item.pages] : undefined,
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false }, barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
belegnummer: finalBelegnummer, belegnummer: finalBelegnummer,
@@ -765,6 +855,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
<Text type="secondary"> <Text type="secondary">
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag> Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
</Text> </Text>
{versionEntscheidung[main.virtualId]?.belegnummer === belegnr && (
<Tag color="purple">
wird als neue Version zu Dokument {versionEntscheidung[main.virtualId].documentId} hinzugefügt
</Tag>
)}
</Space> </Space>
{mainAttachments.length > 0 && ( {mainAttachments.length > 0 && (
<div style={{ marginTop: 12, paddingLeft: 16 }}> <div style={{ marginTop: 12, paddingLeft: 16 }}>
@@ -855,6 +950,26 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
{currentStep === 2 && renderStep3()} {currentStep === 2 && renderStep3()}
</div> </div>
</Spin> </Spin>
{vergleichQueue.length > 0 && (() => {
const aktuell = vergleichQueue[0];
const item = importData.find(i => i.virtualId === aktuell.virtualId);
const docId = aktuell.pruefung.documentId ?? aktuell.pruefung.kandidaten[0] ?? null;
if (!item) return null;
return (
<DuplikatVergleichModal
key={aktuell.virtualId}
open
belegnummer={aktuell.belegnummer}
pruefung={aktuell.pruefung}
paperlessDocumentId={docId}
ladeNeuesPdf={() => emailImportApi.attachmentPdfBlob(item.attachmentId, item.pages)}
ladeKennzahlen={() => emailImportApi.vergleich(item.attachmentId, docId ?? 0, item.pages)}
onVersion={handleVergleichVersion}
onVerwerfen={handleVergleichVerwerfen}
onClose={() => setVergleichQueue([])}
/>
);
})()}
</Modal> </Modal>
); );
} }
+54 -133
View File
@@ -22,8 +22,9 @@ import {
ZoomInOutlined, ZoomInOutlined,
ZoomOutOutlined, ZoomOutOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox'; import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless'; import type { BelegnummerPruefung } from '../api/paperless';
import { DuplikatVergleichModal } from '../components/DuplikatVergleichModal';
import { userSettingsApi, type SenderOption } from '../api/userSettings'; import { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
@@ -97,110 +98,6 @@ function SourceTag({ source }: { source: InboxFile['source'] }) {
); );
} }
interface CompareModalProps {
open: boolean;
paperlessDocumentId: number | null;
inboxDocumentId: string;
onClose: () => void;
onCreateNewVersion: () => void;
onSkip: () => void;
}
function CompareModal({
open,
paperlessDocumentId,
inboxDocumentId,
onClose,
onCreateNewVersion,
onSkip,
}: CompareModalProps) {
const compareIsMobile = useIsMobile();
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || paperlessDocumentId === null) return;
let cancelled = false;
let papUrl: string | null = null;
let inbUrl: string | null = null;
setLoading(true);
(async () => {
try {
const [pBlob, iBlob] = await Promise.all([
paperlessApi.getDocumentPdfBlob(paperlessDocumentId),
inboxApi.previewBlob(inboxDocumentId),
]);
if (cancelled) return;
papUrl = URL.createObjectURL(pBlob);
inbUrl = URL.createObjectURL(iBlob);
setPaperlessUrl(papUrl);
setInboxUrl(inbUrl);
} catch {
if (!cancelled) message.error('Vorschau konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (papUrl) URL.revokeObjectURL(papUrl);
if (inbUrl) URL.revokeObjectURL(inbUrl);
setPaperlessUrl(null);
setInboxUrl(null);
};
}, [open, paperlessDocumentId, inboxDocumentId]);
return (
<Modal
open={open}
title="Duplikat vergleichen"
onCancel={onClose}
width="90vw"
style={{ top: 20 }}
footer={[
<Button key="skip" onClick={onSkip}>
Überspringen
</Button>,
<Button key="new" type="primary" onClick={onCreateNewVersion}>
Neue Version anlegen
</Button>,
]}
>
<div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Original (Paperless)
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !paperlessUrl ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={paperlessUrl} title="Paperless Original" style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Aktueller Abschnitt (Inbox)
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !inboxUrl ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={inboxUrl} title="Inbox Abschnitt" style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
</div>
</Modal>
);
}
type WizardStepStatus = 'pending' | 'processing' | 'success' | 'duplicate' | 'error' | 'skipped'; type WizardStepStatus = 'pending' | 'processing' | 'success' | 'duplicate' | 'error' | 'skipped';
interface WizardStep { interface WizardStep {
@@ -259,7 +156,7 @@ function PostprocessWizardModal({
setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s))); setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s)));
}; };
const processSection = async (idx: number, replaceDuplicate = false) => { const processSection = async (idx: number, entscheidung?: PaperlessEntscheidung) => {
if (!documentId) return; if (!documentId) return;
setBusy(true); setBusy(true);
setStepAt(idx, { status: 'processing', message: undefined }); setStepAt(idx, { status: 'processing', message: undefined });
@@ -267,13 +164,17 @@ function PostprocessWizardModal({
const { results } = await inboxApi.postprocess(documentId, { const { results } = await inboxApi.postprocess(documentId, {
sectionOffset: idx, sectionOffset: idx,
processOnlyOne: true, processOnlyOne: true,
replaceDuplicate, entscheidung,
}); });
const skipped = results.find((r) => r.skipped); const skipped = results.find((r) => r.skipped);
const failed = results.filter((r) => !r.ok); const failed = results.filter((r) => !r.ok);
if (skipped) { if (skipped) {
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results }); setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
setAwaitingDecision(true); setAwaitingDecision(true);
// Treffer in Paperless → Vergleich direkt öffnen
if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) {
setCompareForSection(idx);
}
} else if (failed.length > 0) { } else if (failed.length > 0) {
const msg = failed.map((f) => f.message ?? f.actionType).join(', '); const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
setStepAt(idx, { status: 'error', message: msg, results }); setStepAt(idx, { status: 'error', message: msg, results });
@@ -281,6 +182,9 @@ function PostprocessWizardModal({
} else if (results.length === 0) { } else if (results.length === 0) {
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results }); setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
setAwaitingDecision(true); setAwaitingDecision(true);
} else if (entscheidung?.aktion === 'verwerfen') {
setStepAt(idx, { status: 'skipped', message: 'Verworfen', results });
setCurrentSection((s) => s + 1);
} else { } else {
setStepAt(idx, { status: 'success', results }); setStepAt(idx, { status: 'success', results });
setCurrentSection((s) => s + 1); setCurrentSection((s) => s + 1);
@@ -303,9 +207,19 @@ function PostprocessWizardModal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]); }, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
const handleReplace = () => { const handleVersion = () => {
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
if (documentId === undefined) return;
setCompareForSection(null);
setAwaitingDecision(false); setAwaitingDecision(false);
void processSection(currentSection, true); void processSection(currentSection, { aktion: 'version', documentId });
};
// Folgende Aktionen (z. B. Export) laufen trotzdem – nur der Paperless-Upload entfällt
const handleVerwerfen = () => {
setCompareForSection(null);
setAwaitingDecision(false);
void processSection(currentSection, { aktion: 'verwerfen' });
}; };
const handleSkip = () => { const handleSkip = () => {
@@ -388,16 +302,13 @@ function PostprocessWizardModal({
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span> <span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
{idx === currentSection && awaitingDecision && ( {idx === currentSection && awaitingDecision && (
<Space wrap> <Space wrap>
<Button type="primary" size="small" onClick={handleReplace}>
Neue Version erstellen
</Button>
{dupDocId !== null && ( {dupDocId !== null && (
<Button size="small" onClick={() => setCompareForSection(idx)}> <Button type="primary" size="small" onClick={() => setCompareForSection(idx)}>
Vergleichen Vergleichen
</Button> </Button>
)} )}
<Button size="small" onClick={handleSkip}> <Button size="small" danger onClick={handleVerwerfen}>
Überspringen Verwerfen
</Button> </Button>
</Space> </Space>
)} )}
@@ -508,23 +419,33 @@ function PostprocessWizardModal({
) : ( ) : (
<Steps direction="vertical" current={stepperCurrent} items={allItems} /> <Steps direction="vertical" current={stepperCurrent} items={allItems} />
)} )}
<CompareModal {compareForSection !== null && (() => {
open={compareForSection !== null} const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
paperlessDocumentId={ const docId = dup?.duplicateOfDocumentId ?? null;
compareForSection !== null // Seiten so, wie das Backend den Abschnitt hochlädt (manuelle Splits zählen dort nicht)
? steps[compareForSection]?.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null const pages = dup?.seiten ?? documents[compareForSection]?.pages ?? [];
: null const kandidaten = dup?.duplikatKandidaten ?? [];
} const pruefung: BelegnummerPruefung | null = kandidaten.length
inboxDocumentId={documentId} ? {
onClose={() => setCompareForSection(null)} status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig',
onCreateNewVersion={() => { documentId: dup?.versionMoeglich ? docId : null,
// TODO: wird im nächsten Schritt implementiert kandidaten,
}} }
onSkip={() => { : null;
setCompareForSection(null); return (
handleSkip(); <DuplikatVergleichModal
}} open
/> belegnummer={dup?.belegnummer ?? ''}
pruefung={pruefung}
paperlessDocumentId={docId}
ladeNeuesPdf={() => inboxApi.downloadSegmentBlob(documentId, pages)}
ladeKennzahlen={() => inboxApi.vergleich(documentId, docId ?? 0, pages)}
onVersion={handleVersion}
onVerwerfen={handleVerwerfen}
onClose={() => setCompareForSection(null)}
/>
);
})()}
</Modal> </Modal>
); );
} }