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