7 Commits

Author SHA1 Message Date
bjoernpoettker c08559b5c3 ci: also tag and push images as :dev on every build
Build and Push Multi-Platform Images / build-and-push (push) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:57:24 +02:00
bjoernpoettker 72d199fb3a feat: navigate to mailbox after successful mail import
Build and Push Multi-Platform Images / build-and-push (push) Successful in 16s
- Add onSuccess prop to MailImportWizard, called instead of onClose on success
- MailDetailPage navigates to /mailpostfach after import completes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:35:43 +02:00
bjoernpoettker 7cd7b2dbf5 perf: resolve correspondents via stored AgrarmonitorId instead of name lookup
Build and Push Multi-Platform Images / build-and-push (push) Successful in 30s
- getOrCreateCorrespondent first checks CorrespondentSetting by kundenId
- Falls back to name search only when no mapping exists
- Saves the mapping after creation for future polling runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:07:09 +02:00
bjoernpoettker 4046c656de fix: match correspondents by Kundennummer (KD-prefix) in addition to Lieferantennummer
Build and Push Multi-Platform Images / build-and-push (push) Successful in 29s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:51:31 +02:00
bjoernpoettker 018f487baf 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>
2026-05-25 14:33:48 +02:00
bjoernpoettker b4fe5a336c feat: add Agrarmonitor correspondent sync
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
- Extract getOrCreateCorrespondent helper to deduplicate logic
- Add syncCorrespondentIds to match Paperless correspondents to
  Agrarmonitor IDs via Lieferantennummer and persist in CorrespondentSetting
