From 018f487baf17bd4d5250daf1c50f825752f15543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 25 May 2026 14:33:48 +0200 Subject: [PATCH] feat: detect and resolve duplicate correspondents in Agrarmonitor sync - Detect duplicates after sync (same AgrarmonitorId, multiple correspondents) - Auto-merge duplicates with identical names (delete empty, move docs to larger) - Expose conflicts with different names for manual resolution - New mergeCorrespondents endpoint + service method - Conflict resolution modal in SettingsPage with radio selection per conflict - deleteCorrespondent added to PaperlessService Co-Authored-By: Claude Sonnet 4.6 --- .../agrarmonitor-polling.service.ts | 104 +++++++++++++++++- .../agrarmonitor/agrarmonitor.controller.ts | 7 ++ .../src/paperless/paperless.service.ts | 4 + paperless-frontend/src/api/settings.ts | 17 ++- paperless-frontend/src/pages/SettingsPage.tsx | 98 +++++++++++++++-- 5 files changed, 214 insertions(+), 16 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 5772729..05cdbd5 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { AgrarmonitorService } from './agrarmonitor.service'; import { PaperlessService } from '../paperless/paperless.service'; import { Setting } from '../database/entities/setting.entity'; @@ -21,6 +21,19 @@ export interface PollingResult { errors: string[]; } +export interface SyncConflict { + agrarmonitorId: number; + correspondents: Array<{ id: number; name: string; documentCount: number }>; +} + +export interface SyncCorrespondentsResult { + total: number; + matched: number; + unmatched: number; + autoMerged: number; + conflicts: SyncConflict[]; +} + @Injectable() export class AgrarmonitorPollingService implements OnModuleInit { private readonly logger = new Logger(AgrarmonitorPollingService.name); @@ -464,7 +477,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { } } - async syncCorrespondentIds(): Promise<{ total: number; matched: number; unmatched: number }> { + async syncCorrespondentIds(): Promise { let amClient: Awaited>; try { amClient = await this.agrarmonitorService.getClient(); @@ -509,8 +522,91 @@ export class AgrarmonitorPollingService implements OnModuleInit { matched++; } - this.logger.log(`Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer`); - return { total: allCorrespondents.length, matched, unmatched }; + // Duplikate ermitteln: mehrere Paperless-Korrespondenten mit derselben AgrarmonitorId + const allLinked = await this.corrSettingRepo.find({ + where: { AgrarmonitorId: Not(IsNull()) }, + }); + const byAmId = new Map(); + for (const s of allLinked) { + const amId = s.AgrarmonitorId!; + const ids = byAmId.get(amId) ?? []; + ids.push(s.CorrespondentId); + byAmId.set(amId, ids); + } + + let autoMerged = 0; + const conflicts: SyncConflict[] = []; + + for (const [amId, corrIds] of byAmId) { + if (corrIds.length <= 1) continue; + + const corrs = await Promise.all(corrIds.map(id => this.paperlessService.getCorrespondent(id))); + const uniqueNames = new Set(corrs.map((c: any) => c.name as string)); + + if (uniqueNames.size === 1) { + // Gleicher Name — automatisch zusammenführen + const withoutDocs = corrs.filter((c: any) => Number(c.document_count) === 0); + const withDocs = corrs.filter((c: any) => Number(c.document_count) > 0); + + if (withoutDocs.length > 0) { + for (const toDelete of withoutDocs) { + await this.paperlessService.deleteCorrespondent(toDelete.id as number); + await this.corrSettingRepo.delete({ CorrespondentId: toDelete.id as number }); + autoMerged++; + this.logger.log(`Duplikat gelöscht (keine Dokumente): ${toDelete.name as string} (ID ${toDelete.id as number})`); + } + } else { + // Alle haben Dokumente — in den mit den meisten Dokumenten zusammenführen + const sorted = [...withDocs].sort((a: any, b: any) => Number(b.document_count) - Number(a.document_count)); + const keep = sorted[0] as any; + for (const toMerge of sorted.slice(1)) { + await this.mergeCorrespondents(keep.id as number, toMerge.id as number); + autoMerged++; + this.logger.log(`Duplikat zusammengeführt in ${keep.name as string} (ID ${keep.id as number})`); + } + } + } else { + // Unterschiedliche Namen — Nutzerentscheidung erforderlich + conflicts.push({ + agrarmonitorId: amId, + correspondents: corrs.map((c: any) => ({ + id: c.id as number, + name: c.name as string, + documentCount: Number(c.document_count), + })), + }); + } + } + + this.logger.log( + `Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer, ` + + `${autoMerged} automatisch zusammengeführt, ${conflicts.length} Konflikte`, + ); + return { total: allCorrespondents.length, matched, unmatched, autoMerged, conflicts }; + } + + async mergeCorrespondents(keepId: number, deleteId: number): Promise<{ mergedDocuments: number }> { + let mergedDocuments = 0; + let page = 1; + while (true) { + const resp = await this.paperlessService.getDocuments({ + correspondent__id: deleteId, + page, + page_size: 250, + truncate_content: true, + }); + const docs: any[] = resp?.results ?? []; + for (const doc of docs) { + await this.paperlessService.updateDocument(doc.id as number, { correspondent: keepId }); + mergedDocuments++; + } + if (!resp?.next) break; + page++; + } + await this.paperlessService.deleteCorrespondent(deleteId); + await this.corrSettingRepo.delete({ CorrespondentId: deleteId }); + this.logger.log(`Korrespondent ${deleteId} → ${keepId} zusammengeführt (${mergedDocuments} Dokumente)`); + return { mergedDocuments }; } private async getOrCreateCorrespondent(customer: Record): Promise { diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index df3b0cf..c7124c7 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -56,4 +56,11 @@ export class AgrarmonitorController { async syncCorrespondents() { return this.pollingService.syncCorrespondentIds(); } + + @Post('merge-correspondents') + @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) + async mergeCorrespondents(@Body() body: { keepId: number; deleteId: number }) { + return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId); + } } diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index 00efbad..9ec06c4 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -180,6 +180,10 @@ export class PaperlessService { return response.data; } + async deleteCorrespondent(id: number): Promise { + await this.client.delete(`/correspondents/${id}/`); + } + async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise { const endpoint = type === 'original' ? `/documents/${id}/download/` diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index b083026..e09665b 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -210,6 +210,19 @@ export interface AgrarmonitorPollingResult { errors: string[]; } +export interface SyncConflict { + agrarmonitorId: number; + correspondents: Array<{ id: number; name: string; documentCount: number }>; +} + +export interface SyncCorrespondentsResult { + total: number; + matched: number; + unmatched: number; + autoMerged: number; + conflicts: SyncConflict[]; +} + export const agrarmonitorApi = { getStatus: () => api.get('/api/agrarmonitor/status').then((r) => r.data), @@ -226,5 +239,7 @@ export const agrarmonitorApi = { processUploads: () => api.post('/api/agrarmonitor/process-uploads').then((r) => r.data), syncCorrespondents: () => - api.post<{ total: number; matched: number; unmatched: number }>('/api/agrarmonitor/sync-correspondents').then((r) => r.data), + api.post('/api/agrarmonitor/sync-correspondents').then((r) => r.data), + mergeCorrespondents: (keepId: number, deleteId: number) => + api.post<{ mergedDocuments: number }>('/api/agrarmonitor/merge-correspondents', { keepId, deleteId }).then((r) => r.data), }; diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index 6c8eba5..5bc1c56 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import dayjs from 'dayjs'; import { Tabs, Typography, Table, Button, Modal, Form, Input, Select, - Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, + Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert, } from 'antd'; import { UserOutlined, FileTextOutlined, ThunderboltOutlined, @@ -21,6 +21,7 @@ import { type InboxAction, type InboxActionType, agrarmonitorApi, type AgrarmonitorStatusData, type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult, + type SyncConflict, } from '../api/settings'; import { clientsApi, type Client } from '../api/inbox'; import { apiKeysApi, type ApiKey } from '../api/api-keys'; @@ -1409,6 +1410,10 @@ function CorrespondentsTab() { const [searchText, setSearchText] = useState(''); const [createModalOpen, setCreateModalOpen] = useState(false); const [syncLoading, setSyncLoading] = useState(false); + const [conflicts, setConflicts] = useState([]); + const [conflictsModalOpen, setConflictsModalOpen] = useState(false); + const [conflictSelections, setConflictSelections] = useState>({}); + const [mergeLoading, setMergeLoading] = useState(false); const [form] = Form.useForm(); const load = useCallback(async (page: number, size: number, search?: string) => { @@ -1424,8 +1429,8 @@ function CorrespondentsTab() { } }, []); - useEffect(() => { - load(currentPage, pageSize, searchText); + useEffect(() => { + load(currentPage, pageSize, searchText); }, [currentPage, pageSize, searchText, load]); const handleCreate = async () => { @@ -1445,8 +1450,20 @@ function CorrespondentsTab() { setSyncLoading(true); try { const result = await agrarmonitorApi.syncCorrespondents(); - message.success(`Abgleich abgeschlossen: ${result.matched} zugeordnet, ${result.unmatched} ohne Treffer (${result.total} gesamt)`); + const parts: string[] = [ + `${result.matched} zugeordnet`, + `${result.unmatched} ohne Treffer`, + ]; + if (result.autoMerged > 0) parts.push(`${result.autoMerged} automatisch bereinigt`); + message.success(`Abgleich abgeschlossen: ${parts.join(', ')} (${result.total} gesamt)`); load(currentPage, pageSize, searchText); + if (result.conflicts.length > 0) { + setConflicts(result.conflicts); + setConflictSelections( + Object.fromEntries(result.conflicts.map(c => [c.agrarmonitorId, c.correspondents[0].id])) + ); + setConflictsModalOpen(true); + } } catch (err) { message.error('Fehler beim Agrarmonitor-Abgleich'); } finally { @@ -1454,6 +1471,28 @@ function CorrespondentsTab() { } }; + const handleMergeConflicts = async () => { + setMergeLoading(true); + try { + for (const conflict of conflicts) { + const keepId = conflictSelections[conflict.agrarmonitorId]; + if (keepId === undefined) continue; + const deleteIds = conflict.correspondents.map(c => c.id).filter(id => id !== keepId); + for (const deleteId of deleteIds) { + await agrarmonitorApi.mergeCorrespondents(keepId, deleteId); + } + } + message.success(`${conflicts.length} Konflikt(e) aufgelöst`); + setConflictsModalOpen(false); + setConflicts([]); + load(currentPage, pageSize, searchText); + } catch (err) { + message.error('Fehler beim Zusammenführen'); + } finally { + setMergeLoading(false); + } + }; + const updateAgrarmonitorId = async (id: number, val: number | null) => { try { await settingsApi.updateCorrespondentSetting(id, val); @@ -1540,22 +1579,59 @@ function CorrespondentsTab() { }} /> - setCreateModalOpen(false)} >
-
+ + setConflictsModalOpen(false)} + okText="Zusammenführen" + cancelText="Abbrechen" + confirmLoading={mergeLoading} + width={560} + > + + {conflicts.map((conflict, idx) => ( +
+
+ Agrarmonitor-ID: {conflict.agrarmonitorId} +
+ setConflictSelections(prev => ({ ...prev, [conflict.agrarmonitorId]: e.target.value as number }))} + > + + {conflict.correspondents.map(c => ( + + {c.name} + ({c.documentCount} Dokumente) + + ))} + + +
+ ))} +
); }