fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s

Die tägliche E-Mail-Zusammenfassung zeigte Nutzern Bereiche, für die sie
keine Berechtigung haben. Zwei unabhängige Ursachen:

- Das Backend prüfte weiterhin die Altgruppe PM_Belege. Die Umbenennung
  zu PM_Buchhaltung (ca1d371) war nur im Frontend angekommen, weshalb
  der Digest "Manuell bearbeiten" und "In Agrarmonitor" anbot, während
  die Oberfläche beide Bereiche sperrte.
- Der Cron-Versand wertet die Gruppen aus user_settings aus. Diese Spalte
  wurde nur beim Aufruf der Benutzereinstellungen gefüllt; entzogene
  Berechtigungen erreichten den Digest daher unter Umständen nie.

Behoben durch Angleichen des Gruppen-Mappings und den neuen
UserIdentitySyncService, der E-Mail, Benutzername und Gruppen bei jedem
authentifizierten Request aus dem Token spiegelt – ohne den Request zu
blockieren und ohne DB-Zugriff, solange sich das Token nicht ändert. Die
doppelte Identitätspflege im UserSettingsService entfällt.

WebDAV wird nicht eingesetzt und ist entfernt; Export-Ziele bieten nur
noch FTP. Damit verschwindet das ESM-Paket webdav, an dem zwei
Jest-Suites bereits beim Parsen scheiterten.

Veraltete Tests instand gesetzt: email.controller und settings.controller
mockten weniger Abhängigkeiten, als die Klassen inzwischen haben;
postprocessing.service.spec beschrieb noch das alte Regelmodell mit
Einzelfeldern statt FilterJson und ist gegen die heutige Filter-Engine
neu geschrieben (AND/OR, verschachtelte Gruppen, Fehlerprotokollierung).

Enthält außerdem den Arbeitsstand der E-Rechnungs-Mandantenzuordnung, da
sich beide Änderungen dieselben Dateien teilen (SettingsPage, package.json).

98 Tests in 12 Suites grün, Backend- und Frontend-Build sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5 committed 2026-09-01 00:06:50 +02:00
1 parent 32ba772e4e
commit b7b65c64ae
41 files changed
+2462 -443

No files matched your search

