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
@@ -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/`