This commit is contained in:
seb
2026-10-20 02:56:31 +02:00
parent 067207c58e
commit b727e00928
3 changed files with 204 additions and 185 deletions

View File

@@ -8,7 +8,6 @@ import IconButton from '@mui/material/IconButton';
import TableCell from '@mui/material/TableCell'; import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
@@ -608,9 +607,6 @@ class ManifestFormLine extends PureComponent {
: (rawWarehouses.length > 0 || isTracked : (rawWarehouses.length > 0 || isTracked
? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0) ? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: null)); : null));
const warehouseTooltip = rawWarehouses.length > 0
? rawWarehouses.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock)}`).join(' · ')
: (isTracked === false ? 'Keine Bestandsführung' : undefined);
const lineAmount = parseNum(getFirstVal(this.props.line?.amount, this.props.line?.menge)) ?? 0; const lineAmount = parseNum(getFirstVal(this.props.line?.amount, this.props.line?.menge)) ?? 0;
return ( return (
@@ -730,93 +726,65 @@ class ManifestFormLine extends PureComponent {
}} }}
> >
{/* Bestand */} {/* Bestand */}
<Tooltip <Typography
title={ variant="caption"
warehouseTooltip fontWeight={600}
? `Lagerbestand: ${formatStock(stockVal)} (${warehouseTooltip})` sx={{
: isTracked === false color:
? 'Keine Bestandsführung (unbegrenzt verfügbar)' stockVal == null
: `Verfügbarer Lagerbestand: ${formatStock(stockVal)}` ? 'text.disabled'
} : stockVal <= 0
? 'error.main'
: lineAmount > stockVal
? 'warning.main'
: 'text.secondary',
fontSize: '0.72rem',
}}
> >
<Typography Bestand: {isTracked === false ? '∞' : formatStock(stockVal)}
variant="caption" </Typography>
fontWeight={600}
sx={{
color:
stockVal == null
? 'text.disabled'
: stockVal <= 0
? 'error.main'
: lineAmount > stockVal
? 'warning.main'
: 'text.secondary',
fontSize: '0.72rem',
}}
>
Bestand: {isTracked === false ? '∞' : formatStock(stockVal)}
</Typography>
</Tooltip>
{/* Marge */} {/* Marge */}
<Tooltip
title={ <Typography
marge != null variant="caption"
? `Marge: ${marge.toFixed(1).replace('.', ',')}% (EK Netto ${formatPrice(ekNetto)} → VK Netto ${formatPrice(vkNetto)})${rebate > 0 && effectiveMarge != null ? ` · Nach ${rebate}% Rabatt: ${effectiveMarge.toFixed(1).replace('.', ',')}%` : ''}` fontWeight={600}
: 'Kein EK- oder VK-Preis hinterlegt' sx={{
} color: marge != null ? (marge < 0 ? 'error.main' : 'text.secondary') : 'text.disabled',
fontSize: '0.72rem',
}}
> >
<Typography Marge: {marge != null ? `${Math.floor(marge)}%` : '—'}
variant="caption" </Typography>
fontWeight={600}
sx={{
color: marge != null ? (marge < 0 ? 'error.main' : 'text.secondary') : 'text.disabled',
fontSize: '0.72rem',
}}
>
Marge: {marge != null ? `${Math.floor(marge)}%` : '—'}
</Typography>
</Tooltip>
{/* EK Netto */} {/* EK Netto */}
<Tooltip
title={ <Typography
topSupplier?.supplierName variant="caption"
? `Lieferant: ${topSupplier.supplierName}${topSupplier.sku ? ` (Lief.-Art.-Nr.: ${topSupplier.sku})` : ''} (Sort: ${topSupplier.sort ?? 0})` fontWeight={600}
: 'Kein Lieferant mit EK-Preis hinterlegt' sx={{
} color: ekNetto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
> >
<Typography EK Netto: {ekNetto != null ? formatPrice(ekNetto) : '—'}
variant="caption" </Typography>
fontWeight={600}
sx={{
color: ekNetto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
EK Netto: {ekNetto != null ? formatPrice(ekNetto) : '—'}
</Typography>
</Tooltip>
{/* EK Brutto */} {/* EK Brutto */}
<Tooltip
title={ <Typography
topSupplier?.supplierName variant="caption"
? `Lieferant: ${topSupplier.supplierName} (inkl. ${ekTaxRate}% MwSt.)` fontWeight={600}
: 'Kein Lieferant mit EK-Preis hinterlegt' sx={{
} color: ekBrutto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
> >
<Typography EK Brutto: {ekBrutto != null ? formatPrice(ekBrutto) : '—'}
variant="caption" </Typography>
fontWeight={600}
sx={{
color: ekBrutto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
EK Brutto: {ekBrutto != null ? formatPrice(ekBrutto) : '—'}
</Typography>
</Tooltip>
{/* VK Netto */} {/* VK Netto */}
<Typography <Typography
@@ -964,9 +932,6 @@ class ManifestFormLine extends PureComponent {
: (rawWarehouses.length > 0 || isDeliveryTracked : (rawWarehouses.length > 0 || isDeliveryTracked
? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0) ? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: null)); : null));
const deliveryWarehouseTooltip = rawWarehouses.length > 0
? rawWarehouses.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock)}`).join(' · ')
: (isDeliveryTracked === false ? 'Keine Bestandsführung' : undefined);
return ( return (
<Box <Box
@@ -980,26 +945,18 @@ class ManifestFormLine extends PureComponent {
> >
{/* Unlink button at the front */} {/* Unlink button at the front */}
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && ( {this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
<Tooltip <span>
title={ <IconButton
this.isFullyLedgered() size="small"
? 'Position ist bereits vollständig im Ledger verbucht und Verknüpfung kann nicht aufgehoben werden' color="error"
: 'Lieferanten-Verknüpfung aufheben' aria-label="Lieferanten-Verknüpfung entfernen"
} disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
> onClick={this.handleRemoveSupplierArticle}
<span> sx={{ p: 0.25, mr: -0.25 }}
<IconButton >
size="small" <LinkOffOutlinedIcon sx={{ fontSize: 16 }} />
color="error" </IconButton>
aria-label="Lieferanten-Verknüpfung entfernen" </span>
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
onClick={this.handleRemoveSupplierArticle}
sx={{ p: 0.25, mr: -0.25 }}
>
<LinkOffOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
)} )}
{/* Article Photo Thumbnail */} {/* Article Photo Thumbnail */}
@@ -1109,48 +1066,34 @@ class ManifestFormLine extends PureComponent {
}} }}
> >
{/* Bestand */} {/* Bestand */}
<Tooltip <Typography
title={ variant="caption"
deliveryWarehouseTooltip fontWeight={600}
? `Lagerbestand: ${formatStock(deliveryStockVal)} (${deliveryWarehouseTooltip})` sx={{
: isDeliveryTracked === false color:
? 'Keine Bestandsführung (unbegrenzt verfügbar)' deliveryStockVal == null
: `Aktueller Lagerbestand: ${formatStock(deliveryStockVal)}` ? 'text.disabled'
} : deliveryStockVal <= 0
? 'error.main'
: 'text.secondary',
fontSize: '0.72rem',
}}
> >
Bestand: {isDeliveryTracked === false ? '∞' : formatStock(deliveryStockVal)}
</Typography>
{/* Marge */}
{margeDelivery != null && (
<Typography <Typography
variant="caption" variant="caption"
fontWeight={600} fontWeight={600}
sx={{ sx={{
color: color: margeDelivery < 0 ? 'error.main' : 'text.secondary',
deliveryStockVal == null
? 'text.disabled'
: deliveryStockVal <= 0
? 'error.main'
: 'text.secondary',
fontSize: '0.72rem', fontSize: '0.72rem',
}} }}
> >
Bestand: {isDeliveryTracked === false ? '∞' : formatStock(deliveryStockVal)} Marge: {Math.floor(margeDelivery)}%
</Typography> </Typography>
</Tooltip>
{/* Marge */}
{margeDelivery != null && (
<Tooltip
title={`Marge: ${margeDelivery.toFixed(1).replace('.', ',')}% (EK Netto ${formatPrice(priceNet)} → VK Netto ${formatPrice(vkNetto)})`}
>
<Typography
variant="caption"
fontWeight={600}
sx={{
color: margeDelivery < 0 ? 'error.main' : 'text.secondary',
fontSize: '0.72rem',
}}
>
Marge: {Math.floor(margeDelivery)}%
</Typography>
</Tooltip>
)} )}
{priceNet != null && ( {priceNet != null && (
@@ -1363,13 +1306,6 @@ class ManifestFormLine extends PureComponent {
aria-label="Zeile entfernen" aria-label="Zeile entfernen"
disabled={saving || Boolean(this.props.isOnShipment) || isFullyLedgered} disabled={saving || Boolean(this.props.isOnShipment) || isFullyLedgered}
onClick={this.handleRemove} onClick={this.handleRemove}
title={
isFullyLedgered
? 'Position ist bereits vollständig im Ledger verbucht und kann nicht entfernt werden'
: this.props.isOnShipment
? 'Artikel ist bereits auf einem Lieferschein und kann nicht entfernt werden'
: 'Zeile entfernen'
}
sx={{ p: 0.35 }} sx={{ p: 0.35 }}
> >
<DeleteOutlinedIcon sx={{ fontSize: 18 }} /> <DeleteOutlinedIcon sx={{ fontSize: 18 }} />

View File

@@ -27,6 +27,9 @@ import entityAutomat from '../../automats/entityAutomat';
import customergroupAutomat from '../../automats/customergroupAutomat'; import customergroupAutomat from '../../automats/customergroupAutomat';
import supplierAutomat from '../../automats/supplierAutomat'; import supplierAutomat from '../../automats/supplierAutomat';
import numberAutomat from '../../automats/numberAutomat'; import numberAutomat from '../../automats/numberAutomat';
import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat';
import { getNextDocumentNumber, ensureDocumentNumber } from '../../utils/documentNumber'; import { getNextDocumentNumber, ensureDocumentNumber } from '../../utils/documentNumber';
import { import {
SURFACE_RAISED, SURFACE_RAISED,
@@ -233,11 +236,30 @@ class ManifestHeader extends PureComponent {
this.props.onPdfUpload?.(file); this.props.onPdfUpload?.(file);
} }
subscribeListAutomat(kind) {
this.unsubList?.();
this.unsubList = null;
const automat =
kind === 'quote'
? quoteAutomat
: kind === 'order'
? orderAutomat
: kind === 'delivery'
? deliveryAutomat
: null;
if (automat) {
this.unsubList = automat.subscribe(this, () => {
if (!this._cancelled) this.forceUpdate();
});
}
}
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsubNumber = numberAutomat.subscribe(this, () => { this.unsubNumber = numberAutomat.subscribe(this, () => {
this.forceUpdate(); this.forceUpdate();
}); });
this.subscribeListAutomat(this.props.kind);
void numberAutomat.actions.ensureLoaded().catch(() => {}); void numberAutomat.actions.ensureLoaded().catch(() => {});
if (!this.props.number && this.props.docId == null) { if (!this.props.number && this.props.docId == null) {
void ensureDocumentNumber(this.props.kind) void ensureDocumentNumber(this.props.kind)
@@ -249,6 +271,9 @@ class ManifestHeader extends PureComponent {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.kind !== this.props.kind) {
this.subscribeListAutomat(this.props.kind);
}
if ( if (
!this.props.number && !this.props.number &&
this.props.docId == null && this.props.docId == null &&
@@ -265,6 +290,7 @@ class ManifestHeader extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
this.unsubNumber?.(); this.unsubNumber?.();
this.unsubList?.();
} }
render() { render() {

View File

@@ -2,6 +2,10 @@ import numberAutomat from '../automats/numberAutomat';
import quoteAutomat from '../automats/quoteAutomat'; import quoteAutomat from '../automats/quoteAutomat';
import orderAutomat from '../automats/orderAutomat'; import orderAutomat from '../automats/orderAutomat';
import deliveryAutomat from '../automats/deliveryAutomat'; import deliveryAutomat from '../automats/deliveryAutomat';
import { order as orderApi, quote as quoteApi, delivery as deliveryApi } from '../api';
/** Highest integer counter found per document kind. */
const maxKnownNumberByKind = new Map();
/** /**
* Formats a number pattern given prefix, padding width, and counter. * Formats a number pattern given prefix, padding width, and counter.
@@ -20,6 +24,7 @@ export function formatNumberPattern(prefix, padding, next) {
/** /**
* Returns the next document number for quote, order, or delivery based on company sequences. * Returns the next document number for quote, order, or delivery based on company sequences.
* Always verifies that the returned number does not collide with existing documents.
* *
* @param {'quote'|'order'|'delivery'|string} kind * @param {'quote'|'order'|'delivery'|string} kind
* @returns {string} * @returns {string}
@@ -27,59 +32,63 @@ export function formatNumberPattern(prefix, padding, next) {
export function getNextDocumentNumber(kind) { export function getNextDocumentNumber(kind) {
const numbers = numberAutomat.state.items ?? []; const numbers = numberAutomat.state.items ?? [];
const seq = numbers.find((r) => r.type === kind); const seq = numbers.find((r) => r.type === kind);
if (seq) {
return formatNumberPattern(seq.prefix, seq.padding, seq.next);
}
// Detect style from existing company sequences (sku, delivery, entity)
const skuSeq = numbers.find((r) => r.type === 'sku');
const deliverySeq = numbers.find((r) => r.type === 'delivery');
const entitySeq = numbers.find((r) => r.type === 'entity');
const refSeq = skuSeq || deliverySeq || entitySeq;
const pad = refSeq ? Math.max(0, Number(refSeq.padding) || 0) : 6;
const prefixStyle = refSeq?.prefix ?? '';
let prefix = ''; let prefix = '';
let pad = 6;
let startNum = 1; let startNum = 1;
if (kind === 'delivery') { if (seq) {
prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-'); prefix = seq.prefix ?? '';
} else if ( pad = Math.max(0, Number(seq.padding) || 0);
prefixStyle.startsWith('ART-') || startNum = Math.max(1, Number(seq.next) || 1);
prefixStyle.startsWith('LS-') ||
prefixStyle.startsWith('KD-') ||
prefixStyle.endsWith('-')
) {
// Standard German ERP prefix style (ART-, KD-, LS-, KORR-)
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : '');
} else if (
prefixStyle.startsWith('SKU-') ||
prefixStyle.startsWith('DEL-') ||
prefixStyle.startsWith('C-')
) {
prefix = kind === 'quote' ? 'Q-' : (kind === 'order' ? 'ORD-' : '');
} else if (
prefixStyle.startsWith('A-') ||
prefixStyle.startsWith('L-') ||
prefixStyle.startsWith('K-')
) {
prefix = kind === 'quote' ? 'AG-' : (kind === 'order' ? 'AU-' : '');
} else if (!prefixStyle && refSeq?.next >= 10000) {
prefix = '';
startNum = kind === 'quote' ? 40001 : 50001;
} else { } else {
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : ''); // Detect style from existing company sequences (sku, delivery, entity)
const skuSeq = numbers.find((r) => r.type === 'sku');
const deliverySeq = numbers.find((r) => r.type === 'delivery');
const entitySeq = numbers.find((r) => r.type === 'entity');
const refSeq = skuSeq || deliverySeq || entitySeq;
pad = refSeq ? Math.max(0, Number(refSeq.padding) || 0) : 6;
const prefixStyle = refSeq?.prefix ?? '';
if (kind === 'delivery') {
prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-');
} else if (
prefixStyle.startsWith('ART-') ||
prefixStyle.startsWith('LS-') ||
prefixStyle.startsWith('KD-') ||
prefixStyle.endsWith('-')
) {
// Standard German ERP prefix style (ART-, KD-, LS-, KORR-)
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : '');
} else if (
prefixStyle.startsWith('SKU-') ||
prefixStyle.startsWith('DEL-') ||
prefixStyle.startsWith('C-')
) {
prefix = kind === 'quote' ? 'Q-' : (kind === 'order' ? 'ORD-' : '');
} else if (
prefixStyle.startsWith('A-') ||
prefixStyle.startsWith('L-') ||
prefixStyle.startsWith('K-')
) {
prefix = kind === 'quote' ? 'AG-' : (kind === 'order' ? 'AU-' : '');
} else if (!prefixStyle && refSeq?.next >= 10000) {
prefix = '';
startNum = kind === 'quote' ? 40001 : 50001;
} else {
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : '');
}
} }
// Check existing docs from quoteAutomat / orderAutomat / deliveryAutomat to avoid collisions // Check existing docs from quoteAutomat / orderAutomat / deliveryAutomat to avoid collisions
const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : null)); const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : null));
const existingDocs = Array.isArray(listAutomat?.state?.items) ? listAutomat.state.items : []; const existingDocs = Array.isArray(listAutomat?.state?.items) ? listAutomat.state.items : [];
let maxFound = startNum - 1; let maxFound = Math.max(startNum - 1, maxKnownNumberByKind.get(kind) ?? 0);
for (const doc of existingDocs) { for (const doc of existingDocs) {
if (!doc || typeof doc !== 'object') continue; if (!doc || typeof doc !== 'object') continue;
const docNum = String(doc.number ?? '').trim(); const docNum = String(doc.number ?? doc.orderNumber ?? '').trim();
if (!docNum) continue; if (!docNum) continue;
if (prefix && docNum.startsWith(prefix)) { if (prefix && docNum.startsWith(prefix)) {
const rest = docNum.slice(prefix.length); const rest = docNum.slice(prefix.length);
@@ -95,12 +104,15 @@ export function getNextDocumentNumber(kind) {
} }
} }
maxKnownNumberByKind.set(kind, maxFound);
const nextVal = maxFound >= startNum ? maxFound + 1 : startNum; const nextVal = maxFound >= startNum ? maxFound + 1 : startNum;
return formatNumberPattern(prefix, pad, nextVal); return formatNumberPattern(prefix, pad, nextVal);
} }
/** /**
* Asynchronously ensures required stores are loaded, then returns the next document number. * Asynchronously ensures required stores are loaded, queries recent documents to detect
* the highest existing number, then returns the next document number.
* *
* @param {'quote'|'order'|'delivery'|string} kind * @param {'quote'|'order'|'delivery'|string} kind
* @returns {Promise<string>} * @returns {Promise<string>}
@@ -111,6 +123,51 @@ export async function ensureDocumentNumber(kind) {
numberAutomat.actions.ensureLoaded().catch(() => {}), numberAutomat.actions.ensureLoaded().catch(() => {}),
listAutomat?.actions?.ensureLoaded ? listAutomat.actions.ensureLoaded().catch(() => {}) : Promise.resolve(), listAutomat?.actions?.ensureLoaded ? listAutomat.actions.ensureLoaded().catch(() => {}) : Promise.resolve(),
]); ]);
try {
let recentDocs = [];
if (kind === 'order') {
const res = await orderApi.list({ offset: 0, limit: 10 });
recentDocs = Array.isArray(res?.orders) ? res.orders : [];
} else if (kind === 'quote') {
const res = await quoteApi.list({ offset: 0, limit: 10 });
recentDocs = Array.isArray(res?.quotes) ? res.quotes : [];
} else if (kind === 'delivery') {
const res = await deliveryApi.list({ offset: 0, limit: 10 });
recentDocs = Array.isArray(res?.deliveries) ? res.deliveries : [];
}
if (recentDocs.length > 0) {
const numbers = numberAutomat.state.items ?? [];
const seq = numbers.find((r) => r.type === kind);
let prefix = seq?.prefix;
if (prefix == null) {
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : 'LS-');
}
let maxFound = maxKnownNumberByKind.get(kind) ?? 0;
for (const doc of recentDocs) {
const docNum = String(doc.number ?? doc.orderNumber ?? '').trim();
if (!docNum) continue;
if (prefix && docNum.startsWith(prefix)) {
const rest = docNum.slice(prefix.length);
const parsed = parseInt(rest, 10);
if (!Number.isNaN(parsed) && parsed > maxFound) {
maxFound = parsed;
}
} else if (!prefix) {
const parsed = parseInt(docNum, 10);
if (!Number.isNaN(parsed) && parsed > maxFound) {
maxFound = parsed;
}
}
}
maxKnownNumberByKind.set(kind, maxFound);
}
} catch (err) {
console.warn(`[documentNumber] ensureDocumentNumber error fetching latest ${kind}:`, err);
}
return getNextDocumentNumber(kind); return getNextDocumentNumber(kind);
} }