feat(agrarmonitor): fehlende Lieferantennummern vergeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 1m1s

Neuer Bereich „Lieferantennummern überprüfen“ in den Agrarmonitor-
Einstellungen. Er vergibt allen aktiven Lieferanten ohne
Lieferantennummer eine fortlaufende Nummer im Schema 1555xxxx, ab der
höchsten vergebenen Nummer (Inaktive zählen mit, Altlasten werden
ignoriert).

- Vorschau „Lieferant → neue Nummer“, geschrieben wird erst nach
  Bestätigung; Einzelmodus zum Testen an einem Lieferanten
- formData wird wie in der Weboberfläche aus #form_detail aufgebaut,
  nur die Lieferantennummer ändert sich
- Dublettencheck über vorhanden.php, Erfolgskontrolle durch Neuladen
  des Details, Abbruch beim ersten Fehler
- Lauf im Hintergrund mit Status-Abfrage (wegen Proxy-Timeouts),
  Listenfilter werden danach zurückgesetzt
- HTML-Parsing mit cheerio, Unit-Tests für Parser und Vergabe-Ablauf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-09-28 08:51:13 +02:00
1 parent 029009182a
commit 3d4125b29a
11 files changed
+1507 -3

No files matched your search

@@ -16,7 +16,7 @@ const DOCS_PAGE_SIZE = 500;
const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de';
/** Fehlermeldung inkl. aufgerufener Seite (Methode + URL), falls es ein HTTP-Fehler ist. */
function fehlerText(err: unknown): string {
export function fehlerText(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
if (!axios.isAxiosError(err) || !err.config) return message;
const method = (err.config.method ?? 'get').toUpperCase();
@@ -1,6 +1,15 @@
import { Body, Controller, Get, HttpCode, Post, Put } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
Post,
Put,
} from '@nestjs/common';
import { AgrarmonitorService } from './agrarmonitor.service';
import { AgrarmonitorPollingService } from './agrarmonitor-polling.service';
import { LieferantennummernService } from './lieferantennummern.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@@ -9,6 +18,7 @@ export class AgrarmonitorController {
constructor(
private readonly service: AgrarmonitorService,
private readonly pollingService: AgrarmonitorPollingService,
private readonly lieferantennummernService: LieferantennummernService,
) {}
@Get('status')
@@ -86,4 +96,37 @@ export class AgrarmonitorController {
) {
return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId);
}
@Get('lieferantennummern/status')
@RequirePermissions(Permission.MANAGE_SETTINGS)
getLieferantennummernStatus() {
return this.lieferantennummernService.getStatus();
}
@Post('lieferantennummern/vorschau')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async lieferantennummernVorschau() {
return this.lieferantennummernService.vorschau();
}
@Post('lieferantennummern/vergeben')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async lieferantennummernVergeben(
@Body() body: { startNummer: number; nurKundeId?: number },
) {
const startNummer = Number(body.startNummer);
const nurKundeId =
body.nurKundeId === undefined || body.nurKundeId === null
? undefined
: Number(body.nurKundeId);
if (
!Number.isInteger(startNummer) ||
(nurKundeId !== undefined && !Number.isInteger(nurKundeId))
) {
throw new BadRequestException('startNummer bzw. nurKundeId ungültig');
}
return this.lieferantennummernService.starten(startNummer, nurKundeId);
}
}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AgrarmonitorService } from './agrarmonitor.service';
import { AgrarmonitorPollingService } from './agrarmonitor-polling.service';
import { AgrarmonitorController } from './agrarmonitor.controller';
import { LieferantennummernService } from './lieferantennummern.service';
import { PaperlessModule } from '../paperless/paperless.module';
import { Setting } from '../database/entities/setting.entity';
import { Client } from '../database/entities/client.entity';
@@ -13,7 +14,11 @@ import { CorrespondentSetting } from '../database/entities/correspondent-setting
TypeOrmModule.forFeature([Setting, Client, CorrespondentSetting]),
PaperlessModule,
],
providers: [AgrarmonitorService, AgrarmonitorPollingService],
providers: [
AgrarmonitorService,
AgrarmonitorPollingService,
LieferantennummernService,
],
controllers: [AgrarmonitorController],
exports: [AgrarmonitorService],
})
@@ -0,0 +1,196 @@
import {
baueFormData,
bestimmeKandidaten,
hoechsteLieferantennummer,
lieferantennummerAusDetail,
parseLieferantenListe,
} from './lieferantennummern.parser';
const AKTIV = '<i class="fa fa-check fa-lg"></i>';
function zeile(
id: number,
nummer: string,
firma: string,
name: string,
aktiv: boolean,
): string {
return (
`<tr id="${id}" data-id="${id}">` +
`<td>${nummer}</td><td>${firma}</td><td>${name}</td>` +
`<td>Straße 1</td><td>12345</td><td>Ort</td>` +
`<td><input type="checkbox"></td><td>${aktiv ? AKTIV : ''}</td></tr>`
);
}
function liste(...zeilen: string[]): string {
return (
'<div><table id="kunden_table"><thead><tr><th>Nr</th></tr></thead><tbody>' +
zeilen.join('') +
'</tbody></table></div>'
);
}
describe('parseLieferantenListe', () => {
it('liest Id, Nummer, Firma, Name und Aktiv-Status', () => {
const html = liste(
zeile(71860, '15550700', 'AGROLAB Agrar GmbH', '-', true),
zeile(71861, '-', '-', 'Müller, Hans', false),
);
expect(parseLieferantenListe(html)).toEqual([
{
kundeId: 71860,
lieferantennummer: '15550700',
firma: 'AGROLAB Agrar GmbH',
name: '',
aktiv: true,
},
{
kundeId: 71861,
lieferantennummer: '',
firma: '',
name: 'Müller, Hans',
aktiv: false,
},
]);
});
it('wirft, wenn die Tabelle fehlt (z. B. Login-Seite)', () => {
expect(() =>
parseLieferantenListe('<html><body>Login</body></html>'),
).toThrow(/kunden_table/);
});
});
describe('hoechsteLieferantennummer', () => {
it('berücksichtigt Inaktive und ignoriert Altlasten außerhalb des Schemas', () => {
const lieferanten = parseLieferantenListe(
liste(
zeile(1, '15550029', 'A', '-', true),
zeile(2, '15550029', 'B', '-', true),
zeile(3, '30', 'C', '-', true),
zeile(4, '39', 'D', '-', true),
zeile(5, '15550705', 'E', '-', false),
zeile(6, '15550700', 'F', '-', true),
zeile(7, '155507050', 'G', '-', true),
zeile(8, '-', 'H', '-', true),
),
);
expect(hoechsteLieferantennummer(lieferanten)).toBe(15550705);
});
it('liefert null, wenn keine Nummer ins Schema passt', () => {
const lieferanten = parseLieferantenListe(
liste(zeile(1, '30', 'A', '-', true)),
);
expect(hoechsteLieferantennummer(lieferanten)).toBeNull();
});
});
describe('bestimmeKandidaten', () => {
it('nimmt nur aktive ohne Nummer, sortiert nach Firma bzw. Name', () => {
const lieferanten = parseLieferantenListe(
liste(
zeile(1, '-', 'Zeta GmbH', '-', true),
zeile(2, '-', '-', 'Ärger, Anna', true),
zeile(3, '-', 'Beta KG', '-', false),
zeile(4, '15550001', 'Alpha AG', '-', true),
zeile(5, '', 'Bauer GbR', '-', true),
),
);
expect(bestimmeKandidaten(lieferanten).map((l) => l.kundeId)).toEqual([
2, 5, 1,
]);
});
});
const DETAIL = `
<html><body>
<form id="form_detail">
<input type="text" name="kundennummer" id="kundennummer" value="21207">
<input type="text" name="lieferantennummer" id="lieferantennummer" value="">
<input type="hidden" name="action_on_save" value="dieser_kunde">
<input type="number" name="debitorennummer" value="0">
<input type="password" name="geheim" value="x">
<input type="text" name="deaktiviert" value="nein" disabled>
<input name="ohne_typ" value="nein">
<input type="button" name="knopf" value="nein">
<input type="email" name="email_feld" value="nein">
<input type="checkbox" name="ist_kunde" id="ist_kunde" value="1">
<input type="checkbox" name="ist_lieferant" id="ist_lieferant" value="1" checked>
<input type="checkbox" name="gesperrt" value="1" checked disabled>
<input type="checkbox" name="kundengruppen_auswahl" value="3" checked>
<input type="checkbox" name="kundengruppen_auswahl" value="4">
<input type="checkbox" name="kundengruppen_auswahl" value="7" checked>
<input type="checkbox" name="lieferantengruppen_auswahl" value="9" checked>
<input type="checkbox" name="farben" value="rot" checked>
<input type="checkbox" name="farben" value="blau">
<textarea name="notiz">Zeile 1
Zeile 2</textarea>
<textarea name="alt" disabled>nein</textarea>
<select name="land_iso_3166">
<option value="AT">Österreich</option>
<option value="DE" selected>Deutschland</option>
</select>
<select name="zahlungsbedingung">
<option value="0">Keine</option>
<option value="5">5 Tage</option>
</select>
<table id="eigenschaftenTable"><tbody>
<tr><td><input id="eig_12" value="abc"></td></tr>
<tr><td><input id="eig_13" value=""></td></tr>
</tbody></table>
</form>
</body></html>`;
describe('baueFormData', () => {
it('baut formData wie die Weboberfläche und setzt nur die Lieferantennummer', () => {
expect(baueFormData(DETAIL, '15550706')).toEqual({
kundennummer: '21207',
lieferantennummer: '15550706',
action_on_save: 'dieser_kunde',
debitorennummer: '0',
geheim: 'x',
ist_kunde: '',
ist_lieferant: '1',
kundengruppen_auswahl: ['3', '7'],
lieferantengruppen_auswahl: ['9'],
arbeitsgebiet_id: [],
farben: ['rot'],
notiz: 'Zeile 1\nZeile 2',
land_iso_3166: 'DE',
zahlungsbedingung: '0',
eigenschaften: { eig_12: 'abc', eig_13: '' },
});
});
it('liefert ein leeres eigenschaften-Objekt ohne Eigenschaftentabelle', () => {
const html =
'<form id="form_detail"><input type="text" name="lieferantennummer" id="lieferantennummer" value=""></form>';
expect(baueFormData(html, '15550706')).toEqual({
lieferantennummer: '15550706',
kundengruppen_auswahl: [],
lieferantengruppen_auswahl: [],
arbeitsgebiet_id: [],
eigenschaften: {},
});
});
it('wirft, wenn das Formular fehlt', () => {
expect(() => baueFormData('<html></html>', '1')).toThrow(/form_detail/);
});
});
describe('lieferantennummerAusDetail', () => {
it('liest #lieferantennummer getrimmt', () => {
const html =
'<form id="form_detail"><input id="lieferantennummer" name="lieferantennummer" value=" 15550706 "></form>';
expect(lieferantennummerAusDetail(html)).toBe('15550706');
});
it('wirft, wenn das Feld fehlt', () => {
expect(() => lieferantennummerAusDetail('<html></html>')).toThrow(
/lieferantennummer/,
);
});
});
@@ -0,0 +1,181 @@
import * as cheerio from 'cheerio';
import type { Cheerio, CheerioAPI } from 'cheerio';
import type { Element } from 'domhandler';
/** Schema der fortlaufenden Lieferantennummern, z. B. 15550706. */
export const LIEFERANTENNUMMER_SCHEMA = /^1555\d{4}$/;
export interface ListenLieferant {
kundeId: number;
/** Leer, wenn Agrarmonitor "-" anzeigt. */
lieferantennummer: string;
firma: string;
name: string;
aktiv: boolean;
}
export type FormDataWert = string | string[] | Record<string, string>;
export type KundeFormData = Record<string, FormDataWert>;
/** Agrarmonitor zeigt leere Zellen als "-". */
function zellText(td: Cheerio<Element>): string {
const text = td.text().trim();
return text === '-' ? '' : text;
}
/** Liest die Lieferantenliste aus der Antwort von livesearch.php. */
export function parseLieferantenListe(html: string): ListenLieferant[] {
const $ = cheerio.load(html);
const table = $('table#kunden_table');
if (table.length === 0) {
throw new Error(
'Tabelle #kunden_table nicht gefunden (Session abgelaufen?)',
);
}
return table
.find('tr[data-id]')
.toArray()
.map((tr) => {
const tds = $(tr).children('td');
return {
kundeId: Number($(tr).attr('data-id')),
lieferantennummer: zellText(tds.eq(0)),
firma: zellText(tds.eq(1)),
name: zellText(tds.eq(2)),
aktiv: tds.eq(7).find('i.fa-check').length > 0,
};
});
}
export function anzeigeName(
l: Pick<ListenLieferant, 'firma' | 'name'>,
): string {
return l.firma || l.name;
}
/** Höchste Nummer im Schema über alle (auch inaktive) Lieferanten; null, wenn keine passt. */
export function hoechsteLieferantennummer(
lieferanten: ListenLieferant[],
): number | null {
let max: number | null = null;
for (const l of lieferanten) {
if (!LIEFERANTENNUMMER_SCHEMA.test(l.lieferantennummer)) continue;
const n = Number(l.lieferantennummer);
if (max === null || n > max) max = n;
}
return max;
}
/** Aktive Lieferanten ohne Nummer, sortiert nach Firma bzw. Name. */
export function bestimmeKandidaten(
lieferanten: ListenLieferant[],
): ListenLieferant[] {
return lieferanten
.filter((l) => l.aktiv && !l.lieferantennummer)
.sort((a, b) =>
anzeigeName(a).localeCompare(anzeigeName(b), 'de', {
sensitivity: 'base',
}),
);
}
/** Aktuelle Lieferantennummer aus /kunden/detail/<id>. */
export function lieferantennummerAusDetail(html: string): string {
const input = cheerio.load(html)('input#lieferantennummer');
if (input.length === 0) {
throw new Error(
'Feld #lieferantennummer nicht gefunden (Session abgelaufen?)',
);
}
return (input.attr('value') ?? '').trim();
}
function istDisabled(el: Cheerio<Element>): boolean {
return el.attr('disabled') !== undefined;
}
function istChecked(el: Cheerio<Element>): boolean {
return el.attr('checked') !== undefined;
}
function angehakteWerte($: CheerioAPI, name: string): string[] {
return $(`input[type=checkbox][name="${name}"]`)
.toArray()
.map((el) => $(el))
.filter(istChecked)
.map((cb) => cb.attr('value') ?? 'on');
}
const WERT_TYPEN = new Set(['text', 'hidden', 'number', 'password']);
/**
* Baut formData für updateKunde.php genau wie die Agrarmonitor-Weboberfläche
* aus #form_detail und setzt nur die Lieferantennummer neu.
*/
export function baueFormData(
html: string,
lieferantennummer: string,
): KundeFormData {
const $ = cheerio.load(html);
const form = $('form#form_detail');
if (form.length === 0) {
throw new Error(
'Formular #form_detail nicht gefunden (Session abgelaufen?)',
);
}
const formData: KundeFormData = {};
const checkboxen = new Map<string, Cheerio<Element>[]>();
for (const el of form.find('input[name]').toArray()) {
const input = $(el);
if (istDisabled(input)) continue;
const name = input.attr('name')!;
const typ = input.attr('type')?.toLowerCase();
if (typ === 'checkbox') {
checkboxen.set(name, [...(checkboxen.get(name) ?? []), input]);
} else if (typ && WERT_TYPEN.has(typ)) {
formData[name] = input.attr('value') ?? '';
}
}
for (const [name, gruppe] of checkboxen) {
formData[name] =
gruppe.length === 1
? istChecked(gruppe[0])
? (gruppe[0].attr('value') ?? 'on')
: ''
: gruppe.filter(istChecked).map((cb) => cb.attr('value') ?? 'on');
}
for (const el of form.find('textarea[name]').toArray()) {
const textarea = $(el);
if (!istDisabled(textarea)) {
formData[textarea.attr('name')!] = textarea.text();
}
}
for (const el of form.find('select[name]').toArray()) {
const select = $(el);
if (istDisabled(select)) continue;
let option = select.find('option[selected]').first();
if (option.length === 0) option = select.find('option').first();
formData[select.attr('name')!] =
option.length === 0 ? '' : (option.attr('value') ?? option.text());
}
formData.kundengruppen_auswahl = angehakteWerte($, 'kundengruppen_auswahl');
formData.lieferantengruppen_auswahl = angehakteWerte(
$,
'lieferantengruppen_auswahl',
);
formData.arbeitsgebiet_id = angehakteWerte($, 'arbeitsgebiet_id');
const eigenschaften: Record<string, string> = {};
for (const el of $('#eigenschaftenTable tbody input').toArray()) {
const id = $(el).attr('id');
if (id) eigenschaften[id] = $(el).attr('value') ?? '';
}
formData.eigenschaften = eigenschaften;
formData.lieferantennummer = lieferantennummer;
return formData;
}
@@ -0,0 +1,151 @@
import type { AxiosInstance } from 'axios';
import type { AgrarmonitorService } from './agrarmonitor.service';
import {
LieferantennummernService,
type LieferantennummerLaufErgebnis,
} from './lieferantennummern.service';
import type { ListenLieferant } from './lieferantennummern.parser';
// Der echte Connector lädt jsdom (ESM), das Jest nicht verarbeiten kann – hier wird er nicht gebraucht.
jest.mock('agrarmonitor-connector', () => ({}));
interface UpdateBody {
kundeID: number;
formData: Record<string, unknown>;
}
function detail(nummer: string): string {
return (
'<form id="form_detail">' +
'<input type="text" name="kundennummer" id="kundennummer" value="21207">' +
`<input type="text" name="lieferantennummer" id="lieferantennummer" value="${nummer}">` +
'<input type="text" name="firma" value="Firma">' +
'<input type="checkbox" name="ist_kunde" value="1">' +
'<input type="checkbox" name="ist_lieferant" value="1" checked>' +
'</form>'
);
}
function kandidat(kundeId: number, firma: string): ListenLieferant {
return { kundeId, lieferantennummer: '', firma, name: '', aktiv: true };
}
function neuerLauf(gesamt: number): LieferantennummerLaufErgebnis {
return {
modus: 'alle',
gestartetAm: '',
beendetAm: null,
gesamt,
vergeben: 0,
uebersprungen: 0,
zuPruefen: 0,
fehler: 0,
abgebrochen: false,
eintraege: [],
};
}
/**
* Simuliert Agrarmonitor: speichert Nummern aus updateKunde.php pro Kunde,
* vorhanden.php meldet für die Ids in `dubletten` ähnliche Kontakte.
*/
function fakeHttp(opts: {
nummern: Record<number, string>;
dubletten?: number[];
speichernIgnorieren?: number[];
}) {
const aufrufe: string[] = [];
const updates: UpdateBody[] = [];
const http = {
get: jest.fn((url: string) => {
aufrufe.push(`GET ${url}`);
const m = /\/kunden\/detail\/(\d+)/.exec(url);
if (m) {
return Promise.resolve({
data: detail(opts.nummern[Number(m[1])] ?? ''),
});
}
return Promise.resolve({ data: '' });
}),
post: jest.fn((url: string, body: string | UpdateBody) => {
aufrufe.push(`POST ${url}`);
if (typeof body === 'string') {
const id = Number(new URLSearchParams(body).get('customerID'));
return Promise.resolve({
data: opts.dubletten?.includes(id) ? '<div>Ähnlich</div>' : '',
});
}
updates.push(body);
if (!opts.speichernIgnorieren?.includes(body.kundeID)) {
opts.nummern[body.kundeID] = String(body.formData.lieferantennummer);
}
return Promise.resolve({ data: '/kunden/detail/' + body.kundeID });
}),
};
return { http: http as unknown as AxiosInstance, aufrufe, updates };
}
function service() {
const agrarmonitor = { clearClient: jest.fn() };
return new LieferantennummernService(
agrarmonitor as unknown as AgrarmonitorService,
);
}
describe('LieferantennummernService.vergeben', () => {
it('vergibt fortlaufend, überspringt Vorhandene und verbraucht bei Dubletten keine Nummer', async () => {
const { http, updates, aufrufe } = fakeHttp({
nummern: { 2: '15550010' },
dubletten: [3],
});
const lauf = neuerLauf(4);
await service().vergeben(
http,
[kandidat(1, 'A'), kandidat(2, 'B'), kandidat(3, 'C'), kandidat(4, 'D')],
15550706,
lauf,
);
expect(lauf.eintraege.map((e) => [e.kundeId, e.status, e.nummer])).toEqual([
[1, 'vergeben', '15550706'],
[2, 'uebersprungen', undefined],
[3, 'pruefen', undefined],
[4, 'vergeben', '15550707'],
]);
expect(lauf).toMatchObject({
vergeben: 2,
uebersprungen: 1,
zuPruefen: 1,
fehler: 0,
abgebrochen: false,
});
expect(updates[0].kundeID).toBe(1);
expect(updates[0].formData).toMatchObject({
kundennummer: '21207',
lieferantennummer: '15550706',
ist_kunde: '',
ist_lieferant: '1',
});
expect(aufrufe[aufrufe.length - 1]).toContain('itemsperpage=25');
expect(lauf.beendetAm).not.toBeNull();
});
it('bricht ab, wenn die Nummer nach dem Speichern nicht übernommen wurde', async () => {
const { http, updates, aufrufe } = fakeHttp({
nummern: {},
speichernIgnorieren: [1],
});
const lauf = neuerLauf(2);
await service().vergeben(
http,
[kandidat(1, 'A'), kandidat(2, 'B')],
15550706,
lauf,
);
expect(updates).toHaveLength(1);
expect(lauf).toMatchObject({ vergeben: 0, fehler: 1, abgebrochen: true });
expect(lauf.eintraege[0].hinweis).toContain('15550706');
expect(aufrufe[aufrufe.length - 1]).toContain('itemsperpage=25');
});
});
@@ -0,0 +1,376 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import type { AxiosInstance } from 'axios';
import { AgrarmonitorService } from './agrarmonitor.service';
import { fehlerText } from './agrarmonitor-polling.service';
import {
anzeigeName,
baueFormData,
bestimmeKandidaten,
hoechsteLieferantennummer,
lieferantennummerAusDetail,
LIEFERANTENNUMMER_SCHEMA,
parseLieferantenListe,
type FormDataWert,
type ListenLieferant,
} from './lieferantennummern.parser';
const AJAX_HEADERS = {
'X-Requested-With': 'XMLHttpRequest',
Accept: '*/*',
};
/** Die Liste mit allen ~700 Lieferanten braucht länger als der Standard-Timeout des Clients. */
const LISTE_TIMEOUT_MS = 60_000;
/** kunden_aktiv: 0 = nur aktive, -1 = aktive und inaktive, 2 = nur inaktive */
function listenUrl(kundenAktiv: number, itemsPerPage: number): string {
return (
'/module/kunden/livesearch.php?lieferanten=1&suchstring=' +
`&kunden_aktiv=${kundenAktiv}&kundengruppe=0&itemsperpage=${itemsPerPage}&seite=1`
);
}
export interface LieferantennummerKandidat {
kundeId: number;
name: string;
neueNummer: string;
}
export interface LieferantennummerVorschau {
maximum: number;
naechsteNummer: number;
kandidaten: LieferantennummerKandidat[];
}
export type LieferantennummerEintragStatus =
| 'vergeben'
| 'uebersprungen'
| 'pruefen'
| 'fehler';
export interface LieferantennummerEintrag {
kundeId: number;
name: string;
status: LieferantennummerEintragStatus;
nummer?: string;
hinweis?: string;
}
export interface LieferantennummerLaufErgebnis {
modus: 'alle' | 'einzeln';
gestartetAm: string;
beendetAm: string | null;
gesamt: number;
vergeben: number;
uebersprungen: number;
zuPruefen: number;
fehler: number;
/** Lauf wurde wegen eines Fehlers vor dem Ende abgebrochen. */
abgebrochen: boolean;
eintraege: LieferantennummerEintrag[];
}
export interface LieferantennummerStatus {
aktiv: boolean;
lauf: LieferantennummerLaufErgebnis | null;
}
function alsText(wert: FormDataWert | undefined): string {
return typeof wert === 'string' ? wert : '';
}
@Injectable()
export class LieferantennummernService {
private readonly logger = new Logger(LieferantennummernService.name);
private aktiv = false;
private lauf: LieferantennummerLaufErgebnis | null = null;
constructor(private readonly agrarmonitorService: AgrarmonitorService) {}
getStatus(): LieferantennummerStatus {
return { aktiv: this.aktiv, lauf: this.lauf };
}
async vorschau(): Promise<LieferantennummerVorschau> {
this.sperren();
try {
const http = await this.http();
try {
const { maximum, kandidaten } = await this.ermittleStand(http);
return {
maximum,
naechsteNummer: maximum + 1,
kandidaten: kandidaten.map((l, i) => ({
kundeId: l.kundeId,
name: anzeigeName(l),
neueNummer: String(maximum + 1 + i),
})),
};
} finally {
await this.filterZuruecksetzen(http);
}
} catch (err) {
this.agrarmonitorService.clearClient();
throw err;
} finally {
this.aktiv = false;
}
}
/**
* Prüft den Stand gegen die Vorschau und startet die Vergabe im Hintergrund.
* Mit nurKundeId wird nur dieser eine Lieferant nummeriert (Testmodus).
*/
async starten(
startNummer: number,
nurKundeId?: number,
): Promise<LieferantennummerStatus> {
this.sperren();
let http: AxiosInstance;
let kandidaten: ListenLieferant[];
try {
http = await this.http();
try {
const stand = await this.ermittleStand(http);
if (stand.maximum + 1 !== startNummer) {
throw new ConflictException(
`Die nächste freie Nummer ist jetzt ${stand.maximum + 1} statt ${startNummer}. Bitte die Vorschau neu laden.`,
);
}
kandidaten = stand.kandidaten;
if (nurKundeId !== undefined) {
kandidaten = kandidaten.filter((l) => l.kundeId === nurKundeId);
if (kandidaten.length === 0) {
throw new BadRequestException(
`Lieferant ${nurKundeId} ist kein aktiver Lieferant ohne Lieferantennummer (mehr).`,
);
}
}
} catch (err) {
await this.filterZuruecksetzen(http);
throw err;
}
} catch (err) {
if (
!(
err instanceof ConflictException || err instanceof BadRequestException
)
) {
this.agrarmonitorService.clearClient();
}
this.aktiv = false;
throw err;
}
this.lauf = {
modus: nurKundeId !== undefined ? 'einzeln' : 'alle',
gestartetAm: new Date().toISOString(),
beendetAm: null,
gesamt: kandidaten.length,
vergeben: 0,
uebersprungen: 0,
zuPruefen: 0,
fehler: 0,
abgebrochen: false,
eintraege: [],
};
this.logger.log(
`Lieferantennummern: Vergabe gestartet (${this.lauf.modus}, ${kandidaten.length} Kandidaten, ab ${startNummer})`,
);
void this.vergeben(http, kandidaten, startNummer, this.lauf).finally(() => {
this.aktiv = false;
});
return this.getStatus();
}
/** Vergibt die Nummern nacheinander; bricht beim ersten Fehler ab. */
async vergeben(
http: AxiosInstance,
kandidaten: ListenLieferant[],
startNummer: number,
lauf: LieferantennummerLaufErgebnis,
): Promise<void> {
let nummer = startNummer;
const eintragen = (eintrag: LieferantennummerEintrag) => {
lauf.eintraege.push(eintrag);
const text = `Lieferantennummern: ${eintrag.name} (${eintrag.kundeId}) → ${eintrag.status}${eintrag.nummer ? ` ${eintrag.nummer}` : ''}${eintrag.hinweis ? `: ${eintrag.hinweis}` : ''}`;
if (eintrag.status === 'fehler') this.logger.error(text);
else if (eintrag.status === 'vergeben') this.logger.log(text);
else this.logger.warn(text);
};
try {
for (const kandidat of kandidaten) {
const basis = {
kundeId: kandidat.kundeId,
name: anzeigeName(kandidat),
};
const neueNummer = String(nummer);
if (!LIEFERANTENNUMMER_SCHEMA.test(neueNummer)) {
lauf.fehler++;
lauf.abgebrochen = true;
eintragen({
...basis,
status: 'fehler',
hinweis: `Nummer ${neueNummer} passt nicht mehr ins Schema 1555xxxx`,
});
return;
}
try {
const detailHtml = await this.ladeDetail(http, kandidat.kundeId);
const vorhandeneNummer = lieferantennummerAusDetail(detailHtml);
if (vorhandeneNummer) {
lauf.uebersprungen++;
eintragen({
...basis,
status: 'uebersprungen',
hinweis: `hat bereits die Nummer ${vorhandeneNummer}`,
});
continue;
}
const formData = baueFormData(detailHtml, neueNummer);
const aehnliche = await this.aehnlicheKontakte(
http,
kandidat.kundeId,
formData,
);
if (aehnliche) {
lauf.zuPruefen++;
eintragen({
...basis,
status: 'pruefen',
hinweis:
'Agrarmonitor meldet ähnliche Kontakte (mögliche Dublette)',
});
continue;
}
await http.post(
'/module/kunden/api/updateKunde.php',
{ kundeID: kandidat.kundeId, formData },
{
headers: { ...AJAX_HEADERS, 'Content-Type': 'application/json' },
},
);
const gespeichert = lieferantennummerAusDetail(
await this.ladeDetail(http, kandidat.kundeId),
);
if (gespeichert !== neueNummer) {
throw new Error(
`Nach dem Speichern steht dort „${gespeichert || 'leer'}“ statt ${neueNummer}`,
);
}
lauf.vergeben++;
eintragen({ ...basis, status: 'vergeben', nummer: neueNummer });
nummer++;
} catch (err) {
lauf.fehler++;
lauf.abgebrochen = true;
eintragen({ ...basis, status: 'fehler', hinweis: fehlerText(err) });
this.agrarmonitorService.clearClient();
return;
}
}
} finally {
await this.filterZuruecksetzen(http);
lauf.beendetAm = new Date().toISOString();
this.logger.log(
`Lieferantennummern: Lauf beendet – ${lauf.vergeben} vergeben, ${lauf.uebersprungen} übersprungen, ` +
`${lauf.zuPruefen} zu prüfen, ${lauf.fehler} Fehler${lauf.abgebrochen ? ' (abgebrochen)' : ''}`,
);
}
}
private sperren(): void {
if (this.aktiv) {
throw new ConflictException(
'Es läuft bereits eine Lieferantennummern-Prüfung.',
);
}
this.aktiv = true;
}
private async http(): Promise<AxiosInstance> {
return (await this.agrarmonitorService.getClient()).http;
}
private async ermittleStand(
http: AxiosInstance,
): Promise<{ maximum: number; kandidaten: ListenLieferant[] }> {
const response = await http.get<string>(listenUrl(-1, 100000), {
headers: AJAX_HEADERS,
responseType: 'text',
timeout: LISTE_TIMEOUT_MS,
});
const lieferanten = parseLieferantenListe(response.data);
const maximum = hoechsteLieferantennummer(lieferanten);
if (maximum === null) {
throw new Error(
`Keine Lieferantennummer im Schema 1555xxxx gefunden (${lieferanten.length} Lieferanten gelesen)`,
);
}
return { maximum, kandidaten: bestimmeKandidaten(lieferanten) };
}
private async ladeDetail(
http: AxiosInstance,
kundeId: number,
): Promise<string> {
const response = await http.get<string>(`/kunden/detail/${kundeId}`, {
headers: AJAX_HEADERS,
responseType: 'text',
});
return response.data;
}
/** Dublettencheck wie in der Weboberfläche; true, wenn Agrarmonitor ähnliche Kontakte meldet. */
private async aehnlicheKontakte(
http: AxiosInstance,
kundeId: number,
formData: Record<string, FormDataWert>,
): Promise<boolean> {
const body = new URLSearchParams({
customerNumber: alsText(formData.kundennummer),
supplierNumber: alsText(formData.lieferantennummer),
customerID: String(kundeId),
isSupplier: alsText(formData.ist_lieferant) ? '1' : '0',
isCustomer: alsText(formData.ist_kunde) ? '1' : '0',
company: alsText(formData.firma),
firstName: alsText(formData.vorname),
lastName: alsText(formData.nachname),
street: alsText(formData.strasse),
});
const response = await http.post<string>(
'/module/kunden/api/vorhanden.php',
body.toString(),
{
headers: {
...AJAX_HEADERS,
'Content-Type': 'application/x-www-form-urlencoded',
},
responseType: 'text',
},
);
return String(response.data ?? '').trim() !== '';
}
/** Agrarmonitor merkt sich die Listenfilter pro Benutzer – danach wieder auf den Standard setzen. */
private async filterZuruecksetzen(http: AxiosInstance): Promise<void> {
try {
await http.get(listenUrl(0, 25), {
headers: AJAX_HEADERS,
responseType: 'text',
});
} catch (err) {
this.logger.warn(
`Lieferantennummern: Listenfilter konnten nicht zurückgesetzt werden: ${fehlerText(err)}`,
);
}
}
}