This commit is contained in:
seb
2026-10-17 17:11:11 +02:00
parent 20d83b00fc
commit 01d17ebb25
11 changed files with 450 additions and 372 deletions

View File

@@ -34,12 +34,75 @@ import {
fetchDeliveryLedgerSummary,
bookDeliveryLines,
} from '../../utils/deliveryLedger';
import { getArticle } from '../../automats/artikelAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat';
import { supplier as supplierApi } from '../../api';
import deliveryEditorAutomat, { buildSupplierArticlesMap } from '../../automats/deliveryEditorAutomat';
import {
SURFACE_BASE,
SURFACE_RAISED,
} from '../../colors';
function resolveLineSkus(line, delivery, supplierArticlesMap = {}) {
const rawSku = line?.sku ? String(line.sku).trim() : '';
const supplierId = delivery?.supplierId ?? delivery?.partyId;
// 1. Direct line properties if available
let supplierSku =
line?.supplierSku ||
line?.supplier_sku ||
line?.supplierArticle?.sku ||
'';
let ownSku =
line?.articleSku ||
line?.article_sku ||
line?.article?.sku ||
line?.supplierArticle?.article?.sku ||
'';
// 2. Check supplierArticlesMap
const sa = rawSku
? (supplierArticlesMap[rawSku] || supplierArticlesMap[rawSku.toLowerCase()])
: null;
if (sa) {
if (!supplierSku && sa.sku) supplierSku = sa.sku;
if (!ownSku && sa.article?.sku) ownSku = sa.article.sku;
}
// 3. Check loaded article details
const cachedArticle =
line?.article ||
(ownSku ? getArticle(ownSku) : null) ||
(rawSku ? getArticle(rawSku) : null);
if (cachedArticle) {
if (!ownSku && cachedArticle.sku) {
ownSku = cachedArticle.sku;
}
const sup =
(supplierId && cachedArticle.suppliers?.find((s) => s.entityId === supplierId)) ||
cachedArticle.suppliers?.[0];
if (!supplierSku && sup?.sku) {
supplierSku = sup.sku;
}
}
// 4. Fallback heuristics if one is still missing
if (!supplierSku && !ownSku) {
ownSku = rawSku;
} else if (!supplierSku && rawSku && rawSku !== ownSku) {
supplierSku = rawSku;
} else if (!ownSku && rawSku && rawSku !== supplierSku) {
ownSku = rawSku;
}
return {
supplierSku: supplierSku || '–',
ownSku: ownSku || '–',
};
}
export default class BookDeliveryDialog extends PureComponent {
constructor(props) {
super(props);
@@ -61,19 +124,23 @@ export default class BookDeliveryDialog extends PureComponent {
}
const initialWarehouses = warehouseAutomat.state.warehouses || [];
const initialSupplierMap =
props.supplierArticlesMap ||
(deliveryEditorAutomat?.state?.supplierArticlesMap ?? {});
this.state = {
summary: props.summary ?? null,
loading: !props.summary,
error: null,
amountsToBook: initialAmounts,
transactionText: props.delivery?.number
transactionText: props.delivery?.externalNumber || (props.delivery?.number
? `Lieferung ${props.delivery.number}`
: 'Lieferung Wareneingang',
: 'Lieferung Wareneingang'),
isBooking: false,
warehouses: initialWarehouses,
selectedWarehouseId: initialWarehouses.length === 1 ? initialWarehouses[0].id : '',
warehouseError: null,
supplierArticlesMap: initialSupplierMap,
};
this.handleAmountChange = this.handleAmountChange.bind(this);
@@ -84,6 +151,17 @@ export default class BookDeliveryDialog extends PureComponent {
this.handleConfirmBook = this.handleConfirmBook.bind(this);
}
loadSupplierArticlesIfNeeded() {
const supplierId = this.props.delivery?.supplierId ?? this.props.delivery?.partyId;
if (supplierId && Object.keys(this.state.supplierArticlesMap).length === 0) {
void supplierApi.get(supplierId).then((res) => {
const articles = res?.supplier?.articles || res?.articles || [];
const map = buildSupplierArticlesMap(articles);
this.setState({ supplierArticlesMap: map });
}).catch(() => {});
}
}
componentDidMount() {
this.unsubWarehouse = warehouseAutomat.subscribe((state) => {
const warehouses = state.warehouses || [];
@@ -98,6 +176,7 @@ export default class BookDeliveryDialog extends PureComponent {
});
});
void warehouseAutomat.actions.load().catch(() => {});
this.loadSupplierArticlesIfNeeded();
if (this.props.open) {
void this.loadSummary();
@@ -111,6 +190,9 @@ export default class BookDeliveryDialog extends PureComponent {
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || [];
const currentSupplierMap =
this.props.supplierArticlesMap ||
(deliveryEditorAutomat?.state?.supplierArticlesMap ?? this.state.supplierArticlesMap ?? {});
this.setState(
{
summary: this.props.summary ?? null,
@@ -120,13 +202,15 @@ export default class BookDeliveryDialog extends PureComponent {
selectedWarehouseId:
this.state.selectedWarehouseId ||
(currentWarehouses.length === 1 ? currentWarehouses[0].id : ''),
transactionText: this.props.delivery?.number
transactionText: this.props.delivery?.externalNumber || (this.props.delivery?.number
? `Lieferung ${this.props.delivery.number}`
: 'Lieferung Wareneingang',
: 'Lieferung Wareneingang'),
isBooking: false,
supplierArticlesMap: currentSupplierMap,
},
() => {
void warehouseAutomat.actions.load().catch(() => {});
this.loadSupplierArticlesIfNeeded();
if (this.props.summary) {
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
} else {
@@ -284,105 +368,111 @@ export default class BookDeliveryDialog extends PureComponent {
}
}
renderHeaderInfo() {
renderHeaderInfo({ hasTrackedLinesToBook, isWarehouseMissing } = {}) {
const { delivery, partyName } = this.props;
const { summary } = this.state;
const {
transactionText,
isBooking,
summary,
warehouses,
selectedWarehouseId,
warehouseError,
} = this.state;
const isFullyBooked = (summary?.lines?.length ?? 0) > 0 && summary?.status === 'booked';
return (
<Paper
variant="outlined"
sx={{
p: 1.5,
mb: 2,
display: 'flex',
flexWrap: 'wrap',
gap: 3,
backgroundColor: SURFACE_BASE,
alignItems: 'center',
}}
>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Nummer
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{delivery?.number || '–'}
</Typography>
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1.5,
mb: 1.5,
}}
>
<TextField
size="small"
label="Datum"
value={formatDate(delivery?.date) || '–'}
disabled
sx={{ flex: { xs: '1 1 100%', sm: '1 1 110px' }, minWidth: { sm: 100 } }}
/>
<TextField
size="small"
label="Lieferant"
value={partyName || '–'}
disabled
sx={{ flex: { xs: '1 1 100%', sm: '2 1 180px' }, minWidth: { sm: 140 } }}
/>
<TextField
size="small"
label="Nummer"
value={delivery?.number || '–'}
disabled
sx={{ flex: { xs: '1 1 100%', sm: '1 1 110px' }, minWidth: { sm: 100 } }}
/>
</Box>
{delivery?.externalNumber && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Referenz
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{delivery.externalNumber}
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lieferdatum
</Typography>
<Typography variant="body2">
{formatDate(delivery?.date)}
</Typography>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<TextField
size="small"
label="Referenz"
value={transactionText}
onChange={this.handleTransactionTextChange}
disabled={isBooking || isFullyBooked}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 200px' } }}
/>
<FormControl
size="small"
required={hasTrackedLinesToBook}
error={Boolean(warehouseError) || isWarehouseMissing}
disabled={isBooking || isFullyBooked}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 200px' } }}
>
<InputLabel id="warehouse-select-label">
Ziellager{hasTrackedLinesToBook ? ' *' : ''}
</InputLabel>
<Select
labelId="warehouse-select-label"
id="warehouse-select"
value={selectedWarehouseId ?? ''}
label={`Ziellager${hasTrackedLinesToBook ? ' *' : ''}`}
onChange={this.handleWarehouseChange}
startAdornment={
<WarehouseIcon
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
/>
}
>
{warehouses.length === 0 ? (
<MenuItem value="" disabled>
Keine Lager vorhanden
</MenuItem>
) : (
warehouses.map((wh) => (
<MenuItem key={wh.id} value={wh.id}>
{wh.name}
</MenuItem>
))
)}
</Select>
{warehouseError && (
<FormHelperText>{warehouseError}</FormHelperText>
)}
</FormControl>
</Box>
{partyName && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lieferant
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{partyName}
</Typography>
</Box>
)}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
Aktueller Status:
</Typography>
{summary?.status === 'booked' && (
<Chip
size="small"
icon={<CheckCircleOutlineOutlinedIcon />}
label="Vollständig gebucht"
color="success"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)}
{summary?.status === 'partial' && (
<Chip
size="small"
icon={<Inventory2OutlinedIcon />}
label={`Teilweise gebucht (${summary.bookedPositions}/${summary.totalPositions})`}
color="warning"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)}
{summary?.status === 'unbooked' && (
<Chip
size="small"
label={`Offen (${summary.totalPositions} Pos.)`}
variant="outlined"
sx={{ color: 'text.secondary', borderColor: 'divider' }}
/>
)}
{summary?.status === 'empty' && (
<Chip size="small" label="Keine Positionen" variant="outlined" />
)}
</Box>
</Paper>
</Box>
);
}
renderLinesTable() {
const { summary, amountsToBook, isBooking } = this.state;
const { summary, amountsToBook, isBooking, supplierArticlesMap } = this.state;
const lines = summary?.lines || [];
if (lines.length === 0) {
@@ -398,7 +488,10 @@ export default class BookDeliveryDialog extends PureComponent {
<Table size="small">
<TableHead sx={{ backgroundColor: SURFACE_RAISED }}>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600, width: 44 }} align="center">Pos.</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Lieferanten-SKU</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Eigene SKU</TableCell>
<TableCell sx={{ fontWeight: 600 }}>EAN</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Liefermenge</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Bereits gebucht</TableCell>
@@ -406,29 +499,45 @@ export default class BookDeliveryDialog extends PureComponent {
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
{lines.map((line, index) => {
const toBook = amountsToBook[line.id] ?? 0;
const isAlreadyFullyBooked = line.isBooked && line.remainingAmount <= 0;
const posNumber = line.pos ?? line.position ?? (index + 1);
const { supplierSku, ownSku } = resolveLineSkus(
line,
this.props.delivery,
supplierArticlesMap
);
const cachedArticle =
line.article ||
(ownSku && ownSku !== '–' ? getArticle(ownSku) : null) ||
(line.sku ? getArticle(line.sku) : null);
const eans = line.ean
? [line.ean]
: line.eans || cachedArticle?.eans || cachedArticle?.articleean || [];
const primaryEan = Array.isArray(eans) ? (eans[0] ?? '') : (eans ?? '');
return (
<TableRow key={line.id} hover>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{line.sku || '–'}
<TableCell align="center" sx={{ color: 'text.secondary', fontSize: '0.85rem' }}>
{posNumber}
</TableCell>
<TableCell sx={{ maxWidth: 280 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{line.name || '–'}
</Typography>
{!line.trackStock && (
<Chip
size="small"
label="Ohne Lagerbestand"
variant="outlined"
sx={{ fontSize: '0.7rem', height: 20, color: 'text.secondary', borderColor: 'divider' }}
/>
)}
</Box>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{supplierSku}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{ownSku}
</TableCell>
<TableCell
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
title={Array.isArray(eans) && eans.length > 1 ? eans.join(', ') : undefined}
>
{primaryEan || '–'}
</TableCell>
<TableCell sx={{ maxWidth: 260 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{line.name || '–'}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 600 }}>
@@ -524,7 +633,7 @@ export default class BookDeliveryDialog extends PureComponent {
<AppDialog
open={Boolean(open)}
onClose={isBooking ? undefined : onClose}
maxWidth="md"
maxWidth="lg"
fullWidth
>
<DialogTitle
@@ -538,7 +647,7 @@ export default class BookDeliveryDialog extends PureComponent {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Inventory2OutlinedIcon color="primary" />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
Lieferung {delivery?.number || ''} verbuchen
Lieferung verbuchen
</Typography>
</Box>
<IconButton size="small" onClick={onClose} disabled={isBooking}>
@@ -546,8 +655,8 @@ export default class BookDeliveryDialog extends PureComponent {
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ pt: 2 }}>
{this.renderHeaderInfo()}
<DialogContent dividers sx={{ pt: 2, borderBottom: 'none' }}>
{this.renderHeaderInfo({ hasTrackedLinesToBook, isWarehouseMissing })}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6, gap: 2, alignItems: 'center' }}>
@@ -568,125 +677,21 @@ export default class BookDeliveryDialog extends PureComponent {
)}
{this.renderLinesTable()}
{!isFullyBooked && hasLines && (
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
size="small"
variant="text"
onClick={this.handleBookAll}
disabled={isBooking}
>
Alle offenen Mengen übernehmen
</Button>
<Button
size="small"
variant="text"
color="inherit"
onClick={this.handleResetAmounts}
disabled={isBooking}
>
Zurücksetzen
</Button>
</Box>
)}
{hasTrackedLinesToBook && warehouses.length === 0 && !isFullyBooked && (
<Alert severity="error" sx={{ mb: 2 }}>
Es ist noch kein Lager eingerichtet. Für lagergeführte Artikel legen Sie bitte unter „Artikel → Lager“ zuerst ein Lager an.
</Alert>
)}
{hasLines && (
<Box
sx={{
display: 'flex',
gap: 2,
mb: 2,
flexDirection: { xs: 'column', sm: 'row' },
}}
>
{hasTrackStock && (
<FormControl
size="small"
required={hasTrackedLinesToBook}
error={Boolean(warehouseError)}
disabled={isBooking || isFullyBooked}
sx={{ minWidth: { sm: 240 }, flex: { sm: '0 0 auto' } }}
>
<InputLabel id="warehouse-select-label">
Ziellager {hasTrackedLinesToBook ? '*' : ''}
</InputLabel>
<Select
labelId="warehouse-select-label"
id="warehouse-select"
value={selectedWarehouseId ?? ''}
label={`Ziellager ${hasTrackedLinesToBook ? '*' : ''}`}
onChange={this.handleWarehouseChange}
startAdornment={
<WarehouseIcon
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
/>
}
>
{warehouses.length === 0 ? (
<MenuItem value="" disabled>
Keine Lager vorhanden
</MenuItem>
) : (
warehouses.map((wh) => (
<MenuItem key={wh.id} value={wh.id}>
{wh.name}
</MenuItem>
))
)}
</Select>
{warehouseError ? (
<FormHelperText>{warehouseError}</FormHelperText>
) : (
<FormHelperText>
{hasTrackedLinesToBook
? 'Pflichtfeld: Ziellager für lagergeführte Artikel'
: 'Ziellager (optional)'}
</FormHelperText>
)}
</FormControl>
)}
<TextField
fullWidth
size="small"
label="Buchungstext (Ledger-Referenz)"
value={transactionText}
onChange={this.handleTransactionTextChange}
disabled={isBooking || isFullyBooked}
helperText="Wird als Transaktionstext in das Ledger übernommen"
/>
</Box>
)}
{isFullyBooked ? (
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
Alle Positionen dieser Lieferung sind bereits vollständig im Ledger verbucht.
</Alert>
) : countLinesToBook > 0 ? (
<Alert severity={isWarehouseMissing ? 'warning' : 'info'}>
{isWarehouseMissing ? (
<>
Bitte wählen Sie ein <strong>Ziellager</strong> aus, um die lagergeführten Artikel zu buchen.
</>
) : (
<>
Es werden <strong>{totalToBook} Stück</strong> für{' '}
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong>{' '}
{hasTrackedLinesToBook && selectedWarehouse ? (
<>in das Lager <strong>„{selectedWarehouse?.name || '–'}“</strong> verbucht.</>
) : (
<>als Dokumentenbuchung im Ledger verbucht (ohne Lagerbestandsführung).</>
)}
</>
)}
) : isWarehouseMissing ? (
<Alert severity="warning">
Bitte wählen Sie ein <strong>Ziellager</strong> aus, um die lagergeführten Artikel zu buchen.
</Alert>
) : hasLines ? (
) : countLinesToBook === 0 && hasLines ? (
<Alert severity="warning">
Wählen Sie mindestens eine Menge größer als 0 aus, um Positionen zu verbuchen.
</Alert>