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

@@ -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;