From d5ade4923da3af9edd875d8535bbdd4a2fa7eb2e Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 18 Sep 2026 04:39:07 +0200 Subject: [PATCH] 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> --- src/api/ApiClient.js | 21 +- src/api/index.js | 28 +- src/automats/deliveryEditorAutomat.js | 200 +++++++++++++- src/components/manifest/Attachment.jsx | 2 +- src/components/manifest/ManifestFormLine.jsx | 275 +++++++++++++++++-- src/components/manifest/ManifestLines.jsx | 41 ++- src/components/manifest/manifestConfig.js | 4 +- src/pages/lieferung/neu.jsx | 190 ++++++++++--- 8 files changed, 670 insertions(+), 91 deletions(-) diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 10e71bd..d613ee0 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -80,6 +80,18 @@ export class ApiClient { update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }), /** DELETE /api/supplier/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`), + /** GET /api/supplier/{id}/articles?skus=... → { articles: SupplierArticle[] } */ + getArticles: (id, skus) => { + let query = ''; + if (skus != null) { + const list = Array.isArray(skus) ? skus : [skus]; + const filtered = list.map((s) => String(s).trim()).filter(Boolean); + if (filtered.length > 0) { + query = `?skus=${encodeURIComponent(filtered.join(','))}`; + } + } + return this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`); + }, }; this.delivery = { @@ -94,14 +106,15 @@ export class ApiClient { /** DELETE /api/delivery/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`), /** - * POST /api/delivery/parsePDF — multipart/form-data, field "pdf" + * POST /api/delivery/parse — multipart/form-data, field "file" + * Accepts PDF or text files (max 50 MB), detection via mimetype/filename * Returns an async generator that yields SSE event objects: * { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object } */ - parsePdfSse: async function* (file) { + parseSse: async function* (file) { const formData = new FormData(); - formData.append('pdf', file, file.name); - const res = await fetch('/api/delivery/parsePDF', { + formData.append('file', file, file.name); + const res = await fetch('/api/delivery/parse', { method: 'POST', credentials: 'include', body: formData, diff --git a/src/api/index.js b/src/api/index.js index fff5468..bfe7be7 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -611,6 +611,22 @@ export const supplier = { delete(id) { return http(`/api/supplier/${id}`, { method: 'DELETE' }); }, + + /** + * GET /api/supplier/:id/articles?skus=... + * → 200 { articles: SupplierArticle[] } + */ + getArticles(id, skus) { + let query = ''; + if (skus != null) { + const list = Array.isArray(skus) ? skus : [skus]; + const filtered = list.map((s) => String(s).trim()).filter(Boolean); + if (filtered.length > 0) { + query = `?skus=${encodeURIComponent(filtered.join(','))}`; + } + } + return http(`/api/supplier/${encodeURIComponent(id)}/articles${query}`); + }, }; /** @@ -662,18 +678,6 @@ export const delivery = { return http(`/api/delivery/${id}`, { method: 'DELETE' }); }, - /** - * POST /api/delivery/parse-pdf - * Body: raw PDF bytes (Content-Type: application/pdf) - */ - parsePdf(file) { - return httpBinary('/api/delivery/parse-pdf', { - method: 'POST', - contentType: 'application/pdf', - body: file, - headers: file?.name ? { 'X-Filename': encodeURIComponent(file.name) } : undefined, - }); - }, }; /** diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js index 3bbce4a..fa2f6e4 100644 --- a/src/automats/deliveryEditorAutomat.js +++ b/src/automats/deliveryEditorAutomat.js @@ -1,4 +1,5 @@ import { Automat } from 'automat'; +import { supplier as supplierApi } from '../api'; export function createEmptyLineitem(line = {}) { return { @@ -13,6 +14,41 @@ export function createEmptyLineitem(line = {}) { }; } +export function buildSupplierArticlesMap(articles = [], existingMap = {}) { + const map = { ...existingMap }; + for (const item of articles) { + if (!item) continue; + // Map by supplier SKU + if (item.sku != null) { + const skuStr = String(item.sku).trim(); + if (skuStr) { + map[skuStr] = item; + map[skuStr.toLowerCase()] = item; + } + } + // Map by internal article SKU + if (item.article?.sku != null) { + const artSku = String(item.article.sku).trim(); + if (artSku) { + map[artSku] = item; + map[artSku.toLowerCase()] = item; + } + } + // Map by EANs + const eans = item.article?.eans ?? item.article?.articleean ?? []; + for (const ean of eans) { + if (ean != null) { + const eanStr = String(ean).trim(); + if (eanStr) { + map[eanStr] = item; + map[eanStr.toLowerCase()] = item; + } + } + } + } + return map; +} + export const initialDelivery = { id: null, deliveryId: null, @@ -28,6 +64,12 @@ export const initialDelivery = { notes: [], attachments: [], files: [], + supplierArticles: [], + supplierArticlesMap: {}, + supplierArticlesByLineKey: {}, + supplierArticlesLoading: false, + loadingLineKeys: {}, + supplierArticlesError: null, }; export const initialState = { @@ -71,6 +113,10 @@ export const deliveryEditorAutomat = new Automat(initialState, { attachments, files: attachments, }); + + if (partyId && lines.length > 0) { + deliveryEditorAutomat.actions.fetchSupplierArticles(partyId, lines); + } }, setDate(date) { @@ -78,11 +124,28 @@ export const deliveryEditorAutomat = new Automat(initialState, { }, setDeliveryEntity(entityId) { - deliveryEditorAutomat.setState({ deliveryEntity: entityId, partyId: entityId }); + const prevId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; + const idChanged = prevId !== entityId; + deliveryEditorAutomat.setState({ + deliveryEntity: entityId, + partyId: entityId, + ...(idChanged + ? { + supplierArticles: [], + supplierArticlesMap: {}, + supplierArticlesByLineKey: {}, + loadingLineKeys: {}, + } + : {}), + }); + + if (entityId && idChanged) { + deliveryEditorAutomat.actions.fetchSupplierArticles(entityId); + } }, setPartyId(partyId) { - deliveryEditorAutomat.setState({ deliveryEntity: partyId, partyId }); + deliveryEditorAutomat.actions.setDeliveryEntity(partyId); }, setExternalNumber(externalNumber) { @@ -151,15 +214,24 @@ export const deliveryEditorAutomat = new Automat(initialState, { removeLineitem(payload) { const currentLines = deliveryEditorAutomat.state.lines ?? []; let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1; + let targetKey = null; if (index === -1 && payload != null) { const key = typeof payload === 'string' ? payload : payload?.key; if (key != null) { index = currentLines.findIndex((l) => l.key === key); + targetKey = key; } } if (index >= 0 && index < currentLines.length) { + targetKey = targetKey ?? currentLines[index]?.key; const nextLines = currentLines.filter((_, i) => i !== index); - deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines }); + const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; + if (targetKey != null) delete byLineKey[targetKey]; + deliveryEditorAutomat.setState({ + lines: nextLines, + lineitems: nextLines, + supplierArticlesByLineKey: byLineKey, + }); } }, @@ -167,6 +239,121 @@ export const deliveryEditorAutomat = new Automat(initialState, { deliveryEditorAutomat.actions.removeLineitem(payload); }, + async fetchSupplierArticles(targetEntityId, targetLines) { + const entityId = targetEntityId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; + if (!entityId) { + deliveryEditorAutomat.setState({ + supplierArticles: [], + supplierArticlesMap: {}, + supplierArticlesByLineKey: {}, + supplierArticlesLoading: false, + loadingLineKeys: {}, + }); + return; + } + + const lines = targetLines ?? deliveryEditorAutomat.state.lines ?? []; + const skus = Array.from( + new Set( + lines + .map((l) => (l.sku != null ? String(l.sku).trim() : '')) + .filter(Boolean), + ), + ); + + deliveryEditorAutomat.setState({ + supplierArticlesLoading: true, + supplierArticlesError: null, + }); + + try { + const res = await supplierApi.getArticles(entityId, skus); + const articles = Array.isArray(res?.articles) ? res.articles : []; + + const map = buildSupplierArticlesMap(articles); + const byLineKey = {}; + for (const line of lines) { + if (line.key != null) { + const rawSku = line.sku != null ? String(line.sku).trim() : ''; + byLineKey[line.key] = rawSku ? (map[rawSku.toLowerCase()] || map[rawSku] || null) : null; + } + } + + deliveryEditorAutomat.setState({ + supplierArticles: articles, + supplierArticlesMap: map, + supplierArticlesByLineKey: byLineKey, + supplierArticlesLoading: false, + loadingLineKeys: {}, + }); + } catch (err) { + console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err); + deliveryEditorAutomat.setState({ + supplierArticlesLoading: false, + supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel', + }); + } + }, + + async fetchSupplierArticleForSku(sku, lineKey) { + const entityId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; + const trimmed = sku != null ? String(sku).trim() : ''; + + if (!entityId || !trimmed) { + if (lineKey != null) { + const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; + byLineKey[lineKey] = null; + deliveryEditorAutomat.setState({ supplierArticlesByLineKey: byLineKey }); + } + return; + } + + const prevLoading = deliveryEditorAutomat.state.loadingLineKeys || {}; + deliveryEditorAutomat.setState({ + loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading, + }); + + try { + const res = await supplierApi.getArticles(entityId, [trimmed]); + const articles = Array.isArray(res?.articles) ? res.articles : []; + + const nextMap = buildSupplierArticlesMap(articles, deliveryEditorAutomat.state.supplierArticlesMap); + const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null; + + const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; + if (lineKey != null) { + byLineKey[lineKey] = matched; + } + + const currentList = deliveryEditorAutomat.state.supplierArticles || []; + const mergedList = [...currentList]; + for (const a of articles) { + if (!mergedList.some((existing) => existing.id === a.id)) { + mergedList.push(a); + } + } + + const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) }; + if (lineKey != null) delete nextLoading[lineKey]; + + deliveryEditorAutomat.setState({ + supplierArticles: mergedList, + supplierArticlesMap: nextMap, + supplierArticlesByLineKey: byLineKey, + loadingLineKeys: nextLoading, + supplierArticlesLoading: Object.keys(nextLoading).length > 0, + }); + } catch (err) { + console.error('Fehler beim Abrufen des Artikels für SKU:', err); + const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) }; + if (lineKey != null) delete nextLoading[lineKey]; + deliveryEditorAutomat.setState({ + loadingLineKeys: nextLoading, + supplierArticlesLoading: Object.keys(nextLoading).length > 0, + }); + } + }, + addAttachment(attachment) { const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const item = { ...attachment, id }; @@ -234,6 +421,8 @@ export const { updateLine, removeLineitem, removeLine, + fetchSupplierArticles, + fetchSupplierArticleForSku, addAttachment, setAttachments, updateAttachment, @@ -253,5 +442,10 @@ export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems; export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes; export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments; +export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles; +export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap; +export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey; +export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading; +export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys; export default deliveryEditorAutomat; diff --git a/src/components/manifest/Attachment.jsx b/src/components/manifest/Attachment.jsx index f93e599..e42306e 100644 --- a/src/components/manifest/Attachment.jsx +++ b/src/components/manifest/Attachment.jsx @@ -163,7 +163,7 @@ export class Attachment extends PureComponent { }} onClick={(e) => e.stopPropagation()} > - {isPdf ? ( + {isPdf || isText ? ( onChange(line.key, id, e.target.value)} + onBlur={(e) => this.handleBlur(e, id)} slotProps={{ htmlInput: { inputMode: type === 'number' ? 'decimal' : 'text', @@ -51,31 +78,239 @@ class ManifestFormLine extends PureComponent { ); } + renderSecondLine() { + const { line, supplierArticle, loading } = this.props; + const rawSku = line?.sku != null ? String(line.sku).trim() : ''; + + if (loading) { + return ( + + + + Artikeldaten werden geladen… + + + ); + } + + if (supplierArticle) { + const article = supplierArticle.article || {}; + const photo = article.photos?.[0] || article.articlepic?.[0]; + const fullHash = photo?.hash || photo?.fullHash; + const thumbHash = photo?.thumbhash || photo?.thumbHash; + const thumbW = photo?.thumbw || photo?.thumbW; + const thumbH = photo?.thumbh || photo?.thumbH; + const hasPhoto = fullHash != null || thumbHash != null; + + const eans = article.eans ?? article.articleean ?? []; + const primaryEan = Array.isArray(eans) ? eans[0] : eans; + const priceNet = supplierArticle.price?.net; + const taxRate = supplierArticle.price?.taxRate; + + return ( + + {/* Article Photo Thumbnail */} + + {hasPhoto ? ( + + ) : ( + + )} + + + {/* Match Indicator */} + + + {/* Internal Article SKU */} + {article.sku && ( + + )} + + {/* Article Name */} + {article.name && ( + + {article.name} + + )} + + {/* Supplier SKU if distinct */} + {supplierArticle.sku && supplierArticle.sku !== rawSku && supplierArticle.sku !== article.sku && ( + + (Lief.-SKU: {supplierArticle.sku}) + + )} + + {/* Primary EAN */} + {primaryEan && ( + + EAN: {primaryEan} + + )} + + {/* Stock */} + + + {/* Packaging Unit (PU) */} + {supplierArticle.pu != null && ( + + )} + + {/* Stored Purchase Price (Netto) */} + {priceNet != null && ( + + EK Netto: {formatPrice(priceNet)} + {taxRate != null ? ` (${taxRate}% MwSt)` : ''} + + )} + + ); + } + + if (rawSku) { + return ( + + + + Kein Lieferanten-Artikel für SKU „{rawSku}“ gefunden. + + {this.props.onCreateArticle && ( + + )} + + ); + } + + return ( + + SKU eingeben oder Dokument scannen für automatische Artikelzuordnung + + ); + } + render() { const { columns = LINE_COLUMNS, saving } = this.props; return ( - - {columns.map((col) => ( - - {this.renderField(col)} + + {/* Row 1: Editable Line Inputs */} + td': { borderBottom: 'none', py: 0.75 } }}> + {columns.map((col) => ( + + {this.renderField(col)} + + ))} + + + + - ))} - - + + {/* Row 2: Matched Supplier Article Details */} + td': { pt: 0, pb: 0.75, borderBottom: '1px solid', borderColor: 'divider' } }}> + - - - - + {this.renderSecondLine()} + + + ); } } diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index 146b6c6..a9f0105 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -24,6 +24,12 @@ class ManifestLines extends PureComponent { onRemoveLine, onSave, columns = LINE_COLUMNS, + supplierArticlesMap = {}, + supplierArticlesByLineKey = {}, + supplierArticlesLoading = false, + loadingLineKeys = {}, + onSkuBlur, + onCreateArticle, } = this.props; return ( @@ -50,16 +56,31 @@ class ManifestLines extends PureComponent { - {lines.map((line, index) => ( - - ))} + {lines.map((line, index) => { + const lineKey = line.key ?? index; + const rawSku = line.sku != null ? String(line.sku).trim() : ''; + const matched = + supplierArticlesByLineKey[lineKey] || + (rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null); + const isLineLoading = + loadingLineKeys[lineKey] || + (supplierArticlesLoading && !matched); + + return ( + + ); + })} diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index f9e27bc..33f321c 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -114,9 +114,9 @@ export function buildLines(lines, { requireAtLeastOne }) { !sku && !name && amount == null && net == null && tax == null && rebate == null; if (blank) continue; if ((!sku && !name) || amount == null || net == null || tax == null) { - throw new Error('Jede Zeile braucht SKU oder Name, Amount, Net und Tax.'); + throw new Error('Jede Zeile braucht SKU oder Name, Amount, Net und Taxrate.'); } - const row = { amount, net, tax }; + const row = { amount, net, taxrate: tax }; if (sku) row.sku = sku; if (name) row.name = name; if (rebate != null) row.rebate = rebate; diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index f9e0cc6..c897a5c 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -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} /> )}