fix(mail): Duplikate auch bei gerade laufenden Importen erkennen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Wurde dieselbe Mail zweimal weitergeleitet, erkannte der Import-Assistent das Duplikat nicht, solange Paperless das erste Dokument noch verarbeitete – geprüft wurde nur die Checksumme in Paperless. - checkDuplicate() berücksichtigt zusätzlich offene Paperless-Tasks von Anhängen mit gleicher Checksumme (gleicher Seitenbereich) - Assistent prüft erneut vor dem Abrufen der Belegnummern und vor dem Import; Duplikate werden ignoriert, reservierte Nummern freigegeben - executeImport prüft serverseitig vor Upload und Festlegen der Nummer, sperrt laufende Importe gegen parallele Doppelimporte und meldet 409 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3cea3fb239
commit
534b313054
5 files changed
+472
-16
No files matched your search
@@ -95,15 +95,24 @@ export class EmailImportController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// --- Split Checksum Check ---
|
||||
// --- Duplikatprüfung ---
|
||||
@Post('attachments/:attachmentId/check-duplicate')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
async checkDuplicate(@Param('attachmentId') attachmentId: string) {
|
||||
const isDuplicate = await this.importService.checkDuplicate(
|
||||
parseInt(attachmentId, 10),
|
||||
);
|
||||
return { isDuplicate };
|
||||
}
|
||||
|
||||
@Post('attachments/:attachmentId/check-split-checksum')
|
||||
@RequirePermissions(Permission.VIEW_MAIL)
|
||||
async checkSplitChecksum(
|
||||
@Param('attachmentId') attachmentId: number,
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
@Body() body: { pages: { start: number; end: number } },
|
||||
) {
|
||||
const isDuplicate = await this.importService.checkSplitChecksum(
|
||||
attachmentId,
|
||||
const isDuplicate = await this.importService.checkDuplicate(
|
||||
parseInt(attachmentId, 10),
|
||||
body.pages,
|
||||
);
|
||||
return { isDuplicate };
|
||||
@@ -187,6 +196,8 @@ export class EmailImportController {
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error executing import: ${err.message}`);
|
||||
// Gezielte Fehler (z.B. 409 bei Duplikaten) unverändert weiterreichen
|
||||
if (err instanceof HttpException) throw err;
|
||||
throw new HttpException(err.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { EmailImportService } from './email-import.service';
|
||||
import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Email } from '../database/entities/email.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
||||
import { Task } from '../database/entities/task.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { PdfService } from '../preprocessing/pdf.service';
|
||||
import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
|
||||
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
for (let i = 0; i < anzahl; i++) doc.addPage();
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
|
||||
describe('EmailImportService – Duplikatprüfung', () => {
|
||||
let service: EmailImportService;
|
||||
let taskQuery: {
|
||||
innerJoin: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
getCount: jest.Mock;
|
||||
};
|
||||
let attachmentRepo: { findOne: jest.Mock };
|
||||
let contentRepo: { findOne: jest.Mock };
|
||||
let paperlessService: {
|
||||
checksumExists: jest.Mock;
|
||||
uploadDocument: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
taskQuery = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
attachmentRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
Id: 7,
|
||||
Checksum: 'abc123',
|
||||
FileName: 'rechnung.pdf',
|
||||
}),
|
||||
};
|
||||
contentRepo = { findOne: jest.fn() };
|
||||
paperlessService = {
|
||||
checksumExists: jest.fn().mockResolvedValue(false),
|
||||
uploadDocument: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailImportService,
|
||||
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Email), useValue: {} },
|
||||
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
|
||||
{ provide: getRepositoryToken(Content), useValue: contentRepo },
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondentEmailMapping),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Task),
|
||||
useValue: {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(taskQuery),
|
||||
},
|
||||
},
|
||||
{ provide: PaperlessService, useValue: paperlessService },
|
||||
{ provide: PdfService, useValue: {} },
|
||||
{ provide: EmailPageCacheService, useValue: {} },
|
||||
{ provide: ImapFolderService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(EmailImportService);
|
||||
});
|
||||
|
||||
describe('ganzer Anhang', () => {
|
||||
it('erkennt ein Duplikat, dessen Import noch in Paperless verarbeitet wird', async () => {
|
||||
taskQuery.getCount.mockResolvedValue(1);
|
||||
|
||||
await expect(service.checkDuplicate(7)).resolves.toBe(true);
|
||||
|
||||
expect(taskQuery.where).toHaveBeenCalledWith('a.Checksum = :checksum', {
|
||||
checksum: 'abc123',
|
||||
});
|
||||
expect(taskQuery.andWhere).toHaveBeenCalledWith(
|
||||
't.SourceAttachmentRange = :range',
|
||||
{ range: 'full' },
|
||||
);
|
||||
expect(paperlessService.checksumExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('erkennt ein Duplikat, das bereits in Paperless liegt', async () => {
|
||||
paperlessService.checksumExists.mockResolvedValue(true);
|
||||
|
||||
await expect(service.checkDuplicate(7)).resolves.toBe(true);
|
||||
expect(paperlessService.checksumExists).toHaveBeenCalledWith('abc123');
|
||||
});
|
||||
|
||||
it('meldet kein Duplikat, wenn weder Import läuft noch Paperless es kennt', async () => {
|
||||
await expect(service.checkDuplicate(7)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('meldet kein Duplikat für Anhänge ohne Checksumme', async () => {
|
||||
attachmentRepo.findOne.mockResolvedValue({ Id: 7, Checksum: '' });
|
||||
|
||||
await expect(service.checkDuplicate(7)).resolves.toBe(false);
|
||||
expect(taskQuery.getCount).not.toHaveBeenCalled();
|
||||
expect(paperlessService.checksumExists).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Teilbereich (Split)', () => {
|
||||
beforeEach(async () => {
|
||||
contentRepo.findOne.mockResolvedValue({
|
||||
Content1: await pdfMitSeiten(3),
|
||||
});
|
||||
});
|
||||
|
||||
it('sucht laufende Importe mit demselben Seitenbereich wie beim Upload', async () => {
|
||||
taskQuery.getCount.mockResolvedValue(1);
|
||||
|
||||
// 999 steht im Assistenten für "bis zur letzten Seite"
|
||||
await expect(
|
||||
service.checkDuplicate(7, { start: 2, end: 999 }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(taskQuery.where).toHaveBeenCalledWith('a.Checksum = :checksum', {
|
||||
checksum: 'abc123',
|
||||
});
|
||||
expect(taskQuery.andWhere).toHaveBeenCalledWith(
|
||||
't.SourceAttachmentRange = :range',
|
||||
{ range: '2-3' },
|
||||
);
|
||||
});
|
||||
|
||||
it('prüft in Paperless die Checksumme des Teil-PDFs', async () => {
|
||||
await expect(
|
||||
service.checkDuplicate(7, { start: 1, end: 1 }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const [checksum] = paperlessService.checksumExists.mock
|
||||
.calls[0] as string[];
|
||||
expect(checksum).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(checksum).not.toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeImport', () => {
|
||||
const importDaten = (splitRanges?: { start: number; end: number }[]) => ({
|
||||
emailDate: '2026-09-25',
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: 7,
|
||||
type: 'MAIN' as const,
|
||||
splitRanges,
|
||||
belegnummer: '2026-000123',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('bricht vor Upload und Belegnummer ab, wenn das Dokument schon vorhanden ist', async () => {
|
||||
paperlessService.checksumExists.mockResolvedValue(true);
|
||||
|
||||
await expect(service.executeImport(importDaten())).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
expect(paperlessService.uploadDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prüft bei geteilten Anhängen jeden Seitenbereich', async () => {
|
||||
const check = jest
|
||||
.spyOn(service, 'checkDuplicate')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.executeImport(importDaten([{ start: 1, end: 2 }])),
|
||||
).rejects.toThrow(ConflictException);
|
||||
expect(check).toHaveBeenCalledWith(7, { start: 1, end: 2 });
|
||||
});
|
||||
|
||||
it('ignorierte Anhänge werden nicht geprüft', async () => {
|
||||
const check = jest.spyOn(service, 'checkDuplicate');
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { runImport: () => Promise<unknown> },
|
||||
'runImport',
|
||||
)
|
||||
.mockResolvedValue({});
|
||||
const daten = importDaten();
|
||||
daten.attachments[0] = {
|
||||
...daten.attachments[0],
|
||||
type: 'IGNORE' as never,
|
||||
};
|
||||
|
||||
await service.executeImport(daten);
|
||||
expect(check).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lässt denselben Anhang nicht zweimal gleichzeitig importieren', async () => {
|
||||
let ersterFertig!: () => void;
|
||||
const run = jest
|
||||
.spyOn(
|
||||
service as never as { runImport: () => Promise<unknown> },
|
||||
'runImport',
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (ersterFertig = () => resolve({}))),
|
||||
)
|
||||
.mockResolvedValue({});
|
||||
|
||||
const erster = service.executeImport(importDaten());
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
await expect(service.executeImport(importDaten())).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
ersterFertig();
|
||||
await erster;
|
||||
// Nach Abschluss ist die Sperre wieder aufgehoben
|
||||
await service.executeImport(importDaten());
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -31,6 +37,8 @@ function sanitizeFilename(name: string): string {
|
||||
@Injectable()
|
||||
export class EmailImportService {
|
||||
private readonly logger = new Logger(EmailImportService.name);
|
||||
// Anhänge (Checksumme + Seitenbereich), die gerade importiert werden
|
||||
private readonly laufendeImporte = new Set<string>();
|
||||
private readonly importJobs = new Map<
|
||||
string,
|
||||
{ message: string; done: boolean }
|
||||
@@ -237,11 +245,29 @@ export class EmailImportService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Checksum Check for Split Documents ---
|
||||
async checkSplitChecksum(
|
||||
// --- Duplikatprüfung ---
|
||||
/**
|
||||
* Prüft, ob ein Anhang (oder mit `pages` ein Teilbereich davon) schon
|
||||
* importiert wurde. Neben Paperless selbst zählen dabei auch Importe, die
|
||||
* gerade noch in Paperless verarbeitet werden: Deren Checksumme kennt
|
||||
* Paperless erst nach dem Consume, z.B. wenn dieselbe Mail zweimal
|
||||
* weitergeleitet wurde und die erste gerade importiert ist.
|
||||
*/
|
||||
async checkDuplicate(
|
||||
attachmentId: number,
|
||||
pages: { start: number; end: number },
|
||||
pages?: { start: number; end: number },
|
||||
): Promise<boolean> {
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { Id: attachmentId },
|
||||
});
|
||||
if (!attachment) return false;
|
||||
|
||||
if (!pages) {
|
||||
if (!attachment.Checksum) return false;
|
||||
if (await this.hasPendingImport(attachment.Checksum, 'full')) return true;
|
||||
return this.paperlessService.checksumExists(attachment.Checksum);
|
||||
}
|
||||
|
||||
const content = await this.contentRepo.findOne({
|
||||
where: { AttachmentEntityId: attachmentId },
|
||||
});
|
||||
@@ -251,13 +277,21 @@ export class EmailImportService {
|
||||
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;
|
||||
// Bereich genauso begrenzen wie executeImport, damit der Range-Key passt
|
||||
const start = Math.max(1, pages.start);
|
||||
const end = Math.min(pages.end === 999 ? total : pages.end, total);
|
||||
|
||||
if (
|
||||
attachment.Checksum &&
|
||||
(await this.hasPendingImport(attachment.Checksum, `${start}-${end}`))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sliced = await PDFDocument.create();
|
||||
const indices = Array.from(
|
||||
{ length: endIdx - startIdx + 1 },
|
||||
(_, i) => startIdx + i,
|
||||
{ length: end - start + 1 },
|
||||
(_, i) => start - 1 + i,
|
||||
);
|
||||
const copied = await sliced.copyPages(pdfDoc, indices);
|
||||
copied.forEach((p) => sliced.addPage(p));
|
||||
@@ -269,6 +303,25 @@ export class EmailImportService {
|
||||
return this.paperlessService.checksumExists(checksum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft für einen Anhang mit dieser Checksumme (egal aus welcher E-Mail)
|
||||
* bereits ein Import desselben Seitenbereichs? Fehlgeschlagene Tasks löscht
|
||||
* der Task-Processor, abgeschlossene findet Paperless selbst.
|
||||
*/
|
||||
private async hasPendingImport(
|
||||
checksum: string,
|
||||
range: string,
|
||||
): Promise<boolean> {
|
||||
const count = await this.taskRepo
|
||||
.createQueryBuilder('t')
|
||||
.innerJoin(Attachment, 'a', 'a.Id = t.SourceAttachmentID')
|
||||
.where('a.Checksum = :checksum', { checksum })
|
||||
.andWhere('t.SourceAttachmentRange = :range', { range })
|
||||
.andWhere('(t.Fertig IS NULL OR t.Fertig = 0)')
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// --- Print Preview ---
|
||||
async generatePrintPdf(
|
||||
attachmentId: number,
|
||||
@@ -460,6 +513,11 @@ export class EmailImportService {
|
||||
}
|
||||
|
||||
// --- Import Logic ---
|
||||
/**
|
||||
* Prüft unmittelbar vor dem Upload – und damit bevor eine Belegnummer
|
||||
* festgelegt wird – nochmals auf Duplikate. Seit dem Öffnen des Assistenten
|
||||
* kann dasselbe Dokument aus einer anderen E-Mail importiert worden sein.
|
||||
*/
|
||||
async executeImport(data: {
|
||||
jobId?: string;
|
||||
attachments: {
|
||||
@@ -478,6 +536,86 @@ export class EmailImportService {
|
||||
belegnummer?: string;
|
||||
}[];
|
||||
emailDate: string;
|
||||
}): Promise<{ success: boolean; results: any[] }> {
|
||||
const sperren = await this.sperreImporte(data.attachments);
|
||||
try {
|
||||
return await this.runImport(data);
|
||||
} finally {
|
||||
sperren.forEach((key) => this.laufendeImporte.delete(key));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserviert jeden zu importierenden Anhang (bzw. Seitenbereich) für diesen
|
||||
* Import und wirft einen ConflictException, wenn er schon vorhanden ist oder
|
||||
* parallel importiert wird. Die Reservierung schließt die Lücke zwischen
|
||||
* Prüfung und Upload, in der Paperless noch nichts davon weiß.
|
||||
*/
|
||||
private async sperreImporte(
|
||||
attachments: {
|
||||
attachmentId: number;
|
||||
type: string;
|
||||
splitRanges?: { start: number; end: number }[];
|
||||
}[],
|
||||
): Promise<string[]> {
|
||||
const sperren: string[] = [];
|
||||
try {
|
||||
for (const att of attachments) {
|
||||
if (att.type === 'IGNORE') continue;
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { Id: att.attachmentId },
|
||||
});
|
||||
if (!attachment) continue;
|
||||
|
||||
const ranges = att.splitRanges?.length ? att.splitRanges : [undefined];
|
||||
for (const range of ranges) {
|
||||
const bereich = range ? `${range.start}-${range.end}` : 'full';
|
||||
const key = `${attachment.Checksum || `id:${attachment.Id}`}:${bereich}`;
|
||||
const bezeichnung = range
|
||||
? `„${attachment.FileName}" (Seiten ${bereich})`
|
||||
: `„${attachment.FileName}"`;
|
||||
|
||||
// has + add ohne await dazwischen: atomar gegenüber parallelen Importen
|
||||
if (this.laufendeImporte.has(key)) {
|
||||
throw new ConflictException(
|
||||
`${bezeichnung} wird gerade bereits importiert.`,
|
||||
);
|
||||
}
|
||||
this.laufendeImporte.add(key);
|
||||
sperren.push(key);
|
||||
|
||||
if (await this.checkDuplicate(att.attachmentId, range)) {
|
||||
throw new ConflictException(
|
||||
`${bezeichnung} ist bereits in Paperless vorhanden oder wird dort gerade verarbeitet.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sperren;
|
||||
} catch (err) {
|
||||
sperren.forEach((key) => this.laufendeImporte.delete(key));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async runImport(data: {
|
||||
jobId?: string;
|
||||
attachments: {
|
||||
attachmentId: number;
|
||||
type: 'MAIN' | 'ATTACHMENT' | 'IGNORE';
|
||||
paperlessCorrespondentId?: number | null;
|
||||
parentDocumentId?: number | null;
|
||||
splitRanges?: { start: number; end: number }[];
|
||||
barcode?: {
|
||||
x: number;
|
||||
y: number;
|
||||
nummer: string;
|
||||
datum: string;
|
||||
jahr: string;
|
||||
};
|
||||
belegnummer?: string;
|
||||
}[];
|
||||
emailDate: string;
|
||||
}): Promise<{ success: boolean; results: any[] }> {
|
||||
const tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'paperless-mail-import-'),
|
||||
@@ -500,7 +638,9 @@ export class EmailImportService {
|
||||
if (!content) continue;
|
||||
|
||||
const originalPdfBytes = content.Content1;
|
||||
const baseFilename = sanitizeFilename(attachmentEntity.FileName.replace(/\.pdf$/i, ''));
|
||||
const baseFilename = sanitizeFilename(
|
||||
attachmentEntity.FileName.replace(/\.pdf$/i, ''),
|
||||
);
|
||||
const paperlessIds: any = {};
|
||||
|
||||
const uploadPromises = [];
|
||||
|
||||
@@ -57,6 +57,12 @@ export const emailImportApi = {
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// Prüft auf Duplikate – in Paperless und bei Importen, die dort noch verarbeitet werden.
|
||||
checkDuplicate: async (attachmentId: number): Promise<boolean> => {
|
||||
const res = await api.post<{ isDuplicate: boolean }>(`/api/email-import/attachments/${attachmentId}/check-duplicate`);
|
||||
return res.data.isDuplicate;
|
||||
},
|
||||
|
||||
checkSplitChecksum: async (attachmentId: number, pages: { start: number; end: number }): Promise<boolean> => {
|
||||
const res = await api.post<{ isDuplicate: boolean }>(`/api/email-import/attachments/${attachmentId}/check-split-checksum`, { pages });
|
||||
return res.data.isDuplicate;
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
// Load correspondents and try to find suggestion
|
||||
loadCorrespondents();
|
||||
|
||||
// Check for duplicates in Paperless
|
||||
// Duplikate prüfen (Paperless und noch laufende Importe)
|
||||
checkDuplicates(initialData);
|
||||
}
|
||||
}, [visible, attachments]);
|
||||
@@ -94,7 +94,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
const item = updatedData[i];
|
||||
const attachment = attachments.find(a => a.Id === item.attachmentId);
|
||||
if (attachment && attachment.Checksum) {
|
||||
const exists = await paperlessApi.checksumExists(attachment.Checksum);
|
||||
const exists = await emailImportApi.checkDuplicate(attachment.Id);
|
||||
if (exists) {
|
||||
updatedData[i] = { ...updatedData[i], type: 'IGNORE', isDuplicate: true };
|
||||
}
|
||||
@@ -187,6 +187,29 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
}
|
||||
};
|
||||
|
||||
// Prüft alle noch zu importierenden Dokumente erneut auf Duplikate – seit dem
|
||||
// Öffnen kann dasselbe Dokument aus einer anderen E-Mail importiert worden
|
||||
// sein. Treffer werden als ignoriert markiert und zurückgegeben.
|
||||
const recheckDuplicates = async (): Promise<AttachmentImportData[]> => {
|
||||
const candidates = importData.filter(i => i.type !== 'IGNORE');
|
||||
const results = await Promise.all(candidates.map(item =>
|
||||
item.pages
|
||||
? emailImportApi.checkSplitChecksum(item.attachmentId, item.pages)
|
||||
: emailImportApi.checkDuplicate(item.attachmentId)
|
||||
));
|
||||
const duplicates = candidates.filter((_, idx) => results[idx]);
|
||||
if (duplicates.length > 0) {
|
||||
const ids = new Set(duplicates.map(d => d.virtualId));
|
||||
setImportData(prev => prev.map(item =>
|
||||
ids.has(item.virtualId) ? { ...item, type: 'IGNORE' as const, isDuplicate: true } : item
|
||||
));
|
||||
message.warning(
|
||||
`Bereits in Paperless vorhanden, wird ignoriert: ${duplicates.map(d => d.fileName).join(', ')}`
|
||||
);
|
||||
}
|
||||
return duplicates;
|
||||
};
|
||||
|
||||
const loadBelegnummern = async () => {
|
||||
// No longer fetching from API here, just initializing state for Step 2
|
||||
for (const item of importData) {
|
||||
@@ -309,6 +332,21 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
if (currentStep === 0) {
|
||||
await loadBelegnummern();
|
||||
} else if (currentStep === 1) {
|
||||
// Vor dem Abrufen der Belegnummern erneut prüfen, damit für ein
|
||||
// Duplikat gar nicht erst eine Nummer reserviert wird
|
||||
setLoading(true);
|
||||
try {
|
||||
const duplicates = await recheckDuplicates();
|
||||
if (duplicates.length > 0) {
|
||||
setCurrentStep(0);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
message.error('Duplikatprüfung fehlgeschlagen – bitte erneut versuchen.');
|
||||
return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
await preFetchBelegnummern();
|
||||
}
|
||||
setCurrentStep(currentStep + 1);
|
||||
@@ -316,6 +354,33 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
|
||||
const executeImport = async () => {
|
||||
setLoading(true);
|
||||
// Letzte Prüfung vor dem Import: Duplikate fliegen raus, ihre bereits
|
||||
// reservierten Belegnummern werden wieder freigegeben
|
||||
try {
|
||||
const duplicates = await recheckDuplicates();
|
||||
if (duplicates.length > 0) {
|
||||
for (const item of duplicates) {
|
||||
const vid = item.virtualId;
|
||||
const num = belegnummern[vid];
|
||||
if ((belegnummerMode[vid] || 'neu') === 'neu' && num && num !== '000000') {
|
||||
const dateStr = (eingangsdaten[vid] || dayjs(email.Date)).format('YYYY-MM-DD');
|
||||
await emailImportApi.releaseBelegnummer(dateStr, num).catch(() => {});
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
message.error('Duplikatprüfung fehlgeschlagen – bitte erneut versuchen.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!importData.some(i => i.type !== 'IGNORE')) {
|
||||
message.warning('Keine Dokumente zum Importieren ausgewählt.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportStatus('Import wird gestartet...');
|
||||
const jobId = crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}`;
|
||||
const statusPoll = setInterval(async () => {
|
||||
@@ -371,7 +436,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
setImportSuccess(true);
|
||||
setCurrentStep(2);
|
||||
} catch (e: any) {
|
||||
message.error(`Fehler beim Import: ${e.message}`);
|
||||
message.error(`Fehler beim Import: ${e.response?.data?.message ?? e.message}`);
|
||||
} finally {
|
||||
clearInterval(statusPoll);
|
||||
setImportStatus('');
|
||||
|
||||
Reference in new issue
Block a user