Compare commits
10
Commits
da02baa2b9
...
c1afa02ee2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1afa02ee2 | ||
|
|
79dadb9c93 | ||
|
|
32b5228f68 | ||
|
|
98021fdcbf | ||
|
|
fffbf692b2 | ||
|
|
21a447e915 | ||
|
|
bdc85f5f1f | ||
|
|
170eb72c70 | ||
|
|
8e0c609802 | ||
|
|
57a460ffa2 |
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:
|
||||
|
||||
- CF7 = interne Belegnummer (z. B. `2026-000123`)
|
||||
- ASN = interne Belegnummer ohne Bindestrich (z. B. `2026000123`) – auch im
|
||||
Posteingang, unabhängig von einem im Template separat hinterlegten ASN-Feld.
|
||||
- ASN = die ASN, die das Dokument beim Upload **tatsächlich bekäme**: im
|
||||
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
|
||||
ein** Dokument zeigen. Zeigen CF7- und ASN-Treffer auf verschiedene Dokumente
|
||||
|
||||
@@ -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).
|
||||
- [ ] **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.
|
||||
- [ ] **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 { EmailImportService } from './email-import.service';
|
||||
import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import {
|
||||
kennzahlenAusPdf,
|
||||
type VergleichKennzahlen,
|
||||
} from '../paperless/vergleich-kennzahlen.util';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
|
||||
@@ -24,6 +29,7 @@ export class EmailImportController {
|
||||
constructor(
|
||||
private readonly importService: EmailImportService,
|
||||
private readonly pageCache: EmailPageCacheService,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
) {}
|
||||
|
||||
// --- Korrespondenten Mapping ---
|
||||
@@ -118,6 +124,51 @@ export class EmailImportController {
|
||||
return { isDuplicate };
|
||||
}
|
||||
|
||||
// --- Duplikatvergleich ---
|
||||
/** PDF eines Anhangs bzw. Seitenbereichs ohne Stempel (rechte Seite im Vergleich). */
|
||||
@Get('attachments/:attachmentId/pdf')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
async getAttachmentPdf(
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
@Query('start') start: string | undefined,
|
||||
@Query('end') end: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const pdf = await this.importService.getAttachmentPdf(
|
||||
parseInt(attachmentId, 10),
|
||||
seitenbereich(start, end),
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename="anhang-${attachmentId}.pdf"`,
|
||||
);
|
||||
res.send(pdf);
|
||||
}
|
||||
|
||||
@Post('attachments/:attachmentId/vergleich')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
async vergleich(
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
@Body()
|
||||
body: {
|
||||
paperlessDocumentId: number;
|
||||
pages?: { start: number; end: number };
|
||||
},
|
||||
): Promise<VergleichKennzahlen> {
|
||||
const pdf = await this.importService.getAttachmentPdf(
|
||||
parseInt(attachmentId, 10),
|
||||
body.pages,
|
||||
);
|
||||
const [vorhanden, neu] = await Promise.all([
|
||||
this.paperlessService.getDocumentKennzahlen(
|
||||
Number(body.paperlessDocumentId),
|
||||
),
|
||||
kennzahlenAusPdf(pdf),
|
||||
]);
|
||||
return { vorhanden, neu };
|
||||
}
|
||||
|
||||
// --- Print Preview ---
|
||||
@Post('attachments/:attachmentId/print-preview')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
@@ -202,3 +253,12 @@ export class EmailImportController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seitenbereich(
|
||||
start: string | undefined,
|
||||
end: string | undefined,
|
||||
): { start: number; end: number } | undefined {
|
||||
const s = parseInt(start ?? '', 10);
|
||||
const e = parseInt(end ?? '', 10);
|
||||
return Number.isNaN(s) || Number.isNaN(e) ? undefined : { start: s, end: e };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
@@ -14,6 +14,7 @@ import { PdfService } from '../preprocessing/pdf.service';
|
||||
import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { DokumentVersionService } from '../paperless/dokument-version.service';
|
||||
|
||||
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
@@ -29,11 +30,19 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
andWhere: 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 paperlessService: {
|
||||
checksumExists: jest.Mock;
|
||||
uploadDocument: jest.Mock;
|
||||
validateAsnNotExists: jest.Mock;
|
||||
};
|
||||
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
|
||||
|
||||
@@ -50,11 +59,25 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
Checksum: 'abc123',
|
||||
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() };
|
||||
paperlessService = {
|
||||
checksumExists: jest.fn().mockResolvedValue(false),
|
||||
uploadDocument: jest.fn(),
|
||||
validateAsnNotExists: jest.fn(),
|
||||
};
|
||||
fingerprints = {
|
||||
ensure: jest.fn().mockResolvedValue(null),
|
||||
@@ -65,24 +88,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
providers: [
|
||||
EmailImportService,
|
||||
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Email), useValue: {} },
|
||||
{ provide: getRepositoryToken(Email), useValue: emailRepo },
|
||||
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
|
||||
{ provide: getRepositoryToken(Content), useValue: contentRepo },
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondentEmailMapping),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Task),
|
||||
useValue: {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
|
||||
},
|
||||
},
|
||||
{ provide: getRepositoryToken(Task), useValue: taskRepo },
|
||||
{ provide: PaperlessService, useValue: paperlessService },
|
||||
{ provide: PdfService, useValue: {} },
|
||||
{ provide: EmailPageCacheService, useValue: {} },
|
||||
{ provide: ImapFolderService, useValue: {} },
|
||||
{ provide: AttachmentFingerprintService, useValue: fingerprints },
|
||||
{ provide: DokumentVersionService, useValue: dokumentVersion },
|
||||
],
|
||||
}).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', () => {
|
||||
const importDaten = (splitRanges?: { start: number; end: number }[]) => ({
|
||||
emailDate: '2026-09-25',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -21,6 +22,7 @@ import * as QRCode from 'qrcode';
|
||||
import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { DokumentVersionService } from '../paperless/dokument-version.service';
|
||||
import { PdfService } from '../preprocessing/pdf.service';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -73,6 +75,7 @@ export class EmailImportService {
|
||||
private readonly pageCache: EmailPageCacheService,
|
||||
private readonly imapFolderService: ImapFolderService,
|
||||
private readonly fingerprints: AttachmentFingerprintService,
|
||||
private readonly dokumentVersion: DokumentVersionService,
|
||||
) {}
|
||||
|
||||
async ensurePreviews(emailId: number): Promise<void> {
|
||||
@@ -348,9 +351,13 @@ export class EmailImportService {
|
||||
}
|
||||
|
||||
// --- Print Preview ---
|
||||
async generatePrintPdf(
|
||||
/**
|
||||
* PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999`
|
||||
* = bis zum Ende) – ohne Stempel.
|
||||
*/
|
||||
async getAttachmentPdf(
|
||||
attachmentId: number,
|
||||
barcodeData: any,
|
||||
pages?: { start: number; end: number },
|
||||
): Promise<Buffer> {
|
||||
const content = await this.contentRepo.findOne({
|
||||
where: { AttachmentEntityId: attachmentId },
|
||||
@@ -358,28 +365,44 @@ export class EmailImportService {
|
||||
if (!content)
|
||||
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
|
||||
|
||||
let pdfBytes: Buffer = content.Content1;
|
||||
if (!pages) return content.Content1;
|
||||
|
||||
const pages: { start: number; end: number } | undefined =
|
||||
barcodeData._pages;
|
||||
if (pages) {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
const total = pdfDoc.getPageCount();
|
||||
const startIdx = Math.max(1, pages.start) - 1;
|
||||
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
|
||||
const sliced = await PDFDocument.create();
|
||||
const indices = Array.from(
|
||||
{ length: endIdx - startIdx + 1 },
|
||||
(_, i) => startIdx + i,
|
||||
const pdfDoc = await PDFDocument.load(content.Content1, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
const total = pdfDoc.getPageCount();
|
||||
const ende = pages.end === 999 ? total : Math.min(pages.end, total);
|
||||
if (
|
||||
!Number.isInteger(pages.start) ||
|
||||
!Number.isInteger(ende) ||
|
||||
pages.start < 1 ||
|
||||
pages.start > ende
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`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 pdfBytes = await this.getAttachmentPdf(
|
||||
attachmentId,
|
||||
_pages as { start: number; end: number } | undefined,
|
||||
);
|
||||
return this.applyBarcodeToPdf(pdfBytes, barcode);
|
||||
}
|
||||
|
||||
@@ -559,6 +582,8 @@ export class EmailImportService {
|
||||
jahr: string;
|
||||
};
|
||||
belegnummer?: string;
|
||||
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
|
||||
versionZuDokumentId?: number | null;
|
||||
}[];
|
||||
emailDate: string;
|
||||
}): 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: {
|
||||
jobId?: string;
|
||||
attachments: {
|
||||
@@ -641,6 +691,8 @@ export class EmailImportService {
|
||||
jahr: string;
|
||||
};
|
||||
belegnummer?: string;
|
||||
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
|
||||
versionZuDokumentId?: number | null;
|
||||
}[];
|
||||
emailDate: string;
|
||||
}): Promise<{ success: boolean; results: any[] }> {
|
||||
@@ -668,7 +720,7 @@ export class EmailImportService {
|
||||
const baseFilename = sanitizeFilename(
|
||||
attachmentEntity.FileName.replace(/\.pdf$/i, ''),
|
||||
);
|
||||
const paperlessIds: any = {};
|
||||
const paperlessIds: Record<string, number> = {};
|
||||
|
||||
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
|
||||
if (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 { Repository } from 'typeorm';
|
||||
import * as fs from 'fs/promises';
|
||||
@@ -18,6 +23,13 @@ import {
|
||||
extractSectionToTemp,
|
||||
} from './edit-applier';
|
||||
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 {
|
||||
if (!s) return null;
|
||||
@@ -45,6 +57,16 @@ export interface ActionResult {
|
||||
skipped?: boolean;
|
||||
message?: string;
|
||||
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 {
|
||||
@@ -56,6 +78,9 @@ interface PaperlessRunResult {
|
||||
skipped?: boolean;
|
||||
message?: string;
|
||||
duplicateOfDocumentId?: number;
|
||||
versionMoeglich?: boolean;
|
||||
duplikatKandidaten?: number[];
|
||||
belegnummer?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -65,6 +90,7 @@ export class InboxPostprocessorService {
|
||||
constructor(
|
||||
private readonly pageCache: PageCacheService,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
private readonly dokumentVersion: DokumentVersionService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly exportService: ExportService,
|
||||
@InjectRepository(InboxPostprocessingAction)
|
||||
@@ -82,8 +108,14 @@ export class InboxPostprocessorService {
|
||||
preferredUsername: string | null,
|
||||
sectionOffset: number = 0,
|
||||
processOnlyOne: boolean = false,
|
||||
replaceDuplicate: boolean = false,
|
||||
entscheidung?: PaperlessEntscheidung,
|
||||
): 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 } });
|
||||
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
|
||||
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
|
||||
@@ -252,16 +284,31 @@ export class InboxPostprocessorService {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
for (const action of tplActions) {
|
||||
for (const action of aktionenAbEntscheidung(
|
||||
tplActions,
|
||||
entscheidung,
|
||||
)) {
|
||||
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 {
|
||||
const res = await this.runPaperless(
|
||||
action.Content ?? {},
|
||||
sectionPdfPath,
|
||||
variables,
|
||||
defaultFilenameBase,
|
||||
replaceDuplicate,
|
||||
entscheidung,
|
||||
);
|
||||
results.push({
|
||||
sectionIndex: currentSectionIndex,
|
||||
@@ -271,6 +318,15 @@ export class InboxPostprocessorService {
|
||||
skipped: res.skipped,
|
||||
message: res.message,
|
||||
duplicateOfDocumentId: res.duplicateOfDocumentId,
|
||||
versionMoeglich: res.versionMoeglich,
|
||||
duplikatKandidaten: res.duplikatKandidaten,
|
||||
belegnummer: res.belegnummer,
|
||||
seiten: res.skipped
|
||||
? originalSeitenDesAbschnitts(
|
||||
survivingOriginalPages,
|
||||
pageIndices,
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
if (res.skipped) {
|
||||
abortProcessing = true;
|
||||
@@ -404,7 +460,7 @@ export class InboxPostprocessorService {
|
||||
pdfPath: string,
|
||||
variables: Record<string, string>,
|
||||
defaultFilenameBase?: string,
|
||||
replaceDuplicate: boolean = false,
|
||||
entscheidung?: PaperlessEntscheidung,
|
||||
): Promise<PaperlessRunResult> {
|
||||
// 1. Interne Belegnummer auflösen (Pflicht)
|
||||
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
|
||||
@@ -416,84 +472,77 @@ export class InboxPostprocessorService {
|
||||
if (!interneBelegnummer)
|
||||
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 asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
|
||||
let archiveSerialNumber: number | undefined;
|
||||
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;
|
||||
}
|
||||
const archiveSerialNumber = ermittleUploadAsn(asn, interneBelegnummer);
|
||||
|
||||
if (replaceDuplicate) {
|
||||
this.logger.log(
|
||||
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`,
|
||||
);
|
||||
} else {
|
||||
// 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,
|
||||
// 3. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen
|
||||
if (entscheidung?.aktion === 'version') {
|
||||
await this.dokumentVersion.haengeVersionAn(
|
||||
entscheidung.documentId,
|
||||
pdfPath,
|
||||
interneBelegnummer,
|
||||
archiveSerialNumber ?? null,
|
||||
);
|
||||
if (cf7DocId !== null) {
|
||||
this.logger.warn(
|
||||
`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`,
|
||||
);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
|
||||
duplicateOfDocumentId: cf7DocId,
|
||||
};
|
||||
}
|
||||
this.logger.log(
|
||||
`Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
|
||||
);
|
||||
return {
|
||||
message: `Als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Duplikat-Check Paperless API (archive_serial_number)
|
||||
if (archiveSerialNumber !== undefined) {
|
||||
const asnDocId =
|
||||
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
|
||||
if (asnDocId !== null) {
|
||||
this.logger.warn(
|
||||
`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`,
|
||||
);
|
||||
return {
|
||||
skipped: true,
|
||||
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
|
||||
duplicateOfDocumentId: asnDocId,
|
||||
};
|
||||
}
|
||||
}
|
||||
// 4. Duplikat-Check Paperless: CF7 + Upload-ASN
|
||||
const pruefung = await this.paperlessService.pruefeBelegnummer(
|
||||
interneBelegnummer,
|
||||
archiveSerialNumber ?? null,
|
||||
);
|
||||
if (pruefung.status !== 'frei') {
|
||||
this.logger.warn(
|
||||
`Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
|
||||
);
|
||||
return {
|
||||
skipped: true,
|
||||
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
|
||||
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)',
|
||||
};
|
||||
}
|
||||
// 5. 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,
|
||||
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
|
||||
|
||||
@@ -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 { InboxService } from './inbox.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 { UserSettingsService } from '../user-settings/user-settings.service';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import {
|
||||
kennzahlenAusPdf,
|
||||
type VergleichKennzahlen,
|
||||
} from '../paperless/vergleich-kennzahlen.util';
|
||||
|
||||
@Controller('api/inbox')
|
||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||
@@ -30,6 +36,7 @@ export class InboxController {
|
||||
private readonly postprocessor: InboxPostprocessorService,
|
||||
private readonly barcodeScanner: BarcodeScannerService,
|
||||
private readonly userSettingsService: UserSettingsService,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -137,7 +144,7 @@ export class InboxController {
|
||||
body: {
|
||||
sectionOffset?: number;
|
||||
processOnlyOne?: boolean;
|
||||
replaceDuplicate?: boolean;
|
||||
entscheidung?: PaperlessEntscheidung;
|
||||
},
|
||||
) {
|
||||
const preferredUsername: string | null =
|
||||
@@ -147,7 +154,7 @@ export class InboxController {
|
||||
preferredUsername,
|
||||
body?.sectionOffset ?? 0,
|
||||
body?.processOnlyOne ?? false,
|
||||
body?.replaceDuplicate ?? false,
|
||||
body?.entscheidung,
|
||||
);
|
||||
return { results, totalSections };
|
||||
}
|
||||
@@ -250,6 +257,29 @@ export class InboxController {
|
||||
return new StreamableFile(Readable.from(buffer));
|
||||
}
|
||||
|
||||
/** Seiten-/Wortzahl des Abschnitts und des vorhandenen Paperless-Dokuments. */
|
||||
@Post(':id/vergleich')
|
||||
async vergleich(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { paperlessDocumentId: number; pages: number[] },
|
||||
@Request() req: any,
|
||||
): Promise<VergleichKennzahlen> {
|
||||
const preferredUsername: string | null =
|
||||
req.user?.preferredUsername ?? null;
|
||||
const { buffer } = await this.inboxService.getSegmentPdfBuffer(
|
||||
id,
|
||||
preferredUsername,
|
||||
body.pages ?? [],
|
||||
);
|
||||
const [vorhanden, neu] = await Promise.all([
|
||||
this.paperlessService.getDocumentKennzahlen(
|
||||
Number(body.paperlessDocumentId),
|
||||
),
|
||||
kennzahlenAusPdf(buffer),
|
||||
]);
|
||||
return { vorhanden, neu };
|
||||
}
|
||||
|
||||
@Post(':id/send-email')
|
||||
@HttpCode(204)
|
||||
async sendEmail(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module';
|
||||
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
|
||||
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
||||
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
import { PaperlessModule } from '../paperless/paperless.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
InboxPostprocessorModule,
|
||||
PostprocessingModule,
|
||||
UserSettingsModule,
|
||||
PaperlessModule,
|
||||
],
|
||||
controllers: [InboxController, ClientsController],
|
||||
providers: [InboxService, InboxMigrationService],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/** Ist die interne Belegnummer in Paperless schon vergeben (CF7 + ASN)? */
|
||||
@Get('belegnummer-pruefung')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
async pruefeBelegnummer(@Query('belegnummer') belegnummer: string) {
|
||||
return this.paperlessService.pruefeBelegnummer(belegnummer ?? '');
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
async getDocuments(
|
||||
@Query('search') search?: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PaperlessService } from './paperless.service';
|
||||
import { PaperlessController } from './paperless.controller';
|
||||
import { PaperlessProcessorService } from './paperless-processor.service';
|
||||
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
|
||||
import { DokumentVersionService } from './dokument-version.service';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import { DocumentField } from '../database/entities/document-field.entity';
|
||||
import { Task } from '../database/entities/task.entity';
|
||||
@@ -33,7 +34,12 @@ import { ErechnungModule } from '../erechnung/erechnung.module';
|
||||
PaperlessService,
|
||||
PaperlessProcessorService,
|
||||
PaperlessTaskProcessorService,
|
||||
DokumentVersionService,
|
||||
],
|
||||
exports: [
|
||||
PaperlessService,
|
||||
PaperlessProcessorService,
|
||||
DokumentVersionService,
|
||||
],
|
||||
exports: [PaperlessService, PaperlessProcessorService],
|
||||
})
|
||||
export class PaperlessModule {}
|
||||
@@ -4,6 +4,20 @@ import axios, { type AxiosInstance } from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import FormData = require('form-data');
|
||||
import { type Kennzahlen, zaehleWoerter } from './vergleich-kennzahlen.util';
|
||||
import {
|
||||
type BelegnummerPruefung,
|
||||
belegnummerZuAsn,
|
||||
klassifiziereTreffer,
|
||||
} from './belegnummer-pruefung.util';
|
||||
|
||||
/** Ausschnitt eines Paperless-Dokuments, wie ihn die Belegnummer-Suche braucht. */
|
||||
interface PaperlessTrefferDokument {
|
||||
id: number;
|
||||
root_document?: number | null;
|
||||
archive_serial_number?: number | null;
|
||||
custom_fields?: { field: number; value: string | number | boolean | null }[];
|
||||
}
|
||||
|
||||
export interface PaperlessTaskStatus {
|
||||
taskId: string;
|
||||
@@ -132,6 +146,34 @@ export class PaperlessService {
|
||||
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[]> {
|
||||
const response = await this.client.get('/users/');
|
||||
return response.data?.results ?? [];
|
||||
@@ -147,6 +189,18 @@ export class PaperlessService {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Seiten- und Wortzahl eines Paperless-Dokuments (aktuelle Fassung). */
|
||||
async getDocumentKennzahlen(id: number): Promise<Kennzahlen> {
|
||||
const response = await this.client.get<{
|
||||
page_count?: number | null;
|
||||
content?: string | null;
|
||||
}>(`/documents/${id}/`);
|
||||
return {
|
||||
seiten: response.data.page_count ?? null,
|
||||
woerter: zaehleWoerter(response.data.content),
|
||||
};
|
||||
}
|
||||
|
||||
async getInboxDocuments(): Promise<any[]> {
|
||||
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
||||
const response = await this.client.get('/documents/', {
|
||||
@@ -396,4 +450,72 @@ export class PaperlessService {
|
||||
);
|
||||
return match ? Number(match.id) : null;
|
||||
}
|
||||
|
||||
/** Stamm-ID eines Treffers – Versionen zeigen über `root_document` auf ihren Stamm. */
|
||||
private static rootId(doc: PaperlessTrefferDokument): number {
|
||||
return Number(doc.root_document ?? doc.id);
|
||||
}
|
||||
|
||||
private async sucheDokumente(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<PaperlessTrefferDokument[]> {
|
||||
const response = await this.client.get<{
|
||||
results?: PaperlessTrefferDokument[];
|
||||
}>('/documents/', {
|
||||
params: { ...params, page_size: 100, truncate_content: true },
|
||||
});
|
||||
return response.data.results ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Stammdokumente, deren Custom Field exakt (case-insensitiv) den Wert hat.
|
||||
*/
|
||||
async findRootDocumentIdsByCustomField(
|
||||
fieldId: number,
|
||||
value: string,
|
||||
): Promise<number[]> {
|
||||
const treffer = await this.sucheDokumente({
|
||||
custom_field_query: JSON.stringify([fieldId, 'icontains', value]),
|
||||
});
|
||||
const valueLower = value.toLowerCase();
|
||||
return treffer
|
||||
.filter((doc) =>
|
||||
(doc.custom_fields ?? []).some(
|
||||
(cf) =>
|
||||
cf.field === fieldId &&
|
||||
String(cf.value ?? '').toLowerCase() === valueLower,
|
||||
),
|
||||
)
|
||||
.map((doc) => PaperlessService.rootId(doc));
|
||||
}
|
||||
|
||||
/** Alle Stammdokumente mit genau dieser ASN. */
|
||||
async findRootDocumentIdsByAsn(asn: number): Promise<number[]> {
|
||||
if (!Number.isFinite(asn)) return [];
|
||||
const treffer = await this.sucheDokumente({ archive_serial_number: asn });
|
||||
return treffer
|
||||
.filter((doc) => Number(doc.archive_serial_number) === asn)
|
||||
.map((doc) => PaperlessService.rootId(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob die interne Belegnummer in Paperless schon vergeben ist – über
|
||||
* CF7 und die 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 type { VergleichKennzahlen } from './paperless';
|
||||
|
||||
export interface CorrespondentMapping {
|
||||
Id: number;
|
||||
@@ -19,6 +20,8 @@ export interface AttachmentImportData {
|
||||
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
|
||||
belegnummer?: string;
|
||||
isDuplicate?: boolean;
|
||||
/** Stammdokument in Paperless, an das der Beleg als neue Version kommt */
|
||||
versionZuDokumentId?: number | null;
|
||||
}
|
||||
|
||||
export const emailImportApi = {
|
||||
@@ -50,6 +53,23 @@ export const emailImportApi = {
|
||||
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
|
||||
},
|
||||
|
||||
attachmentPdfBlob: async (attachmentId: number, pages?: { start: number; end: number }): Promise<Blob> => {
|
||||
const res = await api.get(`/api/email-import/attachments/${attachmentId}/pdf`, {
|
||||
params: pages ? { start: pages.start, end: pages.end } : undefined,
|
||||
responseType: 'blob',
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
vergleich: async (
|
||||
attachmentId: number,
|
||||
paperlessDocumentId: number,
|
||||
pages?: { start: number; end: number },
|
||||
): Promise<VergleichKennzahlen> => {
|
||||
const res = await api.post(`/api/email-import/attachments/${attachmentId}/vergleich`, { paperlessDocumentId, pages });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
printPreview: async (attachmentId: number, barcodeData: any, pages?: { start: number; end: number }): Promise<Blob> => {
|
||||
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import api from './client';
|
||||
import type { BarcodeActionType } from './barcode-templates';
|
||||
import type { VergleichKennzahlen } from './paperless';
|
||||
|
||||
export type InboxSource = 'all' | 'user';
|
||||
|
||||
@@ -86,7 +87,7 @@ export const inboxApi = {
|
||||
|
||||
postprocess: (
|
||||
id: string,
|
||||
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
|
||||
opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung },
|
||||
) =>
|
||||
api
|
||||
.post<{ results: PostprocessActionResult[]; totalSections: number }>(
|
||||
@@ -107,6 +108,11 @@ export const inboxApi = {
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
vergleich: (id: string, paperlessDocumentId: number, pages: number[]) =>
|
||||
api
|
||||
.post<VergleichKennzahlen>(`/api/inbox/${encodeURIComponent(id)}/vergleich`, { paperlessDocumentId, pages })
|
||||
.then((r) => r.data),
|
||||
|
||||
downloadSegmentBlob: (id: string, pages: number[]) =>
|
||||
api
|
||||
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
|
||||
@@ -126,6 +132,9 @@ export const inboxApi = {
|
||||
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 {
|
||||
sectionIndex: number;
|
||||
actionId: number;
|
||||
@@ -134,6 +143,13 @@ export interface PostprocessActionResult {
|
||||
skipped?: boolean;
|
||||
message?: string;
|
||||
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 = {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
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 {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -62,4 +82,6 @@ export const paperlessApi = {
|
||||
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
|
||||
getDocumentPdfBlob: (id: number) =>
|
||||
api.get<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
|
||||
pruefeBelegnummer: (belegnummer: string) =>
|
||||
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
|
||||
};
|
||||
@@ -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 { emailImportApi, type AttachmentImportData } from '../api/email-import';
|
||||
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 {
|
||||
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
|
||||
} from '@ant-design/icons';
|
||||
import PdfSplitViewer from './PdfSplitViewer';
|
||||
import BarcodePositioner from './BarcodePositioner';
|
||||
import { DuplikatVergleichModal } from './DuplikatVergleichModal';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -43,6 +44,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
// Eingangsdatum per item
|
||||
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
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState('');
|
||||
@@ -78,6 +84,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
});
|
||||
setEingangsdaten(initialDates);
|
||||
setBarcodes(initialBarcodes);
|
||||
setVersionEntscheidung({});
|
||||
setVergleichQueue([]);
|
||||
|
||||
// Load correspondents and try to find suggestion
|
||||
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 () => {
|
||||
if (currentStep === 0) {
|
||||
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
|
||||
@@ -372,6 +446,20 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
} finally {
|
||||
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();
|
||||
}
|
||||
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 entscheidung = versionEntscheidung[item.virtualId];
|
||||
|
||||
finalData.push({
|
||||
...item,
|
||||
versionZuDokumentId: entscheidung?.belegnummer === finalBelegnummer ? entscheidung.documentId : null,
|
||||
splitRanges: item.pages ? [item.pages] : undefined,
|
||||
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
|
||||
belegnummer: finalBelegnummer,
|
||||
@@ -765,6 +855,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
<Text type="secondary">
|
||||
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
|
||||
</Text>
|
||||
{versionEntscheidung[main.virtualId]?.belegnummer === belegnr && (
|
||||
<Tag color="purple">
|
||||
wird als neue Version zu Dokument {versionEntscheidung[main.virtualId].documentId} hinzugefügt
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{mainAttachments.length > 0 && (
|
||||
<div style={{ marginTop: 12, paddingLeft: 16 }}>
|
||||
@@ -855,6 +950,26 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
{currentStep === 2 && renderStep3()}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
|
||||
import type { BelegnummerPruefung } from '../api/paperless';
|
||||
import { DuplikatVergleichModal } from '../components/DuplikatVergleichModal';
|
||||
import { userSettingsApi, type SenderOption } from '../api/userSettings';
|
||||
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';
|
||||
|
||||
interface WizardStep {
|
||||
@@ -259,7 +156,7 @@ function PostprocessWizardModal({
|
||||
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;
|
||||
setBusy(true);
|
||||
setStepAt(idx, { status: 'processing', message: undefined });
|
||||
@@ -267,13 +164,17 @@ function PostprocessWizardModal({
|
||||
const { results } = await inboxApi.postprocess(documentId, {
|
||||
sectionOffset: idx,
|
||||
processOnlyOne: true,
|
||||
replaceDuplicate,
|
||||
entscheidung,
|
||||
});
|
||||
const skipped = results.find((r) => r.skipped);
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (skipped) {
|
||||
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
|
||||
setAwaitingDecision(true);
|
||||
// Treffer in Paperless → Vergleich direkt öffnen
|
||||
if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) {
|
||||
setCompareForSection(idx);
|
||||
}
|
||||
} else if (failed.length > 0) {
|
||||
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
|
||||
setStepAt(idx, { status: 'error', message: msg, results });
|
||||
@@ -281,6 +182,9 @@ function PostprocessWizardModal({
|
||||
} else if (results.length === 0) {
|
||||
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
|
||||
setAwaitingDecision(true);
|
||||
} else if (entscheidung?.aktion === 'verwerfen') {
|
||||
setStepAt(idx, { status: 'skipped', message: 'Verworfen', results });
|
||||
setCurrentSection((s) => s + 1);
|
||||
} else {
|
||||
setStepAt(idx, { status: 'success', results });
|
||||
setCurrentSection((s) => s + 1);
|
||||
@@ -303,9 +207,19 @@ function PostprocessWizardModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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);
|
||||
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 = () => {
|
||||
@@ -388,16 +302,13 @@ function PostprocessWizardModal({
|
||||
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
|
||||
{idx === currentSection && awaitingDecision && (
|
||||
<Space wrap>
|
||||
<Button type="primary" size="small" onClick={handleReplace}>
|
||||
Neue Version erstellen
|
||||
</Button>
|
||||
{dupDocId !== null && (
|
||||
<Button size="small" onClick={() => setCompareForSection(idx)}>
|
||||
<Button type="primary" size="small" onClick={() => setCompareForSection(idx)}>
|
||||
Vergleichen
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" onClick={handleSkip}>
|
||||
Überspringen
|
||||
<Button size="small" danger onClick={handleVerwerfen}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
@@ -508,23 +419,33 @@ function PostprocessWizardModal({
|
||||
) : (
|
||||
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
|
||||
)}
|
||||
<CompareModal
|
||||
open={compareForSection !== null}
|
||||
paperlessDocumentId={
|
||||
compareForSection !== null
|
||||
? steps[compareForSection]?.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null
|
||||
: null
|
||||
}
|
||||
inboxDocumentId={documentId}
|
||||
onClose={() => setCompareForSection(null)}
|
||||
onCreateNewVersion={() => {
|
||||
// TODO: wird im nächsten Schritt implementiert
|
||||
}}
|
||||
onSkip={() => {
|
||||
setCompareForSection(null);
|
||||
handleSkip();
|
||||
}}
|
||||
/>
|
||||
{compareForSection !== null && (() => {
|
||||
const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
|
||||
const docId = dup?.duplicateOfDocumentId ?? null;
|
||||
// Seiten so, wie das Backend den Abschnitt hochlädt (manuelle Splits zählen dort nicht)
|
||||
const pages = dup?.seiten ?? documents[compareForSection]?.pages ?? [];
|
||||
const kandidaten = dup?.duplikatKandidaten ?? [];
|
||||
const pruefung: BelegnummerPruefung | null = kandidaten.length
|
||||
? {
|
||||
status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig',
|
||||
documentId: dup?.versionMoeglich ? docId : null,
|
||||
kandidaten,
|
||||
}
|
||||
: null;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user