Files
paperlessmanager/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts
T
bjoernpoettker 07dfd7e840 fix: resolve all ESLint errors in backend and frontend
Backend 958→0 errors, frontend 98→0 errors. Builds and tsc clean.

Echte Fixes:
- Auth: AuthenticatedUser/AuthenticatedRequest, JwtStrategy + alle 5
  Controller von `@Request() req: any` auf typisierten Request umgestellt
- Error-Handling: neuer getErrorMessage/Stack/Code/getResponseData-Helper;
  alle 50 `catch (err: any)`-Blöcke auf `unknown` + Helper umgestellt
- 24 echte Bugs: require-await, require-imports→ES-Imports, useless-escape,
  misused-promises, tote Imports/Vars, leere catch-Blöcke kommentiert
- document-pipeline: OCR-Ergebnis wird nicht gespeichert (als TODO markiert)

Pragmatisch auf warn herabgestuft (untypisierte Paperless-NGX-API):
no-unsafe-*, restrict-template-expressions, no-base-to-string,
no-explicit-any (FE), react-refresh/only-export-components

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:33:37 +02:00

948 lines
31 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/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';
import { Client } from '../database/entities/client.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
const INTERN_BELEGNUMMER_FIELD_ID = 7;
const EINGANGSDATUM_FIELD_ID = 9;
const EXTERN_BELEGNUMMER_FIELD_ID = 3;
const DOCS_PAGE_SIZE = 500;
const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de';
export interface PollingResult {
processed: number;
updated: number;
skipped: number;
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);
private pollingRunning = false;
private uploadCheckRunning = false;
constructor(
private readonly agrarmonitorService: AgrarmonitorService,
private readonly paperlessService: PaperlessService,
@InjectRepository(Setting)
private readonly settingRepo: Repository<Setting>,
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(CorrespondentSetting)
private readonly corrSettingRepo: Repository<CorrespondentSetting>,
) {}
async onModuleInit() {
await this.upsertSetting('agrarmonitor_tag_fertig', '4');
await this.upsertSetting('agrarmonitor_tag_verbucht', '9');
await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
await this.upsertSetting('agrarmonitor_link_field', '');
await this.upsertSetting('agrarmonitor_tag_manuell', '');
}
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
scheduledPolling() {
if (!process.env['AGRARMONITOR_POLLING_CRON']) return;
this.runPolling().catch((err) =>
this.logger.error('Cron-Polling-Fehler:', err),
);
}
@Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *')
scheduledUploadCheck() {
if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return;
this.processVerarbeiteteDocuments().catch((err) =>
this.logger.error('Cron-Upload-Check-Fehler:', err),
);
}
async getPollingConfig(): Promise<{
tagFertig: string;
tagVerbucht: string;
tagHochgeladen: string;
linkField: string;
tagManuell: string;
}> {
const [fertig, verbucht, hochgeladen, linkField, manuell] =
await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
]);
return {
tagFertig: fertig?.Wert ?? '4',
tagVerbucht: verbucht?.Wert ?? '9',
tagHochgeladen: hochgeladen?.Wert ?? '',
linkField: linkField?.Wert ?? '',
tagManuell: manuell?.Wert ?? '',
};
}
async updatePollingConfig(
tagFertig: string,
tagVerbucht: string,
tagHochgeladen: string,
linkField: string,
tagManuell: string,
): Promise<{
tagFertig: string;
tagVerbucht: string;
tagHochgeladen: string;
linkField: string;
tagManuell: string;
}> {
await Promise.all([
this.settingRepo.update(
{ Tag: 'agrarmonitor_tag_fertig' },
{ Wert: tagFertig },
),
this.settingRepo.update(
{ Tag: 'agrarmonitor_tag_verbucht' },
{ Wert: tagVerbucht },
),
this.settingRepo.update(
{ Tag: 'agrarmonitor_tag_hochgeladen' },
{ Wert: tagHochgeladen },
),
this.settingRepo.update(
{ Tag: 'agrarmonitor_link_field' },
{ Wert: linkField },
),
this.settingRepo.update(
{ Tag: 'agrarmonitor_tag_manuell' },
{ Wert: tagManuell },
),
]);
return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell };
}
async runPolling(): Promise<PollingResult> {
if (this.pollingRunning) {
this.logger.warn('Polling läuft bereits, überspringe');
return {
processed: 0,
updated: 0,
skipped: 0,
errors: ['Polling bereits aktiv'],
};
}
this.pollingRunning = true;
const result: PollingResult = {
processed: 0,
updated: 0,
skipped: 0,
errors: [],
};
this.logger.log('Starte Agrarmonitor-Polling');
try {
const [tagFertigSetting, tagVerbuchtSetting] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
]);
const tagFertigId = parseInt(tagFertigSetting?.Wert ?? '4', 10);
const tagVerbuchtId = parseInt(tagVerbuchtSetting?.Wert ?? '9', 10);
if (isNaN(tagFertigId) || isNaN(tagVerbuchtId)) {
const msg = 'Tag-IDs ungültig (keine Zahlen)';
this.logger.error(msg);
return { ...result, errors: [msg] };
}
let amClient: Awaited<
ReturnType<typeof this.agrarmonitorService.getClient>
>;
try {
amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) {
const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`;
this.logger.error(msg);
return { ...result, errors: [msg] };
}
let customers: Awaited<ReturnType<typeof amClient.fetchCustomers>>;
try {
customers = await amClient.fetchCustomers();
} catch (err: unknown) {
const msg = `Kunden-Abruf fehlgeschlagen: ${err instanceof Error ? err.message : 'unbekannt'}`;
this.logger.error(msg);
return { ...result, errors: [msg] };
}
for (const customer of customers.filter(
(c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1,
)) {
try {
await this.getOrCreateCorrespondent(customer, Number(customer.id));
} catch (err: unknown) {
this.logger.warn(
`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`,
);
}
}
const docsResponse = await this.paperlessService.getDocuments({
page: 1,
page_size: DOCS_PAGE_SIZE,
truncate_content: true,
tags__id__all: tagFertigId,
});
const docs: any[] = docsResponse?.results ?? [];
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
this.logger.warn(
`Mehr als ${DOCS_PAGE_SIZE} Dokumente bereit — nur erste ${DOCS_PAGE_SIZE} werden verarbeitet`,
);
}
this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`);
for (const doc of docs) {
result.processed++;
const interneBelegnummer =
(((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
)?.value as string) ?? '';
if (!interneBelegnummer) {
this.logger.log(
`Dokument ${doc.id as number} hat keine interne Belegnummer`,
);
result.skipped++;
await this.delay(500);
continue;
}
let amResults: Awaited<
ReturnType<typeof amClient.eingangsrechnungenLivesearch>
>;
try {
amResults =
await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Polling abgebrochen, nächster Lauf meldet sich neu an`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (amResults.length === 0) {
this.logger.log(
`${interneBelegnummer} nicht in Agrarmonitor gefunden`,
);
result.skipped++;
await this.delay(500);
continue;
}
if (amResults.length > 1) {
const msg = `${interneBelegnummer}: Mehrfach gefunden`;
this.logger.error(msg);
result.errors.push(msg);
await this.delay(500);
continue;
}
const amDoc = amResults[0];
if (!amDoc.interneBelegNummer && interneBelegnummer) {
try {
await amClient.setLieferscheinNummer(
amDoc.eingangId,
interneBelegnummer,
);
} catch (err: unknown) {
this.logger.warn(
`${interneBelegnummer}: Lieferscheinnummer setzen fehlgeschlagen: ${err instanceof Error ? err.message : err}`,
);
}
}
if (!amDoc.eingangsDatum && amDoc.belegNummer) {
const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === EINGANGSDATUM_FIELD_ID,
);
if (eingangsdatumField?.value) {
const eingangsdatum = new Date(eingangsdatumField.value as string);
if (!isNaN(eingangsdatum.getTime())) {
await amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum);
this.logger.log(
`Eingangsdatum für ${interneBelegnummer} gesetzt`,
);
}
}
}
if (amDoc.buchungsDatum) {
try {
let correspondentId: number | undefined;
const customer = customers.find(
(c) => Number(c.id) === amDoc.kundenId,
);
if (customer) {
const corr = await this.getOrCreateCorrespondent(
customer,
amDoc.kundenId,
);
if (corr) correspondentId = corr.id as number;
}
let ownerId: number | undefined;
const matchedClient = await this.clientRepo.findOneBy({
AgrarmonitorBetriebId: amDoc.betriebId,
});
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
const currentTags: number[] = (doc.tags as number[]) ?? [];
const newTags = [
...new Set(
currentTags
.filter((t) => t !== tagFertigId)
.concat([tagVerbuchtId]),
),
];
const updateData: Record<string, any> = { tags: newTags };
if (correspondentId !== undefined)
updateData.correspondent = correspondentId;
if (ownerId !== undefined) updateData.owner = ownerId;
await this.paperlessService.updateDocument(
doc.id as number,
updateData,
);
this.logger.log(`Beleg ${interneBelegnummer} gebucht`);
result.updated++;
} catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
}
}
await this.delay(500);
}
this.logger.log(
`Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` +
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
);
} finally {
this.pollingRunning = false;
}
return result;
}
async processVerarbeiteteDocuments(): Promise<PollingResult> {
if (this.uploadCheckRunning) {
this.logger.warn('Upload-Check läuft bereits, überspringe');
return {
processed: 0,
updated: 0,
skipped: 0,
errors: ['Upload-Check bereits aktiv'],
};
}
this.uploadCheckRunning = true;
const result: PollingResult = {
processed: 0,
updated: 0,
skipped: 0,
errors: [],
};
this.logger.log('Starte Upload-Check');
try {
const [
hochgeladenSetting,
fertigSetting,
linkFieldSetting,
manuellSetting,
] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
]);
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10);
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
if (isNaN(tagHochgeladenId)) {
this.logger.warn(
'Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen',
);
return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] };
}
let amClient: Awaited<
ReturnType<typeof this.agrarmonitorService.getClient>
>;
try {
amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) {
const msg = `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`;
this.logger.error(msg);
return { ...result, errors: [msg] };
}
const docsResponse = await this.paperlessService.getDocuments({
page: 1,
page_size: DOCS_PAGE_SIZE,
truncate_content: true,
tags__id__all: tagHochgeladenId,
});
const docs: any[] = docsResponse?.results ?? [];
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
this.logger.warn(
`Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`,
);
}
this.logger.log(
`${docs.length} Dokumente laut Paperless im Dateieingang`,
);
for (const doc of docs) {
result.processed++;
const interneBelegnummer =
(((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
)?.value as string) ?? '';
if (!interneBelegnummer) {
this.logger.log(
`Dokument ${doc.id as number} hat keine interne Belegnummer`,
);
result.skipped++;
await this.delay(500);
continue;
}
let vorhanden: boolean;
try {
vorhanden =
await amClient.eingangsrechnungVorhanden(interneBelegnummer);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (!vorhanden) {
// Prüfen ob Beleg noch im Dateieingang von Agrarmonitor liegt
let imDateieingang: boolean;
try {
imDateieingang =
await amClient.eingangsrechnungImDateieingangVorhanden(
interneBelegnummer,
);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
// Bei Fehler vorsichtig: nicht verschieben
const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (imDateieingang) {
// Noch im Dateieingang — wartet auf Verarbeitung, nichts tun
result.skipped++;
await this.delay(500);
continue;
}
// Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen
if (!isNaN(tagManuellId)) {
const currentTags: number[] = (doc.tags as number[]) ?? [];
const newTags = [
...new Set(
currentTags
.filter((t) => t !== tagHochgeladenId)
.concat([tagManuellId, 19]),
),
];
await this.paperlessService.updateDocument(doc.id as number, {
tags: newTags,
});
this.logger.log(
`${interneBelegnummer} nicht mehr in Agrarmonitor — als manuell bearbeiten markiert`,
);
result.updated++;
} else {
result.skipped++;
}
await this.delay(500);
continue;
}
this.logger.log(
`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`,
);
let amResults: Awaited<
ReturnType<typeof amClient.eingangsrechnungenLivesearch>
>;
try {
amResults =
await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (amResults.length > 1) {
this.logger.log(
`Dokument ${interneBelegnummer} ist doppelt vorhanden`,
);
result.skipped++;
await this.delay(500);
continue;
}
const amDoc = amResults[0];
try {
// Kundendaten abrufen
const customer = await amClient.getCustomerById(amDoc.kundenId);
const lieferantennummer =
(customer['lieferantennummer'] as string) ?? '';
if (!lieferantennummer) {
this.logger.log(
`Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`,
);
result.skipped++;
await this.delay(500);
continue;
}
// Korrespondent ermitteln oder anlegen
const corr = await this.getOrCreateCorrespondent(
customer,
amDoc.kundenId,
);
// Owner aus Client-Tabelle
let ownerId: number | undefined;
const matchedClient = await this.clientRepo.findOneBy({
AgrarmonitorBetriebId: amDoc.betriebId,
});
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
// Tags: hochgeladen entfernen, fertig hinzufügen
const currentTags: number[] = (doc.tags as number[]) ?? [];
const newTags = [
...new Set(
currentTags
.filter((t) => t !== tagHochgeladenId)
.concat([tagFertigId]),
),
];
// Custom fields aufbauen: bestehende behalten, extern + link setzen
const existingFields: any[] = (
(doc.custom_fields as any[]) ?? []
).map((f: any) => ({ ...f }));
this.setCustomField(
existingFields,
EXTERN_BELEGNUMMER_FIELD_ID,
amDoc.belegNummer,
);
if (!isNaN(linkFieldId)) {
this.setCustomField(
existingFields,
linkFieldId,
`${AGRARMONITOR_BASE_URL}/rechnungen/detail/${amDoc.eingangId}`,
);
}
const updateData: Record<string, any> = {
title:
(amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer,
document_type: amDoc.dokumentTyp === 0 ? 1 : 2,
tags: newTags,
custom_fields: existingFields,
};
if (amDoc.belegDatum)
updateData.created = amDoc.belegDatum.toISOString().slice(0, 10);
if (corr) updateData.correspondent = corr.id as number;
if (ownerId !== undefined) updateData.owner = ownerId;
await this.paperlessService.updateDocument(
doc.id as number,
updateData,
);
await this.paperlessService.addNote(
doc.id as number,
`Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`,
);
this.logger.log(`Beleg ${interneBelegnummer} auf AMfertig gesetzt`);
result.updated++;
} catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
}
await this.delay(500);
}
this.logger.log(
`Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` +
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
);
} finally {
this.uploadCheckRunning = false;
}
return result;
}
private setCustomField(fields: any[], fieldId: number, value: any): void {
const existing = fields.find((f) => f.field === fieldId);
if (existing) {
existing.value = value;
} else {
fields.push({ field: fieldId, value });
}
}
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];
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 {
const firma = (customer['firma'] as string) ?? '';
const nachname = (customer['nachname'] as string) ?? '';
const vorname = (customer['vorname'] as string) ?? '';
const name = firma || nachname + (vorname ? ', ' + vorname : '');
return `${name} (${nummer})`;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async upsertSetting(
tag: string,
defaultValue: string,
): Promise<void> {
const existing = await this.settingRepo.findOneBy({ Tag: tag });
if (!existing) {
await this.settingRepo.save(
this.settingRepo.create({ Typ: 1, Wert: defaultValue, Tag: tag }),
);
}
}
}