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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user