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

+54
View File
@@ -258,6 +258,46 @@ export interface SyncCorrespondentsResult {
conflicts: SyncConflict[];
}
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;
abgebrochen: boolean;
eintraege: LieferantennummerEintrag[];
}
export interface LieferantennummerStatus {
aktiv: boolean;
lauf: LieferantennummerLaufErgebnis | null;
}
export const agrarmonitorApi = {
getStatus: () =>
api.get<AgrarmonitorStatusData>('/api/agrarmonitor/status').then((r) => r.data),
@@ -277,4 +317,18 @@ export const agrarmonitorApi = {
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),
getLieferantennummernStatus: () =>
api.get<LieferantennummerStatus>('/api/agrarmonitor/lieferantennummern/status').then((r) => r.data),
lieferantennummernVorschau: () =>
api
.post<LieferantennummerVorschau>('/api/agrarmonitor/lieferantennummern/vorschau', {}, { timeout: 120_000 })
.then((r) => r.data),
lieferantennummernVergeben: (startNummer: number, nurKundeId?: number) =>
api
.post<LieferantennummerStatus>(
'/api/agrarmonitor/lieferantennummern/vergeben',
{ startNummer, nurKundeId },
{ timeout: 120_000 },
)
.then((r) => r.data),
};
@@ -23,6 +23,8 @@ import {
agrarmonitorApi, type AgrarmonitorStatusData,
type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult,
type SyncConflict,
type LieferantennummerVorschau, type LieferantennummerStatus, type LieferantennummerEintrag,
type LieferantennummerEintragStatus,
type SettingClientIdentifier, type ClientIdentifierTyp, CLIENT_IDENTIFIER_LABELS,
} from '../api/settings';
import { clientsApi, type Client } from '../api/inbox';
@@ -2778,6 +2780,8 @@ function AgrarmonitorTab() {
</Space>
</Card>
<LieferantennummernCard />
<Card size="small" title="Dokumenten-Verarbeitung">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig",
@@ -2812,6 +2816,214 @@ function AgrarmonitorTab() {
}
const LIEFERANTENNUMMER_STATUS: Record<LieferantennummerEintragStatus, { label: string; color: string }> = {
vergeben: { label: 'vergeben', color: 'success' },
uebersprungen: { label: 'übersprungen', color: 'default' },
pruefen: { label: 'zu prüfen', color: 'warning' },
fehler: { label: 'Fehler', color: 'error' },
};
function fehlermeldung(err: unknown, fallback: string): string {
const msg = (err as { response?: { data?: { message?: unknown } } })?.response?.data?.message;
return typeof msg === 'string' && msg ? msg : fallback;
}
function LieferantennummernCard() {
const [status, setStatus] = useState<LieferantennummerStatus | null>(null);
const [vorschau, setVorschau] = useState<LieferantennummerVorschau | null>(null);
const [vorschauLaedt, setVorschauLaedt] = useState(false);
const [startet, setStartet] = useState(false);
const [einzelKundeId, setEinzelKundeId] = useState<number | undefined>(undefined);
const ladeStatus = useCallback(async () => {
try {
setStatus(await agrarmonitorApi.getLieferantennummernStatus());
} catch {
// Status ist nur Anzeige – beim nächsten Intervall erneut versuchen
}
}, []);
useEffect(() => {
ladeStatus();
}, [ladeStatus]);
// Solange ein Lauf aktiv ist, den Fortschritt abfragen
useEffect(() => {
if (!status?.aktiv) return;
const timer = setInterval(ladeStatus, 2000);
return () => clearInterval(timer);
}, [status?.aktiv, ladeStatus]);
const laufAktiv = !!status?.aktiv || startet;
const handleVorschau = async () => {
setVorschauLaedt(true);
try {
const data = await agrarmonitorApi.lieferantennummernVorschau();
setEinzelKundeId(undefined);
setVorschau(data);
} catch (err) {
message.error(fehlermeldung(err, 'Lieferantenliste konnte nicht geladen werden'));
} finally {
setVorschauLaedt(false);
}
};
const handleVergeben = async (nurKundeId?: number) => {
if (!vorschau) return;
setStartet(true);
try {
setStatus(await agrarmonitorApi.lieferantennummernVergeben(vorschau.naechsteNummer, nurKundeId));
setVorschau(null);
} catch (err) {
message.error(fehlermeldung(err, 'Vergabe konnte nicht gestartet werden'));
} finally {
setStartet(false);
}
};
const lauf = status?.lauf;
const einzelKandidat = vorschau?.kandidaten.find(k => k.kundeId === einzelKundeId);
return (
<Card size="small" title="Lieferantennummern überprüfen">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Vergibt allen aktiven Lieferanten in Agrarmonitor ohne Lieferantennummer eine fortlaufende
Nummer (Schema 1555xxxx, ab der höchsten vergebenen Nummer). Vor dem Schreiben wird eine
Vorschau angezeigt.
</Typography.Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={vorschauLaedt} disabled={laufAktiv} onClick={handleVorschau}>
Fehlende Lieferantennummern vergeben
</Button>
{lauf && (
<div>
{status?.aktiv && (
<Alert
type="info"
showIcon
style={{ marginBottom: 8 }}
message={`Vergabe läuft … ${lauf.eintraege.length} von ${lauf.gesamt} bearbeitet`}
/>
)}
{!status?.aktiv && lauf.abgebrochen && (
<Alert
type="error"
showIcon
style={{ marginBottom: 8 }}
message="Lauf wegen eines Fehlers abgebrochen"
/>
)}
<div style={{ marginBottom: 8 }}>
<Tag color="success">{lauf.vergeben} vergeben</Tag>
<Tag>{lauf.uebersprungen} übersprungen</Tag>
<Tag color="warning">{lauf.zuPruefen} zu prüfen</Tag>
{lauf.fehler > 0 && <Tag color="error">{lauf.fehler} Fehler</Tag>}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{lauf.modus === 'einzeln' ? 'Einzeltest' : 'Alle'} · {dayjs(lauf.gestartetAm).format('DD.MM.YYYY HH:mm')}
</Typography.Text>
</div>
{lauf.eintraege.length > 0 && (
<Table<LieferantennummerEintrag>
size="small"
rowKey="kundeId"
dataSource={lauf.eintraege}
pagination={lauf.eintraege.length > 10 ? { pageSize: 10, size: 'small' } : false}
columns={[
{ title: 'Lieferant', dataIndex: 'name' },
{
title: 'Status',
dataIndex: 'status',
width: 110,
render: (st: LieferantennummerEintragStatus) => (
<Tag color={LIEFERANTENNUMMER_STATUS[st].color}>{LIEFERANTENNUMMER_STATUS[st].label}</Tag>
),
},
{ title: 'Nummer', dataIndex: 'nummer', width: 100 },
{ title: 'Hinweis', dataIndex: 'hinweis' },
]}
/>
)}
</div>
)}
</Space>
<Modal
open={!!vorschau}
title="Lieferantennummern vergeben"
width={720}
onCancel={() => setVorschau(null)}
footer={[
<Button key="abbrechen" onClick={() => setVorschau(null)}>
Abbrechen
</Button>,
<Button
key="einzeln"
disabled={!einzelKandidat}
loading={startet}
onClick={() => handleVergeben(einzelKundeId)}
>
Nur diesen vergeben
</Button>,
<Button
key="alle"
type="primary"
disabled={!vorschau?.kandidaten.length}
loading={startet}
onClick={() => handleVergeben()}
>
Jetzt vergeben
</Button>,
]}
>
{vorschau && (
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text>
Höchste vergebene Nummer: <strong>{vorschau.maximum}</strong> · nächste freie
Nummer: <strong>{vorschau.naechsteNummer}</strong> · <strong>{vorschau.kandidaten.length}</strong>{' '}
aktive Lieferanten ohne Nummer
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Werden Lieferanten übersprungen oder als „zu prüfen“ gemeldet, rücken die folgenden Nummern
nach, damit keine Lücken entstehen.
</Typography.Text>
<Table
size="small"
rowKey="kundeId"
dataSource={vorschau.kandidaten}
pagination={vorschau.kandidaten.length > 10 ? { pageSize: 10, size: 'small' } : false}
columns={[
{ title: 'Lieferant', dataIndex: 'name' },
{ title: 'Kunden-ID', dataIndex: 'kundeId', width: 100 },
{ title: 'Neue Nummer', dataIndex: 'neueNummer', width: 120 },
]}
/>
<Divider style={{ margin: '4px 0' }} />
<Typography.Text strong>Test: nur einen Lieferanten</Typography.Text>
<Select
showSearch
allowClear
placeholder="Lieferant auswählen"
style={{ width: '100%' }}
value={einzelKundeId}
onChange={setEinzelKundeId}
optionFilterProp="label"
options={vorschau.kandidaten.map(k => ({ value: k.kundeId, label: `${k.name} (${k.kundeId})` }))}
/>
{einzelKandidat && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{einzelKandidat.name} erhält die Nummer {vorschau.naechsteNummer}.
</Typography.Text>
)}
</Space>
)}
</Modal>
</Card>
);
}
// ═══════════════════════════════════════════════════════════════════
// Steuertags Tab
// ═══════════════════════════════════════════════════════════════════