feat: implement ProcessVerarbeiteteDocuments (Upload-Check)
Build and Push Multi-Platform Images / build-and-push (push) Successful in 37s

Ported ProcessVerarbeiteteDocuments() from C# ProcessUploads.cs:
- Checks docs tagged "hochgeladen" → eingangsrechnungVorhanden()
- On match: livesearch, update title/type/created/correspondent/tags,
  set custom fields (externeBelegnummer, AgrarmonitorLink), addNote
- Tag "hochgeladen" → "fertig" swap; owner via Client.AgrarmonitorBetriebId
- 401/403 guard: clearClient() + break (same pattern as runPolling)
- Cron: AGRARMONITOR_UPLOAD_CHECK_CRON (default: 0 * * * * *)
- New settings: agrarmonitor_tag_hochgeladen, agrarmonitor_link_field
- Endpoint: POST /api/agrarmonitor/process-uploads
- Frontend: polling-config extended with tagHochgeladen + linkField select,
  new card "Dokumenten-Verarbeitung" with run button + result display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:11:44 +02:00
parent a726f863f0
commit 8c5a81ed27
6 changed files with 312 additions and 11 deletions
+1
View File
@@ -60,3 +60,4 @@ AGRARMONITOR_API_TOKEN=
AGRARMONITOR_COOKIE_PATH=./data/agrarmonitor-cookies.json AGRARMONITOR_COOKIE_PATH=./data/agrarmonitor-cookies.json
AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung
AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren
AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren
+1
View File
@@ -46,6 +46,7 @@ services:
- AGRARMONITOR_COOKIE_PATH=${AGRARMONITOR_COOKIE_PATH:-./data/agrarmonitor-cookies.json} - AGRARMONITOR_COOKIE_PATH=${AGRARMONITOR_COOKIE_PATH:-./data/agrarmonitor-cookies.json}
- AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-} - AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-}
- AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-}
- AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-}
volumes: volumes:
- /mnt/scans:/mnt/scans - /mnt/scans:/mnt/scans
- /mnt/paperlessmanager:/mnt/data - /mnt/paperlessmanager:/mnt/data
@@ -9,7 +9,9 @@ import { Client } from '../database/entities/client.entity';
const INTERN_BELEGNUMMER_FIELD_ID = 7; const INTERN_BELEGNUMMER_FIELD_ID = 7;
const EINGANGSDATUM_FIELD_ID = 9; const EINGANGSDATUM_FIELD_ID = 9;
const EXTERN_BELEGNUMMER_FIELD_ID = 3;
const DOCS_PAGE_SIZE = 500; const DOCS_PAGE_SIZE = 500;
const AGRARMONITOR_BASE_URL = 'https://admin7.agrarmonitor.de';
export interface PollingResult { export interface PollingResult {
processed: number; processed: number;
@@ -22,6 +24,7 @@ export interface PollingResult {
export class AgrarmonitorPollingService implements OnModuleInit { export class AgrarmonitorPollingService implements OnModuleInit {
private readonly logger = new Logger(AgrarmonitorPollingService.name); private readonly logger = new Logger(AgrarmonitorPollingService.name);
private pollingRunning = false; private pollingRunning = false;
private uploadCheckRunning = false;
constructor( constructor(
private readonly agrarmonitorService: AgrarmonitorService, private readonly agrarmonitorService: AgrarmonitorService,
@@ -33,6 +36,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
async onModuleInit() { async onModuleInit() {
await this.upsertSetting('agrarmonitor_tag_fertig', '4'); await this.upsertSetting('agrarmonitor_tag_fertig', '4');
await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); await this.upsertSetting('agrarmonitor_tag_verbucht', '9');
await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
await this.upsertSetting('agrarmonitor_link_field', '');
} }
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
@@ -41,21 +46,40 @@ export class AgrarmonitorPollingService implements OnModuleInit {
this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err));
} }
async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string }> { @Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *')
const [fertig, verbucht] = await Promise.all([ async 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 }> {
const [fertig, verbucht, hochgeladen, linkField] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
]); ]);
return { return {
tagFertig: fertig?.Wert ?? '4', tagFertig: fertig?.Wert ?? '4',
tagVerbucht: verbucht?.Wert ?? '9', tagVerbucht: verbucht?.Wert ?? '9',
tagHochgeladen: hochgeladen?.Wert ?? '',
linkField: linkField?.Wert ?? '',
}; };
} }
async updatePollingConfig(tagFertig: string, tagVerbucht: string): Promise<{ tagFertig: string; tagVerbucht: string }> { async updatePollingConfig(
await this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }); tagFertig: string,
await this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }); tagVerbucht: string,
return { tagFertig, tagVerbucht }; tagHochgeladen: string,
linkField: string,
): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: 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 }),
]);
return { tagFertig, tagVerbucht, tagHochgeladen, linkField };
} }
async runPolling(): Promise<PollingResult> { async runPolling(): Promise<PollingResult> {
@@ -210,9 +234,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
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 lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
const searchName = `(${lieferantennummer})`;
const displayName = this.buildCustomerName(customer, lieferantennummer); const displayName = this.buildCustomerName(customer, lieferantennummer);
let corr = await this.paperlessService.getCorrespondentByName(searchName); let corr = await this.paperlessService.getCorrespondentByName(displayName);
if (!corr) { if (!corr) {
corr = await this.paperlessService.addCorrespondent({ corr = await this.paperlessService.addCorrespondent({
name: displayName, name: displayName,
@@ -264,6 +287,213 @@ export class AgrarmonitorPollingService implements OnModuleInit {
return result; 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] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
]);
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
const linkFieldId = parseInt(linkFieldSetting?.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) {
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 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,
});
}
// 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 });
}
}
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) ?? '';
@@ -32,8 +32,8 @@ export class AgrarmonitorController {
@Put('polling-config') @Put('polling-config')
@RequirePermissions(Permission.MANAGE_SETTINGS) @RequirePermissions(Permission.MANAGE_SETTINGS)
async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string }) { async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }) {
return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht); return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField);
} }
@Post('run-polling') @Post('run-polling')
@@ -42,4 +42,11 @@ export class AgrarmonitorController {
async runPolling() { async runPolling() {
return this.pollingService.runPolling(); return this.pollingService.runPolling();
} }
@Post('process-uploads')
@HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async processUploads() {
return this.pollingService.processVerarbeiteteDocuments();
}
} }
+4
View File
@@ -199,6 +199,8 @@ export interface AgrarmonitorStatusData {
export interface AgrarmonitorPollingConfig { export interface AgrarmonitorPollingConfig {
tagFertig: string; tagFertig: string;
tagVerbucht: string; tagVerbucht: string;
tagHochgeladen: string;
linkField: string;
} }
export interface AgrarmonitorPollingResult { export interface AgrarmonitorPollingResult {
@@ -221,4 +223,6 @@ export const agrarmonitorApi = {
api.put<AgrarmonitorPollingConfig>('/api/agrarmonitor/polling-config', config).then((r) => r.data), api.put<AgrarmonitorPollingConfig>('/api/agrarmonitor/polling-config', config).then((r) => r.data),
runPolling: () => runPolling: () =>
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/run-polling').then((r) => r.data), api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/run-polling').then((r) => r.data),
processUploads: () =>
api.post<AgrarmonitorPollingResult>('/api/agrarmonitor/process-uploads').then((r) => r.data),
}; };
+59 -1
View File
@@ -2291,9 +2291,12 @@ function AgrarmonitorTab() {
const [pollingConfigLoading, setPollingConfigLoading] = useState(false); const [pollingConfigLoading, setPollingConfigLoading] = useState(false);
const [pollingSaving, setPollingSaving] = useState(false); const [pollingSaving, setPollingSaving] = useState(false);
const [pollingRunning, setPollingRunning] = useState(false); const [pollingRunning, setPollingRunning] = useState(false);
const [uploadCheckRunning, setUploadCheckRunning] = useState(false);
const [status, setStatus] = useState<AgrarmonitorStatusData | null>(null); const [status, setStatus] = useState<AgrarmonitorStatusData | null>(null);
const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null);
const [pollingResult, setPollingResult] = useState<AgrarmonitorPollingResult | null>(null); const [pollingResult, setPollingResult] = useState<AgrarmonitorPollingResult | null>(null);
const [uploadCheckResult, setUploadCheckResult] = useState<AgrarmonitorPollingResult | null>(null);
const [customFields, setCustomFields] = useState<PaperlessCustomField[]>([]);
const handleLoadStatus = async () => { const handleLoadStatus = async () => {
setLoading(true); setLoading(true);
@@ -2341,7 +2344,10 @@ function AgrarmonitorTab() {
} }
}, [pollingForm]); }, [pollingForm]);
useEffect(() => { handleLoadPollingConfig(); }, [handleLoadPollingConfig]); useEffect(() => {
handleLoadPollingConfig();
paperlessApi.getCustomFields().then(setCustomFields).catch(() => {});
}, [handleLoadPollingConfig]);
const handleSavePollingConfig = async () => { const handleSavePollingConfig = async () => {
const values = await pollingForm.validateFields() as AgrarmonitorPollingConfig; const values = await pollingForm.validateFields() as AgrarmonitorPollingConfig;
@@ -2369,6 +2375,19 @@ function AgrarmonitorTab() {
} }
}; };
const handleProcessUploads = async () => {
setUploadCheckRunning(true);
setUploadCheckResult(null);
try {
const result = await agrarmonitorApi.processUploads();
setUploadCheckResult(result);
} catch {
message.error('Upload-Check fehlgeschlagen');
} finally {
setUploadCheckRunning(false);
}
};
const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => { const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => {
if (value === null) return <Tag></Tag>; if (value === null) return <Tag></Tag>;
return value return value
@@ -2462,6 +2481,16 @@ function AgrarmonitorTab() {
> >
<Input placeholder="9" style={{ width: 120 }} /> <Input placeholder="9" style={{ width: 120 }} />
</Form.Item> </Form.Item>
<Form.Item name="tagHochgeladen" label="Tag-ID: Hochgeladen in Agrarmonitor">
<Input placeholder="3" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="linkField" label="Custom Field: Agrarmonitor-Link">
<Select allowClear placeholder="Kein Feld ausgewählt" style={{ width: 280 }}>
{customFields.map(f => (
<Select.Option key={f.id} value={String(f.id)}>{f.id}: {f.name}</Select.Option>
))}
</Select>
</Form.Item>
<Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}> <Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}>
Speichern Speichern
</Button> </Button>
@@ -2492,6 +2521,35 @@ function AgrarmonitorTab() {
)} )}
</Space> </Space>
</Card> </Card>
<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",
sobald sie im Agrarmonitor-Buchungssystem erscheinen.
</Typography.Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={uploadCheckRunning} onClick={handleProcessUploads}>
Jetzt prüfen
</Button>
{uploadCheckResult && (
<div>
<Tag color="blue">{uploadCheckResult.processed} geprüft</Tag>
<Tag color="success">{uploadCheckResult.updated} aktualisiert</Tag>
<Tag>{uploadCheckResult.skipped} übersprungen</Tag>
{uploadCheckResult.errors.length > 0 && (
<Tag color="error">{uploadCheckResult.errors.length} Fehler</Tag>
)}
{uploadCheckResult.errors.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{uploadCheckResult.errors.map((e, i) => (
<li key={i} style={{ color: '#ff4d4f' }}>{e}</li>
))}
</ul>
)}
</div>
)}
</Space>
</Card>
</Space> </Space>
</div> </div>
); );