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
@@ -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 = [];
|
||||
|
||||
Reference in new issue
Block a user