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

@@ -163,7 +163,7 @@ export class Attachment extends PureComponent {
}}
onClick={(e) => e.stopPropagation()}
>
{isPdf ? (
{isPdf || isText ? (
<Tooltip title="Neu analysieren">
<span>
<IconButton

View File

@@ -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 &bdquo;{rawSku}&ldquo; 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>
);
}
}

View File

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

View File

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