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

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

View File

@@ -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,
});
},
};
/**

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;

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;

View File

@@ -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}
/>
</>
)}