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>
This commit is contained in:
2026-05-25 14:01:33 +02:00
parent bbdaf19fff
commit b4fe5a336c
5 changed files with 103 additions and 39 deletions
@@ -6,6 +6,7 @@ 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;
@@ -31,6 +32,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
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() {
@@ -128,18 +130,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
(c) => Number(c['ist_lieferant']) === 1 && Number(c['ist_aktiv']) === 1,
)) {
try {
const lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
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,
});
}
await this.getOrCreateCorrespondent(customer);
} catch (err: unknown) {
this.logger.warn(`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`);
}
@@ -233,18 +224,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
let correspondentId: number | undefined;
const customer = customers.find((c) => Number(c.id) === amDoc.kundenId);
if (customer) {
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,
});
}
const corr = await this.getOrCreateCorrespondent(customer);
if (corr) correspondentId = corr.id as number;
}
@@ -416,17 +396,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
}
// Korrespondent ermitteln oder anlegen
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,
});
}
const corr = await this.getOrCreateCorrespondent(customer);
// Owner aus Client-Tabelle
let ownerId: number | undefined;
@@ -494,6 +464,71 @@ export class AgrarmonitorPollingService implements OnModuleInit {
}
}
async syncCorrespondentIds(): Promise<{ total: number; matched: number; unmatched: number }> {
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>();
for (const c of customers) {
const nr = String(c['lieferantennummer'] ?? '').trim();
if (nr) lieferantMap.set(nr, 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+)\)$/;
let matched = 0;
let unmatched = 0;
for (const corr of allCorrespondents) {
const m = lieferantRegex.exec(corr.name as string);
if (!m) { unmatched++; continue; }
const amId = lieferantMap.get(m[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++;
}
this.logger.log(`Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer`);
return { total: allCorrespondents.length, matched, unmatched };
}
private async getOrCreateCorrespondent(customer: Record<string, unknown>): Promise<any> {
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,
});
}
return corr;
}
private buildCustomerName(customer: Record<string, unknown>, nummer: string): string {
const firma = (customer['firma'] as string) ?? '';
const nachname = (customer['nachname'] as string) ?? '';
@@ -49,4 +49,11 @@ export class AgrarmonitorController {
async processUploads() {
return this.pollingService.processVerarbeiteteDocuments();
}
@Post('sync-correspondents')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async syncCorrespondents() {
return this.pollingService.syncCorrespondentIds();
}
}
@@ -6,10 +6,11 @@ import { AgrarmonitorController } from './agrarmonitor.controller';
import { PaperlessModule } from '../paperless/paperless.module';
import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Setting, Client]),
TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]),
PaperlessModule,
],
providers: [AgrarmonitorService, AgrarmonitorPollingService],
+2
View File
@@ -225,4 +225,6 @@ export const agrarmonitorApi = {
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/run-polling').then((r) => r.data),
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),
};
@@ -1408,6 +1408,7 @@ function CorrespondentsTab() {
const [pageSize, setPageSize] = useState(50);
const [searchText, setSearchText] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [syncLoading, setSyncLoading] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async (page: number, size: number, search?: string) => {
@@ -1440,6 +1441,19 @@ function CorrespondentsTab() {
}
};
const handleSync = async () => {
setSyncLoading(true);
try {
const result = await agrarmonitorApi.syncCorrespondents();
message.success(`Abgleich abgeschlossen: ${result.matched} zugeordnet, ${result.unmatched} ohne Treffer (${result.total} gesamt)`);
load(currentPage, pageSize, searchText);
} catch (err) {
message.error('Fehler beim Agrarmonitor-Abgleich');
} finally {
setSyncLoading(false);
}
};
const updateAgrarmonitorId = async (id: number, val: number | null) => {
try {
await settingsApi.updateCorrespondentSetting(id, val);
@@ -1497,10 +1511,15 @@ function CorrespondentsTab() {
onSearch={(v) => { setSearchText(v); setCurrentPage(1); }}
style={{ width: 300 }}
/>
<Space>
<Button icon={<GlobalOutlined />} loading={syncLoading} onClick={handleSync}>
Agrarmonitor-Abgleich
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
Neuen Korrespondenten anlegen
</Button>
</Space>
</Space>
</div>
<Table