- New POST /api/agrarmonitor/sync-correspondents endpoint
- "Agrarmonitor-Abgleich" button in Correspondents settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:01:33 +02:00
bjoernpoettker bbdaf19fff Merge pull request 'Agrarmonitor' (#2) from Agrarmonitor into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 11s
Reviewed-on: #2
2026-05-25 11:02:58 +00:00
9 changed files with 357 additions and 57 deletions
+4
View File
@@ -34,14 +34,18 @@ jobs:
run: | run: |
docker build \ docker build \
-t gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:${{ steps.vars.outputs.tag }} \ -t gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:${{ steps.vars.outputs.tag }} \
-t gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:dev \
./paperless-backend ./paperless-backend
docker push gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:${{ steps.vars.outputs.tag }} docker push gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:${{ steps.vars.outputs.tag }}
docker push gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:dev
- name: Build and Push Frontend - name: Build and Push Frontend
run: | run: |
docker build \ docker build \
-t gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:${{ steps.vars.outputs.tag }} \ -t gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:${{ steps.vars.outputs.tag }} \
-t gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:dev \
./paperless-frontend ./paperless-frontend
docker push gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:${{ steps.vars.outputs.tag }} docker push gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:${{ steps.vars.outputs.tag }}
docker push gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:dev
@@ -1,11 +1,12 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { AgrarmonitorService } from './agrarmonitor.service'; import { AgrarmonitorService } from './agrarmonitor.service';
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity'; import { Client } from '../database/entities/client.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
const INTERN_BELEGNUMMER_FIELD_ID = 7; const INTERN_BELEGNUMMER_FIELD_ID = 7;
const EINGANGSDATUM_FIELD_ID = 9; const EINGANGSDATUM_FIELD_ID = 9;
@@ -20,6 +21,19 @@ export interface PollingResult {
errors: string[]; 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() @Injectable()
export class AgrarmonitorPollingService implements OnModuleInit { export class AgrarmonitorPollingService implements OnModuleInit {
private readonly logger = new Logger(AgrarmonitorPollingService.name); private readonly logger = new Logger(AgrarmonitorPollingService.name);
@@ -31,6 +45,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
@InjectRepository(Setting) private readonly settingRepo: Repository<Setting>, @InjectRepository(Setting) private readonly settingRepo: Repository<Setting>,
@InjectRepository(Client) private readonly clientRepo: Repository<Client>, @InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository<CorrespondentSetting>,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -128,18 +143,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
(c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1, (c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1,
)) { )) {
try { try {
const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; await this.getOrCreateCorrespondent(customer, Number(customer.id));
const displayName = this.buildCustomerName(customer, lieferantennummer);
const existing = await this.paperlessService.getCorrespondentByName(displayName);
if (!existing) {
await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
} catch (err: unknown) { } catch (err: unknown) {
this.logger.warn(`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`); this.logger.warn(`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`);
} }
@@ -233,18 +237,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
let correspondentId: number | undefined; let correspondentId: number | undefined;
const customer = customers.find((c) => Number(c.id) === amDoc.kundenId); const customer = customers.find((c) => Number(c.id) === amDoc.kundenId);
if (customer) { if (customer) {
const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId);
const displayName = this.buildCustomerName(customer, lieferantennummer);
let corr = await this.paperlessService.getCorrespondentByName(displayName);
if (!corr) {
corr = await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
if (corr) correspondentId = corr.id as number; if (corr) correspondentId = corr.id as number;
} }
@@ -416,17 +409,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
// Korrespondent ermitteln oder anlegen // Korrespondent ermitteln oder anlegen
const displayName = this.buildCustomerName(customer, lieferantennummer); const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId);
let corr = await this.paperlessService.getCorrespondentByName(displayName);
if (!corr) {
corr = await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
// Owner aus Client-Tabelle // Owner aus Client-Tabelle
let ownerId: number | undefined; let ownerId: number | undefined;
@@ -494,6 +477,186 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
} }
async syncCorrespondentIds(): Promise<SyncCorrespondentsResult> {
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
try {
amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) {
throw new Error(`Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`);
}
const customers = await amClient.fetchCustomers();
const lieferantMap = new Map<string, number>(); // lieferantennummer → AM-ID
const kundenMap = new Map<string, number>(); // kundennummer → AM-ID
for (const c of customers) {
const liefNr = String(c['lieferantennummer'] ?? '').trim();
if (liefNr) lieferantMap.set(liefNr, Number(c.id));
const kdNr = String(c['kundennummer'] ?? '').trim();
if (kdNr) kundenMap.set(kdNr, Number(c.id));
}
const allCorrespondents: any[] = [];
let page = 1;
while (true) {
const resp = await this.paperlessService.getCorrespondents({ page, page_size: 250 });
allCorrespondents.push(...(resp.results ?? []));
if (!resp.next) break;
page++;
}
const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer
const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer
let matched = 0;
let unmatched = 0;
for (const corr of allCorrespondents) {
const name = corr.name as string;
let amId: number | undefined;
const kdMatch = kundenRegex.exec(name);
if (kdMatch) {
amId = kundenMap.get(kdMatch[1]);
} else {
const liefMatch = lieferantRegex.exec(name);
if (liefMatch) amId = lieferantMap.get(liefMatch[1]);
}
if (amId === undefined) { unmatched++; continue; }
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corr.id as number });
if (!setting) {
setting = this.corrSettingRepo.create({ CorrespondentId: corr.id as number, AgrarmonitorId: amId });
} else {
setting.AgrarmonitorId = amId;
}
await this.corrSettingRepo.save(setting);
matched++;
}
// 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>, kundenId?: number): Promise<any> {
// Direkter Lookup über gespeicherte Agrarmonitor-ID
if (kundenId !== undefined) {
const setting = await this.corrSettingRepo.findOneBy({ AgrarmonitorId: kundenId });
if (setting) {
return { id: setting.CorrespondentId };
}
}
// Fallback: Namensuche und ggf. anlegen
const lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
const displayName = this.buildCustomerName(customer, lieferantennummer);
let corr = await this.paperlessService.getCorrespondentByName(displayName);
if (!corr) {
corr = await this.paperlessService.addCorrespondent({
name: displayName,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null,
});
}
// Link für künftige Läufe speichern
if (corr && kundenId !== undefined) {
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corr.id as number });
if (!setting) {
setting = this.corrSettingRepo.create({ CorrespondentId: corr.id as number, AgrarmonitorId: kundenId });
} else {
setting.AgrarmonitorId = kundenId;
}
await this.corrSettingRepo.save(setting);
}
return corr;
}
private buildCustomerName(customer: Record<string, unknown>, nummer: string): string { private buildCustomerName(customer: Record<string, unknown>, nummer: string): string {
const firma = (customer['firma'] as string) ?? ''; const firma = (customer['firma'] as string) ?? '';
const nachname = (customer['nachname'] as string) ?? ''; const nachname = (customer['nachname'] as string) ?? '';
@@ -49,4 +49,18 @@ export class AgrarmonitorController {
async processUploads() { async processUploads() {
return this.pollingService.processVerarbeiteteDocuments(); return this.pollingService.processVerarbeiteteDocuments();
} }
@Post('sync-correspondents')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
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);
}
} }
@@ -6,10 +6,11 @@ import { AgrarmonitorController } from './agrarmonitor.controller';
import { PaperlessModule } from '../paperless/paperless.module'; import { PaperlessModule } from '../paperless/paperless.module';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity'; import { Client } from '../database/entities/client.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Setting, Client]), TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]),
PaperlessModule, PaperlessModule,
], ],
providers: [AgrarmonitorService, AgrarmonitorPollingService], providers: [AgrarmonitorService, AgrarmonitorPollingService],
@@ -180,6 +180,10 @@ export class PaperlessService {
return response.data; 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> { async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise<Buffer> {
const endpoint = type === 'original' const endpoint = type === 'original'
? `/documents/${id}/download/` ? `/documents/${id}/download/`
+17
View File
@@ -210,6 +210,19 @@ export interface AgrarmonitorPollingResult {
errors: string[]; 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 = { export const agrarmonitorApi = {
getStatus: () => getStatus: () =>
api.get<AgrarmonitorStatusData>('/api/agrarmonitor/status').then((r) => r.data), api.get<AgrarmonitorStatusData>('/api/agrarmonitor/status').then((r) => r.data),
@@ -225,4 +238,8 @@ export const agrarmonitorApi = {
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/run-polling').then((r) => r.data), api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/run-polling').then((r) => r.data),
processUploads: () => processUploads: () =>
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/process-uploads').then((r) => r.data), api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/process-uploads').then((r) => r.data),
syncCorrespondents: () =>
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),
}; };
@@ -18,11 +18,12 @@ const { Text } = Typography;
interface MailImportWizardProps { interface MailImportWizardProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void;
email: any; email: any;
attachments: any[]; attachments: any[];
} }
export default function MailImportWizard({ visible, onClose, email, attachments }: MailImportWizardProps) { export default function MailImportWizard({ visible, onClose, onSuccess, email, attachments }: MailImportWizardProps) {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [importData, setImportData] = useState<AttachmentImportData[]>([]); const [importData, setImportData] = useState<AttachmentImportData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -726,7 +727,7 @@ export default function MailImportWizard({ visible, onClose, email, attachments
width={1000} width={1000}
footer={ footer={
importSuccess ? ( importSuccess ? (
<Button type="primary" onClick={onClose}>Schließen</Button> <Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button>
) : ( ) : (
<Space> <Space>
{currentStep > 0 && <Button onClick={handleBack}>Zurück</Button>} {currentStep > 0 && <Button onClick={handleBack}>Zurück</Button>}
@@ -252,11 +252,12 @@ export default function MailDetailPage() {
</div> </div>
{wizardOpen && email && ( {wizardOpen && email && (
<MailImportWizard <MailImportWizard
visible={wizardOpen} visible={wizardOpen}
onClose={() => setWizardOpen(false)} onClose={() => setWizardOpen(false)}
email={email} onSuccess={() => navigate('/mailpostfach')}
attachments={attachments} email={email}
attachments={attachments}
/> />
)} )}
</div> </div>
+108 -13
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
Tabs, Typography, Table, Button, Modal, Form, Input, Select, 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'; } from 'antd';
import { import {
UserOutlined, FileTextOutlined, ThunderboltOutlined, UserOutlined, FileTextOutlined, ThunderboltOutlined,
@@ -21,6 +21,7 @@ import {
type InboxAction, type InboxActionType, type InboxAction, type InboxActionType,
agrarmonitorApi, type AgrarmonitorStatusData, agrarmonitorApi, type AgrarmonitorStatusData,
type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult, type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult,
type SyncConflict,
} from '../api/settings'; } from '../api/settings';
import { clientsApi, type Client } from '../api/inbox'; import { clientsApi, type Client } from '../api/inbox';
import { apiKeysApi, type ApiKey } from '../api/api-keys'; import { apiKeysApi, type ApiKey } from '../api/api-keys';
@@ -1408,6 +1409,11 @@ function CorrespondentsTab() {
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(50);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false); 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 [form] = Form.useForm();
const load = useCallback(async (page: number, size: number, search?: string) => { const load = useCallback(async (page: number, size: number, search?: string) => {
@@ -1423,8 +1429,8 @@ function CorrespondentsTab() {
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
load(currentPage, pageSize, searchText); load(currentPage, pageSize, searchText);
}, [currentPage, pageSize, searchText, load]); }, [currentPage, pageSize, searchText, load]);
const handleCreate = async () => { const handleCreate = async () => {
@@ -1440,6 +1446,53 @@ function CorrespondentsTab() {
} }
}; };
const handleSync = async () => {
setSyncLoading(true);
try {
const result = await agrarmonitorApi.syncCorrespondents();
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 {
setSyncLoading(false);
}
};
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) => { const updateAgrarmonitorId = async (id: number, val: number | null) => {
try { try {
await settingsApi.updateCorrespondentSetting(id, val); await settingsApi.updateCorrespondentSetting(id, val);
@@ -1497,9 +1550,14 @@ function CorrespondentsTab() {
onSearch={(v) => { setSearchText(v); setCurrentPage(1); }} onSearch={(v) => { setSearchText(v); setCurrentPage(1); }}
style={{ width: 300 }} style={{ width: 300 }}
/> />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}> <Space>
Neuen Korrespondenten anlegen <Button icon={<GlobalOutlined />} loading={syncLoading} onClick={handleSync}>
</Button> Agrarmonitor-Abgleich
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
Neuen Korrespondenten anlegen
</Button>
</Space>
</Space> </Space>
</div> </div>
@@ -1521,22 +1579,59 @@ function CorrespondentsTab() {
}} }}
/> />
<Modal <Modal
title="Neuen Korrespondenten anlegen" title="Neuen Korrespondenten anlegen"
open={createModalOpen} open={createModalOpen}
onOk={handleCreate} onOk={handleCreate}
onCancel={() => setCreateModalOpen(false)} onCancel={() => setCreateModalOpen(false)}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item <Form.Item
name="name" name="name"
label="Name des Korrespondenten" label="Name des Korrespondenten"
rules={[{ required: true, message: 'Bitte gib einen Namen an' }]} rules={[{ required: true, message: 'Bitte gib einen Namen an' }]}
> >
<Input placeholder="Vollständiger Name" /> <Input placeholder="Vollständiger Name" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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>
</> </>
); );
} }