feat(delivery): add supplier article lookup, article creation, and unified parsing

- Unified PDF/TXT parsing to single POST /api/delivery/parse endpoint
- Added supplier article lookup with SKU/EAN matching in delivery lines
- Added "Artikel anlegen" button to create articles from missing SKUs
- Fixed delivery line taxrate field (was sending 'tax', now 'taxrate')
- Extended TXT file support for rescan functionality
- Updated ApiClient with getArticles method for supplier article lookup
- Added supplier article state management to deliveryEditorAutomat

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
seb
2026-09-18 04:39:07 +02:00
parent 27f6dfdebf
commit d5ade4923d
8 changed files with 670 additions and 91 deletions

View File

@@ -65,6 +65,8 @@ class LieferungNeuPage extends PureComponent {
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
this.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleCreateArticleForLine = this.handleCreateArticleForLine.bind(this);
}
componentDidMount() {
@@ -73,26 +75,32 @@ class LieferungNeuPage extends PureComponent {
if (this.props.docId != null) {
void this.loadEdit();
} else {
if (this.state.deliveryId != null || this.state.number != null) {
void this.loadPartiesOnly();
if (this.props.initialDelivery) {
deliveryEditorAutomat.actions.setDelivery(this.props.initialDelivery);
} else {
deliveryEditorAutomat.actions.resetDelivery();
}
void this.loadPartiesOnly();
}
}
componentDidUpdate(prevProps) {
if (this.props.docId !== prevProps.docId) {
if (prevProps.docId !== this.props.docId) {
if (this.props.docId != null) {
void this.loadEdit();
} else {
deliveryEditorAutomat.actions.resetDelivery();
void this.loadPartiesOnly();
deliveryEditorAutomat.actions.resetDelivery();
}
}
}
componentWillUnmount() {
this._cancelled = true;
if (this._skuDebounceTimers) {
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
this._skuDebounceTimers = null;
}
this.unsubscribe?.();
}
@@ -207,6 +215,86 @@ class LieferungNeuPage extends PureComponent {
...(field === 'net' ? { netprice: value } : {}),
...(field === 'tax' ? { taxrate: value } : {}),
});
if (field === 'sku') {
if (!this._skuDebounceTimers) this._skuDebounceTimers = {};
if (this._skuDebounceTimers[key]) {
clearTimeout(this._skuDebounceTimers[key]);
}
this._skuDebounceTimers[key] = setTimeout(() => {
if (this._skuDebounceTimers) delete this._skuDebounceTimers[key];
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key);
}, 350);
}
}
handleSkuBlur(key, value) {
if (this._skuDebounceTimers?.[key]) {
clearTimeout(this._skuDebounceTimers[key]);
delete this._skuDebounceTimers[key];
}
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key);
}
async handleCreateArticleForLine(line) {
const { partyId } = this.state;
if (!partyId) {
enqueueSnackbar('Bitte wählen Sie zuerst einen Lieferanten aus.', { variant: 'warning' });
return;
}
const categoryName = line?.name;
if (!categoryName) {
enqueueSnackbar('Artikelname fehlt.', { variant: 'warning' });
return;
}
try {
// Get root category ID
const categoryState = this.context.getCategoryState();
if (!categoryState) {
enqueueSnackbar('Kategorie-Daten nicht verfügbar.', { variant: 'error' });
return;
}
const { rootCategoryId } = categoryState;
if (!rootCategoryId) {
enqueueSnackbar('Root-Kategorie nicht gefunden.', { variant: 'error' });
return;
}
// Create article in root category
const articleRes = await this.context.article.create({
parentId: rootCategoryId,
name: categoryName,
});
requireObject(articleRes, 'POST /api/article');
requireObject(articleRes.article, 'article');
requireDefined(articleRes.article.sku, 'article.sku');
const internalSku = articleRes.article.sku;
const supplierSku = line.sku;
// Add supplier relationship with the supplier's SKU
await this.context.article.addSupplier(internalSku, {
entityId: partyId,
sku: supplierSku || undefined,
pu: line.pu ? Number(line.pu) : undefined,
stock: line.amount ? Number(line.amount) : undefined,
netPrice: line.netprice ? Number(line.netprice) : undefined,
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
});
// Keep the supplier SKU in the delivery line, but refresh supplier article data
// using the supplier SKU (not the internal SKU)
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
enqueueSnackbar(`Artikel "${internalSku}" erstellt und Lieferanten-Artikel "${supplierSku}" zugeordnet.`, { variant: 'success' });
} catch (err) {
console.error('Fehler beim Erstellen des Artikels:', err);
notifyError(err);
}
}
handleAddLine() {
@@ -268,39 +356,42 @@ class LieferungNeuPage extends PureComponent {
async handlePdfRescan(attachment) {
if (!attachment?.file || this.state.pdfUploading) return;
// Refresh textfile upon rescan
try {
const text = await extractPdfText(attachment.file, { layout: true });
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
// Refresh textfile upon rescan for PDF
const isPdf = attachment.type === 'application/pdf' || attachment.name.toLowerCase().endsWith('.pdf');
if (isPdf) {
try {
const text = await extractPdfText(attachment.file, { layout: true });
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
if (existingTxt) {
deliveryEditorAutomat.actions.updateAttachment({
id: existingTxt.id,
file: txtFile,
size: txtFile.size,
text,
});
} else {
deliveryEditorAutomat.actions.addAttachment({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(),
});
const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
if (existingTxt) {
deliveryEditorAutomat.actions.updateAttachment({
id: existingTxt.id,
file: txtFile,
size: txtFile.size,
text,
});
} else {
deliveryEditorAutomat.actions.addAttachment({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(),
});
}
} catch (txtErr) {
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
}
} catch (txtErr) {
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
}
await this.runPdfParse(attachment.file);
await this.runParse(attachment.file);
}
handlePdfDelete(attachment) {
@@ -308,7 +399,7 @@ class LieferungNeuPage extends PureComponent {
deliveryEditorAutomat.actions.removeAttachment(attachment.id);
}
async runPdfParse(file) {
async runParse(file) {
this.setState({
pdfUploading: true,
pdfDialogOpen: true,
@@ -327,10 +418,10 @@ class LieferungNeuPage extends PureComponent {
};
try {
const gen = this.context?.delivery?.parsePdfSse;
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
const parser = this.context?.delivery?.parseSse;
if (!parser) throw new Error('parseSse nicht verfügbar.');
for await (const { event, data } of this.context.delivery.parsePdfSse(file)) {
for await (const { event, data } of parser(file)) {
if (this._cancelled) return;
switch (event) {
case 'status':
@@ -414,6 +505,13 @@ class LieferungNeuPage extends PureComponent {
if (notes.length > 0) {
deliveryEditorAutomat.actions.setNotes(notes);
}
// Supply whole article list to GET /supplier/{id}/articles
const effectiveSupplierId = supplierId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
const effectiveLines = (newLines && newLines.length > 0) ? newLines : deliveryEditorAutomat.state.lines;
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
deliveryEditorAutomat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
}
break;
}
case 'error':
@@ -421,7 +519,7 @@ class LieferungNeuPage extends PureComponent {
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
error: data.message ?? 'PDF-Analyse fehlgeschlagen.',
error: data.message ?? 'Analyse fehlgeschlagen.',
},
}));
break;
@@ -435,7 +533,7 @@ class LieferungNeuPage extends PureComponent {
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
error: err.message ?? 'Netzwerkfehler bei PDF-Analyse.',
error: err.message ?? 'Netzwerkfehler bei Analyse.',
},
}));
} finally {
@@ -450,6 +548,14 @@ class LieferungNeuPage extends PureComponent {
}
}
async runPdfParse(file) {
await this.runParse(file);
}
async runTxtParse(file) {
await this.runParse(file);
}
handleClosePdfDialog() {
this.setState({ pdfDialogOpen: false });
}
@@ -646,10 +752,16 @@ class LieferungNeuPage extends PureComponent {
lines={lines}
saving={saving}
linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
supplierArticlesLoading={this.state.supplierArticlesLoading}
loadingLineKeys={this.state.loadingLineKeys}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onRemoveLine={this.handleRemoveLine}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}
/>
</>
)}