feat: detect and resolve duplicate correspondents in Agrarmonitor sync
Build and Push Multi-Platform Images / build-and-push (push) Successful in 35s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:33:48 +02:00
parent b4fe5a336c
commit 018f487baf
5 changed files with 214 additions and 16 deletions
+16 -1
View File
@@ -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<AgrarmonitorStatusData>('/api/agrarmonitor/status').then((r) => r.data),
@@ -226,5 +239,7 @@ export const agrarmonitorApi = {
processUploads: () =>
api.post<AgrarmonitorPollingResult>('/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<SyncCorrespondentsResult>('/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),
};
+87 -11
View File
@@ -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<SyncConflict[]>([]);
const [conflictsModalOpen, setConflictsModalOpen] = useState(false);
const [conflictSelections, setConflictSelections] = useState<Record<number, number>>({});
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() {
}}
/>
<Modal
title="Neuen Korrespondenten anlegen"
open={createModalOpen}
onOk={handleCreate}
<Modal
title="Neuen Korrespondenten anlegen"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => setCreateModalOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Name des Korrespondenten"
<Form.Item
name="name"
label="Name des Korrespondenten"
rules={[{ required: true, message: 'Bitte gib einen Namen an' }]}
>
<Input placeholder="Vollständiger Name" />
</Form.Item>
</Form>
</Modal>
<Modal
title="Doppelte Korrespondenten Namenskonflikt"
open={conflictsModalOpen}
onOk={handleMergeConflicts}
onCancel={() => setConflictsModalOpen(false)}
okText="Zusammenführen"
cancelText="Abbrechen"
confirmLoading={mergeLoading}
width={560}
>
<Alert
type="warning"
style={{ marginBottom: 16 }}
message="Mehrere Korrespondenten haben dieselbe Agrarmonitor-ID, aber unterschiedliche Namen. Wähle jeweils den Namen, der behalten werden soll. Alle Dokumente des anderen werden übertragen, der leere Eintrag wird gelöscht."
/>
{conflicts.map((conflict, idx) => (
<div key={conflict.agrarmonitorId} style={{ marginBottom: idx < conflicts.length - 1 ? 24 : 0 }}>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
Agrarmonitor-ID: {conflict.agrarmonitorId}
</div>
<Radio.Group
value={conflictSelections[conflict.agrarmonitorId]}
onChange={e => setConflictSelections(prev => ({ ...prev, [conflict.agrarmonitorId]: e.target.value as number }))}
>
<Space direction="vertical">
{conflict.correspondents.map(c => (
<Radio key={c.id} value={c.id}>
<strong>{c.name}</strong>
<span style={{ marginLeft: 8, color: '#888' }}>({c.documentCount} Dokumente)</span>
</Radio>
))}
</Space>
</Radio.Group>
</div>
))}
</Modal>
</>
);
}