fix(mail): Duplikate auch bei gerade laufenden Importen erkennen
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:
bjoernpoettkerandClaude Opus 5.5 committed 2026-09-25 10:29:43 +02:00
1 parent 3cea3fb239
commit 534b313054
5 files changed
+472 -16

No files matched your search

@@ -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('');