This commit is contained in:
seb
2026-10-21 03:22:35 +02:00
parent b09100b13e
commit fbf107843f
9 changed files with 448 additions and 172 deletions

View File

@@ -29,17 +29,39 @@ import { formatStock } from '../article/searchFormat';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import { todayInputValue } from './manifestConfig';
// MARK: helper to sum already shipped quantities per SKU across order shipments
function computeShippedBySku(shipments) {
const map = {};
// MARK: helper to sum already shipped quantities per SKU, orderLineId, and line name across shipments
export function computeShippedSummary(shipments) {
const bySku = {};
const byOrderLineId = {};
const byName = {};
for (const sh of shipments || []) {
for (const sl of sh.lines || []) {
const amt = Number(sl.amount) || 0;
if (sl.sku) {
map[sl.sku] = (map[sl.sku] || 0) + (Number(sl.amount) || 0);
const k = String(sl.sku).trim();
bySku[k] = (bySku[k] || 0) + amt;
}
if (sl.orderLineId != null) {
byOrderLineId[sl.orderLineId] = (byOrderLineId[sl.orderLineId] || 0) + amt;
}
if (sl.name) {
const n = String(sl.name).trim().toLowerCase();
byName[n] = (byName[n] || 0) + amt;
}
}
}
return map;
return { bySku, byOrderLineId, byName };
}
function getItemKey(line, index) {
if (line.sku && String(line.sku).trim()) {
return `sku:${String(line.sku).trim()}`;
}
if (line.id != null) {
return `id:${line.id}`;
}
return `idx:${index}`;
}
// MARK: helper to determine next shipment number preview
@@ -78,7 +100,8 @@ export default class OrderShipmentDialog extends PureComponent {
};
this.handleAmountChange = this.handleAmountChange.bind(this);
this.handleToggleSku = this.handleToggleSku.bind(this);
this.handleToggleItem = this.handleToggleItem.bind(this);
this.handleToggleSku = this.handleToggleItem; // Alias for backward compatibility
this.handleToggleAll = this.handleToggleAll.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleShipAll = this.handleShipAll.bind(this);
@@ -128,35 +151,146 @@ export default class OrderShipmentDialog extends PureComponent {
}
calculateInitialState(props) {
const shippedBySku = computeShippedBySku(props.shipments);
const { bySku, byOrderLineId, byName } = computeShippedSummary(props.shipments);
const remainingShippedByName = { ...byName };
const initialAmounts = {};
const initialSelected = new Set();
for (const l of props.lines || []) {
if (!l.sku) continue;
(props.lines || []).forEach((l, idx) => {
const isShippable = l.shippable !== false;
const key = getItemKey(l, idx);
const ordered = Number(l.amount) || 0;
const shipped = shippedBySku[l.sku] || 0;
const rem = Math.max(0, ordered - shipped);
if (rem > 0) {
initialAmounts[l.sku] = String(rem);
initialSelected.add(l.sku);
} else {
initialAmounts[l.sku] = '0';
const sku = l.sku ? String(l.sku).trim() : null;
const orderLineId = l.id != null ? l.id : null;
let shipped = 0;
if (sku) {
shipped = bySku[sku] || 0;
} else if (orderLineId != null && byOrderLineId[orderLineId] != null) {
shipped = byOrderLineId[orderLineId];
} else if (l.name) {
const n = String(l.name).trim().toLowerCase();
const pool = remainingShippedByName[n] || 0;
const consumed = Math.min(ordered, pool);
shipped = consumed;
remainingShippedByName[n] = pool - consumed;
}
}
const rem = isShippable ? Math.max(0, ordered - shipped) : 0;
if (isShippable && rem > 0) {
initialAmounts[key] = String(rem);
initialSelected.add(key);
} else {
initialAmounts[key] = '0';
}
});
return { initialAmounts, initialSelected };
}
handleToggleSku(sku, remaining) {
getItems() {
const { lines = [], shipments = [], orderId } = this.props;
const { lineAmounts, selectedSkus } = this.state;
const currentOrderId = orderId != null ? Number(orderId) : null;
const { bySku, byOrderLineId, byName } = computeShippedSummary(shipments);
const remainingShippedByName = { ...byName };
return (lines || []).map((line, idx) => {
const itemKey = getItemKey(line, idx);
const isShippable = line.shippable !== false;
const ordered = Number(line.amount) || 0;
const sku = line.sku ? String(line.sku).trim() : null;
const orderLineId = line.id != null ? line.id : null;
let shipped = 0;
if (sku) {
shipped = bySku[sku] || 0;
} else if (orderLineId != null && byOrderLineId[orderLineId] != null) {
shipped = byOrderLineId[orderLineId];
} else if (line.name) {
const n = String(line.name).trim().toLowerCase();
const pool = remainingShippedByName[n] || 0;
const consumed = Math.min(ordered, pool);
shipped = consumed;
remainingShippedByName[n] = pool - consumed;
}
const remaining = isShippable ? Math.max(0, ordered - shipped) : 0;
const isSelected = selectedSkus.has(itemKey);
const shipAmount = lineAmounts[itemKey] ?? (remaining > 0 ? String(remaining) : '0');
let cached = null;
let isTracked = null;
let stockVal = null;
let availableVal = null;
let reservedTitle = undefined;
let stockTitle = undefined;
if (sku) {
cached = getArticle(sku);
const rawWarehouses = Array.isArray(cached?.warehouses) ? cached.warehouses : [];
isTracked = cached ? (cached.track_stock ?? cached.trackStock ?? (rawWarehouses.length > 0)) : null;
stockVal = cached == null || isTracked === false
? null
: (cached.stock != null && !Number.isNaN(Number(cached.stock))
? Number(cached.stock)
: (rawWarehouses.length > 0 || isTracked
? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: null));
const undeliveredOrders = Array.isArray(cached?.undeliveredOrders) ? cached.undeliveredOrders : [];
const otherOrders = currentOrderId != null
? undeliveredOrders.filter((ord) => Number(ord.orderId) !== currentOrderId)
: undeliveredOrders;
const reservedInOtherOrders = otherOrders.reduce((sum, ord) => sum + (Number(ord.amount) || 0), 0);
availableVal = cached == null || isTracked === false
? null
: (stockVal != null ? stockVal - reservedInOtherOrders : null);
reservedTitle = isTracked !== false && reservedInOtherOrders > 0
? `Bereits in anderen Aufträgen reserviert:\n${otherOrders
.map((ord) => `• ${ord.orderNumber || (ord.orderId ? `Auftrag #${ord.orderId}` : 'Auftrag')}: ${formatStock(ord.amount)}${ord.customerName ? ` (${ord.customerName})` : ''}`)
.join('\n')}`
: undefined;
stockTitle = isTracked !== false && rawWarehouses.length > 1
? `Lagerbestände:\n${rawWarehouses
.map((wh) => `• ${wh.name || 'Lager'}: ${formatStock(wh.stock)}`)
.join('\n')}`
: undefined;
}
return {
...line,
itemKey,
sku,
orderLineId,
isShippable,
ordered,
shipped,
remaining,
isSelected,
shipAmount,
cached,
isTracked,
stockVal,
availableVal,
reservedTitle,
stockTitle,
};
});
}
handleToggleItem(itemKey, remaining) {
this.setState((prev) => {
const nextSelected = new Set(prev.selectedSkus);
const nextAmounts = { ...prev.lineAmounts };
if (nextSelected.has(sku)) {
nextSelected.delete(sku);
if (nextSelected.has(itemKey)) {
nextSelected.delete(itemKey);
} else {
nextSelected.add(sku);
if (!Number(nextAmounts[sku]) || Number(nextAmounts[sku]) <= 0) {
nextAmounts[sku] = String(remaining);
nextSelected.add(itemKey);
if (!Number(nextAmounts[itemKey]) || Number(nextAmounts[itemKey]) <= 0) {
nextAmounts[itemKey] = String(remaining);
}
}
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
@@ -165,15 +299,15 @@ export default class OrderShipmentDialog extends PureComponent {
handleToggleAll(openItems) {
this.setState((prev) => {
const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.sku));
const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.itemKey));
const nextSelected = new Set();
const nextAmounts = { ...prev.lineAmounts };
if (!allSelected) {
for (const it of openItems) {
nextSelected.add(it.sku);
if (!Number(nextAmounts[it.sku]) || Number(nextAmounts[it.sku]) <= 0) {
nextAmounts[it.sku] = String(it.remaining);
nextSelected.add(it.itemKey);
if (!Number(nextAmounts[it.itemKey]) || Number(nextAmounts[it.itemKey]) <= 0) {
nextAmounts[it.itemKey] = String(it.remaining);
}
}
}
@@ -181,15 +315,15 @@ export default class OrderShipmentDialog extends PureComponent {
});
}
handleAmountChange(sku, val) {
handleAmountChange(itemKey, val) {
this.setState((prev) => {
const nextAmounts = { ...prev.lineAmounts, [sku]: val };
const nextAmounts = { ...prev.lineAmounts, [itemKey]: val };
const nextSelected = new Set(prev.selectedSkus);
const num = Number(val);
if (num > 0) {
nextSelected.add(sku);
nextSelected.add(itemKey);
} else {
nextSelected.delete(sku);
nextSelected.delete(itemKey);
}
return { lineAmounts: nextAmounts, selectedSkus: nextSelected };
});
@@ -203,11 +337,17 @@ export default class OrderShipmentDialog extends PureComponent {
const { date, externalNumber, lineAmounts, selectedSkus, saving } = this.state;
if (saving || !orderId) return;
const items = this.getItems();
const linesToShip = [];
for (const sku of selectedSkus) {
const amount = Number(lineAmounts[sku]);
for (const it of items) {
if (!it.isShippable || !selectedSkus.has(it.itemKey)) continue;
const amount = Number(lineAmounts[it.itemKey]);
if (amount > 0) {
linesToShip.push({ sku, amount });
if (it.sku) {
linesToShip.push({ sku: it.sku, amount });
} else if (it.orderLineId != null) {
linesToShip.push({ orderLineId: it.orderLineId, amount });
}
}
}
@@ -240,9 +380,7 @@ export default class OrderShipmentDialog extends PureComponent {
this.setState({ saving: false, error: err?.message || String(err) });
}
}
// #endregion
// MARK: calls orderApi.createShipment to ship all remaining lines
// // MARK: calls orderApi.createShipment to ship all remaining lines
// #region POST /api/order/:id/shipment — ship all remaining
async handleShipAll() {
const { orderId, onClose, onSuccess } = this.props;
@@ -275,73 +413,14 @@ export default class OrderShipmentDialog extends PureComponent {
// #endregion
render() {
const { open, onClose, orderId, orderNumber, lines = [], shipments = [] } = this.props;
const { date, externalNumber, lineAmounts, selectedSkus, saving, error } = this.state;
const nextShipmentNumber = getNextShipmentNumber(orderNumber, shipments);
const { open, onClose, orderNumber } = this.props;
const { date, externalNumber, saving, error } = this.state;
const nextShipmentNumber = getNextShipmentNumber(orderNumber, this.props.shipments);
const shippedBySku = computeShippedBySku(shipments);
const activeLines = lines.filter((l) => Boolean(l.sku));
const currentOrderId = orderId != null ? Number(orderId) : null;
const items = activeLines.map((line) => {
const ordered = Number(line.amount) || 0;
const shipped = shippedBySku[line.sku] || 0;
const remaining = Math.max(0, ordered - shipped);
const isSelected = selectedSkus.has(line.sku);
const shipAmount = lineAmounts[line.sku] ?? (remaining > 0 ? String(remaining) : '0');
// Stock & reservation details from artikelAutomat
const cached = getArticle(line.sku);
const rawWarehouses = Array.isArray(cached?.warehouses) ? cached.warehouses : [];
const isTracked = cached ? (cached.track_stock ?? cached.trackStock ?? (rawWarehouses.length > 0)) : null;
const stockVal = cached == null || isTracked === false
? null
: (cached.stock != null && !Number.isNaN(Number(cached.stock))
? Number(cached.stock)
: (rawWarehouses.length > 0 || isTracked
? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: null));
const undeliveredOrders = Array.isArray(cached?.undeliveredOrders) ? cached.undeliveredOrders : [];
const otherOrders = currentOrderId != null
? undeliveredOrders.filter((ord) => Number(ord.orderId) !== currentOrderId)
: undeliveredOrders;
const reservedInOtherOrders = otherOrders.reduce((sum, ord) => sum + (Number(ord.amount) || 0), 0);
const availableVal = cached == null || isTracked === false
? null
: (stockVal != null ? stockVal - reservedInOtherOrders : null);
const reservedTitle = isTracked !== false && reservedInOtherOrders > 0
? `Bereits in anderen Aufträgen reserviert:\n${otherOrders
.map((ord) => `• ${ord.orderNumber || (ord.orderId ? `Auftrag #${ord.orderId}` : 'Auftrag')}: ${formatStock(ord.amount)}${ord.customerName ? ` (${ord.customerName})` : ''}`)
.join('\n')}`
: undefined;
const stockTitle = isTracked !== false && rawWarehouses.length > 1
? `Lagerbestände:\n${rawWarehouses
.map((wh) => `• ${wh.name || 'Lager'}: ${formatStock(wh.stock)}`)
.join('\n')}`
: undefined;
return {
...line,
ordered,
shipped,
remaining,
isSelected,
shipAmount,
cached,
isTracked,
stockVal,
availableVal,
reservedInOtherOrders,
reservedTitle,
stockTitle,
};
});
const openItems = items.filter((it) => it.remaining > 0);
const allShipped = activeLines.length > 0 && openItems.length === 0;
const items = this.getItems();
const shippableItems = items.filter((it) => it.isShippable);
const openItems = shippableItems.filter((it) => it.remaining > 0);
const allShipped = shippableItems.length > 0 && openItems.length === 0;
const isAllSelected = openItems.length > 0 && openItems.every((it) => it.isSelected);
const selectedCount = openItems.filter((it) => it.isSelected).length;
@@ -451,10 +530,11 @@ export default class OrderShipmentDialog extends PureComponent {
</TableRow>
) : (
items.map((item) => {
const isClosed = item.remaining <= 0;
const isNonShippable = !item.isShippable;
const isClosed = isNonShippable || item.remaining <= 0;
return (
<TableRow
key={item.sku}
key={item.itemKey}
hover={!isClosed}
sx={{
opacity: isClosed ? 0.55 : 1,
@@ -466,17 +546,17 @@ export default class OrderShipmentDialog extends PureComponent {
size="small"
checked={item.isSelected && !isClosed}
disabled={saving || isClosed}
onChange={() => this.handleToggleSku(item.sku, item.remaining)}
onChange={() => this.handleToggleItem(item.itemKey, item.remaining)}
/>
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{item.sku}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{item.sku || '–'}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.name || '–'}
</Typography>
</TableCell>
<TableCell align="right">{item.ordered}</TableCell>
<TableCell align="right">{item.shipped}</TableCell>
<TableCell align="right">{isNonShippable ? '–' : item.shipped}</TableCell>
<TableCell
align="right"
sx={{
@@ -484,7 +564,7 @@ export default class OrderShipmentDialog extends PureComponent {
color: item.remaining > 0 ? 'primary.main' : 'text.disabled',
}}
>
{item.remaining}
{isNonShippable ? '–' : item.remaining}
</TableCell>
<TableCell
align="right"
@@ -492,14 +572,14 @@ export default class OrderShipmentDialog extends PureComponent {
sx={{
fontWeight: 500,
color:
!item.cached || item.stockVal == null
!item.sku || !item.cached || item.stockVal == null
? 'text.disabled'
: item.stockVal <= 0
? 'error.main'
: 'text.secondary',
}}
>
{!item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.stockVal)}
{!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.stockVal)}
</TableCell>
<TableCell
align="right"
@@ -507,7 +587,7 @@ export default class OrderShipmentDialog extends PureComponent {
sx={{
fontWeight: 600,
color:
!item.cached || item.availableVal == null
!item.sku || !item.cached || item.availableVal == null
? 'text.disabled'
: item.availableVal <= 0
? 'error.main'
@@ -516,10 +596,12 @@ export default class OrderShipmentDialog extends PureComponent {
: 'text.secondary',
}}
>
{!item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.availableVal)}
{!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.availableVal)}
</TableCell>
<TableCell align="right">
{isClosed ? (
{isNonShippable ? (
<Chip size="small" label="Nicht lieferbar" variant="outlined" sx={{ height: 22, fontSize: '0.7rem' }} />
) : isClosed ? (
<Chip size="small" label="Geliefert" color="success" variant="outlined" sx={{ height: 22, fontSize: '0.7rem' }} />
) : (
<TextField
@@ -527,7 +609,7 @@ export default class OrderShipmentDialog extends PureComponent {
type="number"
disabled={saving || !item.isSelected}
value={item.shipAmount}
onChange={(e) => this.handleAmountChange(item.sku, e.target.value)}
onChange={(e) => this.handleAmountChange(item.itemKey, e.target.value)}
slotProps={{
htmlInput: {
min: 0,