@@ -1,9 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag, Alert } from 'antd';
import { PlusOutlined, EyeOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
import type { DocumentRequirement, PosteingangDocument, Kontonummer, ERechnungVorschlag } from '../api/posteingang';
import { clientsApi } from '../api/inbox';
import type { Client } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
@@ -42,6 +42,10 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
const [steuertagIds, setSteuertagIds] = useState<number[]>([]);
const contentTags = allTags.filter(t => !steuertagIds.includes(t.id));
// E-Rechnung: ausgelesener Vorschlag und die Felder, die daraus befüllt wurden
const [erechnung, setErechnung] = useState<ERechnungVorschlag | null>(null);
const [ausERechnung, setAusERechnung] = useState<{ mandant: boolean, documentType: boolean }>({ mandant: false, documentType: false });
const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null);
const [docTitles, setDocTitles] = useState<Record<number, string>>({});
const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null);
@@ -117,6 +121,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
setKontonummerMissing(null);
setKontonummern([]);
setNewKontonummer('');
setErechnung(null);
setAusERechnung({ mandant: false, documentType: false });
}
}, [open]);
@@ -155,6 +161,41 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
}
}, [document, open, steuertagIds, form]);
// E-Rechnung auswerten und Mandant/Belegart vorschlagen. Es werden nur leere
// Felder befüllt – ein bereits gesetzter Wert stammt vom Benutzer oder einer
// Regel und wird nicht überschrieben.
useEffect(() => {
setErechnung(null);
setAusERechnung({ mandant: false, documentType: false });
if (!open || !isPosteingang || !documentId) return;
let abgebrochen = false;
(async () => {
try {
const vorschlag = await posteingangApi.getErechnung(documentId);
if (abgebrochen || !vorschlag.erechnung) return;
setErechnung(vorschlag);
const uebernommen = { mandant: false, documentType: false };
if (vorschlag.mandantId && !form.getFieldValue('mandant')) {
form.setFieldValue('mandant', vorschlag.mandantId);
uebernommen.mandant = true;
}
if (vorschlag.belegartId && !form.getFieldValue('documentType')) {
form.setFieldValue('documentType', vorschlag.belegartId);
uebernommen.documentType = true;
fetchRequirements(vorschlag.belegartId);
}
setAusERechnung(uebernommen);
} catch {
// Die Auswertung ist eine Hilfestellung – ein Fehler darf das
// Bearbeiten nicht stören und wird deshalb nicht gemeldet.
}
})();
return () => { abgebrochen = true; };
}, [open, isPosteingang, documentId]);
const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => {
if (!correspondentId) return;
@@ -252,9 +293,33 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
};
const handleDocumentTypeChange = (value: number) => {
setAusERechnung(prev => ({ ...prev, documentType: false }));
fetchRequirements(value);
};
/** Kurzbeschreibung der erkannten Rechnung zur Sichtkontrolle. */
const beschreibeRechnung = (v: ERechnungVorschlag) => {
const r = v.rechnung;
const teile: string[] = [];
if (r?.verkaeufer) teile.push(r.verkaeufer);
if (r?.rechnungsnummer) teile.push(`Nr. ${r.rechnungsnummer}`);
if (r?.rechnungsdatum) teile.push(dayjs(r.rechnungsdatum).format('DD.MM.YYYY'));
if (r?.gesamtbetrag !== null && r?.gesamtbetrag !== undefined) {
const waehrung = /^[A-Z]{3}$/.test(r.waehrung || '') ? r.waehrung! : 'EUR';
teile.push(new Intl.NumberFormat('de-DE', { style: 'currency', currency: waehrung }).format(r.gesamtbetrag));
}
return teile.length ? teile.join(' \u00b7 ') : 'Keine weiteren Angaben im Rechnungs-XML.';
};
/** Label mit Chip, solange der Wert aus der E-Rechnung stammt. */
const labelMitHerkunft = (text: string, ausRechnung: boolean) =>
ausRechnung ? (
<Space size={4}>
{text}
<Tag color="blue" style={{ marginInlineEnd: 0 }}>aus E-Rechnung</Tag>
</Space>
) : text;
const handleSaveDocument = async (values: any, isNext: boolean = false) => {
// Collect custom fields into an array
const customFieldsObj: any = {};
@@ -279,6 +344,9 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null,
tags: values.tags || [],
customFields: customFieldsObj,
// Signal fürs Backend, die Käufer-Kennungen auf den gespeicherten
// Mandanten fortzuschreiben.
erechnung: erechnung?.erechnung === true,
};
setSaving(true);
@@ -394,17 +462,37 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
lg={10}
style={isMobile ? {} : { overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}
>
{erechnung?.erechnung && (
<Alert
type={erechnung.hinweis ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 16 }}
message="E-Rechnung erkannt"
description={
<>
<div>{beschreibeRechnung(erechnung)}</div>
{erechnung.hinweis && <div style={{ marginTop: 4 }}>{erechnung.hinweis}</div>}
</>
}
/>
)}
<Form form={form} layout="vertical" disabled={saving}>
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
<Select showSearch optionFilterProp="children" allowClear>
<Form.Item name="mandant" label={labelMitHerkunft('Mandant', ausERechnung.mandant)} rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
<Select
showSearch
optionFilterProp="children"
allowClear
onChange={() => setAusERechnung(prev => ({ ...prev, mandant: false }))}
>
{clients.map(c => (
<Option key={c.Id} value={c.PaperlessUserId}>{c.Name}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="documentType" label="Dokumentart" rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
<Form.Item name="documentType" label={labelMitHerkunft('Dokumentart', ausERechnung.documentType)} rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
<Select showSearch optionFilterProp="children" onChange={handleDocumentTypeChange}>
{documentTypes.map(d => (
<Option key={d.id} value={d.id}>{d.name}</Option>