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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -163,7 +163,7 @@ export class Attachment extends PureComponent {
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isPdf ? (
|
||||
{isPdf || isText ? (
|
||||
<Tooltip title="Neu analysieren">
|
||||
<span>
|
||||
<IconButton
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { Fragment, PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
|
||||
class ManifestFormLine extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleRemove = this.handleRemove.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||
}
|
||||
|
||||
handleRemove() {
|
||||
this.props.onRemove(this.props.line.key);
|
||||
}
|
||||
|
||||
handleBlur(e, columnId) {
|
||||
if (columnId === 'sku' && this.props.onSkuBlur) {
|
||||
this.props.onSkuBlur(this.props.line.key, e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
handleCreateArticle() {
|
||||
const { line, onCreateArticle } = this.props;
|
||||
if (onCreateArticle) {
|
||||
onCreateArticle(line);
|
||||
}
|
||||
}
|
||||
|
||||
renderField(column) {
|
||||
const { id, align, type } = column;
|
||||
const { line, saving, onChange } = this.props;
|
||||
@@ -28,6 +54,7 @@ class ManifestFormLine extends PureComponent {
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
onChange={(e) => 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 (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.25 }}>
|
||||
<CircularProgress size={14} thickness={5} sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.72rem' }}>
|
||||
Artikeldaten werden geladen…
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
py: 0.25,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{/* Article Photo Thumbnail */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
minWidth: 28,
|
||||
borderRadius: 0.75,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{hasPhoto ? (
|
||||
<ArticlePhoto
|
||||
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||||
size={28}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Match Indicator */}
|
||||
<CheckCircleOutlinedIcon sx={{ fontSize: 16, color: 'success.main', flexShrink: 0 }} />
|
||||
|
||||
{/* Internal Article SKU */}
|
||||
{article.sku && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Art.-Nr.: ${article.sku}`}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: 'primary.50',
|
||||
color: 'primary.dark',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.200',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article Name */}
|
||||
{article.name && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
|
||||
title={article.name}
|
||||
>
|
||||
{article.name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Supplier SKU if distinct */}
|
||||
{supplierArticle.sku && supplierArticle.sku !== rawSku && supplierArticle.sku !== article.sku && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
(Lief.-SKU: <strong>{supplierArticle.sku}</strong>)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Primary EAN */}
|
||||
{primaryEan && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.68rem', bgcolor: 'action.hover', px: 0.5, py: 0.1, borderRadius: 0.5 }}
|
||||
>
|
||||
EAN: {primaryEan}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Stock */}
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Bestand: ${supplierArticle.stock ?? 0}`}
|
||||
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
{/* Packaging Unit (PU) */}
|
||||
{supplierArticle.pu != null && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`VPE: ${supplierArticle.pu}`}
|
||||
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stored Purchase Price (Netto) */}
|
||||
{priceNet != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{ color: 'success.dark', ml: 'auto', fontSize: '0.72rem', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
EK Netto: {formatPrice(priceNet)}
|
||||
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (rawSku) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
|
||||
Kein Lieferanten-Artikel für SKU „{rawSku}“ gefunden.
|
||||
</Typography>
|
||||
{this.props.onCreateArticle && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
startIcon={<AddIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={this.handleCreateArticle}
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
minWidth: 'auto',
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.50',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Artikel anlegen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
SKU eingeben oder Dokument scannen für automatische Artikelzuordnung
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { columns = LINE_COLUMNS, saving } = this.props;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
align={col.align}
|
||||
sx={{ py: 0.75, verticalAlign: 'middle' }}
|
||||
>
|
||||
{this.renderField(col)}
|
||||
<Fragment>
|
||||
{/* Row 1: Editable Line Inputs */}
|
||||
<TableRow hover sx={{ '& > td': { borderBottom: 'none', py: 0.75 } }}>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
align={col.align}
|
||||
sx={{ py: 0.75, verticalAlign: 'middle' }}
|
||||
>
|
||||
{this.renderField(col)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell padding="checkbox" sx={{ py: 0.75 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Zeile entfernen"
|
||||
disabled={saving}
|
||||
onClick={this.handleRemove}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell padding="checkbox" sx={{ py: 0.75 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Zeile entfernen"
|
||||
disabled={saving}
|
||||
onClick={this.handleRemove}
|
||||
</TableRow>
|
||||
|
||||
{/* Row 2: Matched Supplier Article Details */}
|
||||
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: '1px solid', borderColor: 'divider' } }}>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
sx={{
|
||||
py: 0.4,
|
||||
px: 2,
|
||||
bgcolor: 'rgba(248, 250, 252, 0.75)',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{this.renderSecondLine()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{lines.map((line, index) => (
|
||||
<ManifestFormLine
|
||||
key={line.key ?? index}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
/>
|
||||
))}
|
||||
{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 (
|
||||
<ManifestFormLine
|
||||
key={lineKey}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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