Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c08559b5c3 | |||
| 72d199fb3a | |||
| 7cd7b2dbf5 | |||
| 4046c656de | |||
| 018f487baf | |||
| b4fe5a336c | |||
| bbdaf19fff |
@@ -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/`
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export default function MailDetailPage() {
|
|||||||
<MailImportWizard
|
<MailImportWizard
|
||||||
visible={wizardOpen}
|
visible={wizardOpen}
|
||||||
onClose={() => setWizardOpen(false)}
|
onClose={() => setWizardOpen(false)}
|
||||||
|
onSuccess={() => navigate('/mailpostfach')}
|
||||||
email={email}
|
email={email}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -1537,6 +1595,43 @@ function CorrespondentsTab() {
|
|||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user