feat: detect and resolve duplicate correspondents in Agrarmonitor sync
Build and Push Multi-Platform Images / build-and-push (push) Successful in 35s
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:
@@ -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<SyncCorrespondentsResult> {
|
||||
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
|
||||
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<number, number[]>();
|
||||
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<string, unknown>): Promise<any> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,10 @@ export class PaperlessService {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteCorrespondent(id: number): Promise<void> {
|
||||
await this.client.delete(`/correspondents/${id}/`);
|
||||
}
|
||||
|
||||
async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise<Buffer> {
|
||||
const endpoint = type === 'original'
|
||||
? `/documents/${id}/download/`
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user