Compare commits

...

3 Commits

Author SHA1 Message Date
seb
35144c7187 u 2026-10-23 23:11:05 +02:00
seb
5dd45916b8 u 2026-10-23 22:40:28 +02:00
seb
b9bd0380cb u 2026-10-23 20:31:34 +02:00
14 changed files with 1539 additions and 494 deletions

View File

@@ -257,8 +257,8 @@ _none_
- Params: `sku: str, body: { eans: str[] }`
- Returns: `204 void`
#### Get article history by SKU — `GET /article/{sku}/history`
- Params: `sku: str, query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }`
- Params: `sku: str, query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { history: { id: int, date: str, text: str }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Add article image — `POST /article/{sku}/image`
- Params: `sku: str, image/avif`
- Returns: `201 { image: { picId: int, sort: int, full: { w: int, h: int, size: int }, thumb: { w: int, h: int }, hash: str, thumbHash: str } }`

577
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,6 @@
"@mui/material": "^9.5.0",
"@shopify/draggable": "^1.2.1",
"@uiw/react-codemirror": "^4.25.12",
"ag-grid-community": "^36.2.0",
"ag-grid-react": "^36.2.0",
"dashgui": "file:../dashgui",
"flexsearch": "^0.8.212",
"jsbarcode": "^3.12.3",
@@ -45,9 +43,8 @@
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"react-dom": "^19.3.1",
"react-router-dom": "^7.18.4",
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
"react-window": "^2.3.4",
"skelehash-wasm": "^0.1.0",
"skelehash-wasm": "^0.1.1",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},

View File

@@ -50,11 +50,19 @@ export const article = {
// #endregion
/**
* GET /api/article/:sku/history
* GET /api/article/:sku/history?offset=0&limit=50&search=...&seekMonths=-1
* @param {string} sku
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number, offset: number, limit: number }
*/
getBySkuHistory(sku, { limit } = {}) {
getBySkuHistory(sku, { offset = 0, limit = 50, search, seekMonths } = {}) {
const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit));
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString();
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
},

View File

@@ -34,26 +34,72 @@ import { requireArray, requireObject } from '../utils/apiShape';
* @property {number|null} taxRate Tax rate in percent.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import authAutomat from './authAutomat';
/**
* Per-SKU article change history (`GET /api/article/{sku}/history`).
*
* The backend returns the full list; pagination happens client-side in
* `ArticleHistoryTab`. Keyed by SKU so several tabs can watch the same article.
*
* @type {KeyedAutomat<Array<ArticleHistoryEntry>>}
* Registry of SKU-specific chunk-list automats for article history.
* @type {Map<string, import('react-automat').Automat>}
*/
export const articleHistoryAutomat = new KeyedAutomat(
null,
{},
{
name: 'articleHistory',
loader: async (sku) => {
const res = await articleApi.getBySkuHistory(String(sku));
requireObject(res, 'GET /api/article/:sku/history');
return requireArray(res.history, 'history');
const skuHistoryAutomats = new Map();
/**
* Gets or creates the chunk-list automat for a specific article's history.
*
* @param {string|number} sku Article SKU.
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
*/
export function getArticleHistoryAutomat(sku) {
const key = String(sku);
let inst = skuHistoryAutomats.get(key);
if (!inst) {
inst = createChunkListAutomat({
name: `articleHistory:${key}`,
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => articleApi.getBySkuHistory(key, params),
});
skuHistoryAutomats.set(key, inst);
}
return inst;
}
/**
* Per-SKU article change history manager (`GET /api/article/{sku}/history`).
*
* Backed by chunk-list automats (`createChunkListAutomat`) keyed by SKU with
* multi-filter memory caching, LRU eviction, and infinite-scrolling / FillSpace support.
*/
export const articleHistoryAutomat = {
get: getArticleHistoryAutomat,
key: getArticleHistoryAutomat,
has(sku) {
return skuHistoryAutomats.has(String(sku));
},
async load(sku) {
const inst = getArticleHistoryAutomat(sku);
return inst.getItems();
},
);
async reload(sku) {
const inst = skuHistoryAutomats.get(String(sku));
if (inst) {
await inst.actions.reload();
}
},
reset() {
for (const inst of skuHistoryAutomats.values()) {
inst.actions.reset();
}
skuHistoryAutomats.clear();
},
};
// Reset on logout or company change
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
articleHistoryAutomat.reset();
}
});
/**
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).

View File

@@ -117,7 +117,7 @@ export { createListAutomat, selectListItems, selectListStatus } from './createLi
/** Per-SKU article detail views keyed by SKU. */
export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
/** Single shared GET /api/events connection. */
export { default as sseAutomat, invalidateArticle } from './sseAutomat';

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,10 @@ import customergroupAutomat from '../../automats/customergroupAutomat';
import supplierAutomat from '../../automats/supplierAutomat';
import orderAutomat from '../../automats/orderAutomat';
import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat';
import sseAutomat from '../../automats/sseAutomat';
import OrderLedgerPanel from './OrderLedgerPanel';
import { fetchOrderLedgerEntries } from '../../utils/orderLedger';
import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price';
import { resolveSalesPrice } from '../../utils/articleDetail';
import {
@@ -102,6 +106,7 @@ class ManifestForm extends PureComponent {
shipments: initialEdit?.shipments || [],
payments: initialEdit?.payments || [],
shippings: initialEdit?.shippings || [],
ledgerEntries: [],
orderTotal: initialEdit?.orderTotal ?? null,
paidTotal: initialEdit?.paidTotal ?? null,
paid: Boolean(initialEdit?.paid),
@@ -217,8 +222,20 @@ class ManifestForm extends PureComponent {
}
});
if (warehouseAutomat.state.status === 'idle') {
void warehouseAutomat.actions.load();
}
if (this.props.mode === 'edit') {
void this.loadEdit();
if (this.props.kind === 'order') {
this.unsubSseLedger = sseAutomat?.actions?.on?.('ledger', () => {
void this.reloadLedger();
});
this.unsubSseStock = sseAutomat?.actions?.on?.('stock', () => {
void this.reloadLedger();
});
}
} else {
void this.loadPartiesOnly();
void ensureDocumentNumber(this.props.kind).catch(() => {});
@@ -359,12 +376,28 @@ class ManifestForm extends PureComponent {
this.unsubParties?.();
this.unsubGroups?.();
this.unsubCompany?.();
this.unsubSseLedger?.();
this.unsubSseStock?.();
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
}
async reloadLedger() {
if (this.props.kind !== 'order' || this.props.mode !== 'edit' || !this.props.docId) return;
try {
const ledgerEntries = await fetchOrderLedgerEntries(this.props.docId, {
shipments: this.state.shipments,
lines: this.state.lines,
});
if (this._cancelled) return;
this.setState({ ledgerEntries });
} catch (err) {
console.warn('[ManifestForm] Failed to reload ledger entries:', err);
}
}
async loadPartiesOnly() {
if (this.state.parties.length === 0) {
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
@@ -464,6 +497,18 @@ class ManifestForm extends PureComponent {
}
}
let ledgerEntries = [];
if (kind === 'order') {
try {
ledgerEntries = await fetchOrderLedgerEntries(docId, {
shipments,
lines: doc.lines,
});
} catch (lErr) {
console.warn('[ManifestForm] Failed to load ledger entries:', lErr);
}
}
const loadedDoc = {
date: dateInputValue(doc.date),
partyId: doc[cfg.partyField],
@@ -492,6 +537,7 @@ class ManifestForm extends PureComponent {
parties,
partiesLoading: false,
...loadedDoc,
ledgerEntries,
isDirty: false,
dirtyFields: null,
dirtyLineKeys: [],
@@ -1409,6 +1455,9 @@ class ManifestForm extends PureComponent {
navigate={this.props.navigate}
/>
) : null}
{status === 'ready' && kind === 'order' && this.props.mode === 'edit' ? (
<OrderLedgerPanel entries={this.state.ledgerEntries} />
) : null}
</PanelTileGrid>
<ManifestPdfDialog
open={pdfDialogOpen}

View File

@@ -0,0 +1,96 @@
import { PureComponent } from 'react';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { PanelTile } from 'dashgui/panels';
import { SURFACE_BASE, SURFACE_RAISED } from '../../colors';
export default class OrderLedgerPanel extends PureComponent {
render() {
const { entries = [] } = this.props;
if (!Array.isArray(entries) || entries.length === 0) {
return null;
}
return (
<PanelTile
wide={4}
color={SURFACE_BASE}
sx={{
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' },
}}
>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead sx={{ bgcolor: SURFACE_RAISED }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>ArtNr</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lieferschein / Beleg</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lager</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Menge verbucht</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Referenz</TableCell>
</TableRow>
</TableHead>
<TableBody>
{entries.map((entry) => {
const isNegative = entry.rawAmount != null && entry.rawAmount < 0;
return (
<TableRow key={entry.id} hover>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{entry.sku}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.name}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.documentNumber || '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.warehouseName}
</Typography>
</TableCell>
<TableCell
align="right"
sx={{
py: 0.5,
fontWeight: 700,
color: isNegative ? 'error.main' : 'success.main',
whiteSpace: 'nowrap',
}}
>
{entry.mengeVerbucht}
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.transaction_text || '–'}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</PanelTile>
);
}
}

View File

@@ -328,7 +328,7 @@ export default class ShipmentDetailDialog extends PureComponent {
this.setState((prev) => ({
shipment: { ...prev.shipment, state: newState },
}));
const label = newState === 'delivered' ? 'Versendet' : newState === 'returned' ? 'Retoure' : 'Neu';
const label = newState === 'delivered' ? 'Versendet' : 'Neu';
enqueueSnackbar(`Lieferstatus geändert: ${label}`, { variant: 'success' });
await Promise.all([
orderAutomat.actions.reload(),
@@ -631,20 +631,9 @@ export default class ShipmentDetailDialog extends PureComponent {
</Button>
)}
{shipment.state === 'delivered' && (
<Button
variant="contained"
color="warning"
disabled={busyAction}
onClick={() => this.handleStateChange('returned')}
sx={{ textTransform: 'none', fontWeight: 700 }}
>
{busyAction ? 'Wird gebucht…' : 'Retoure'}
</Button>
)}
{shipment.state === 'returned' && (
<Chip
label="Retoure"
color="error"
label="Versendet"
color="success"
variant="outlined"
sx={{ fontWeight: 700, height: 28, fontSize: '0.8rem' }}
/>

View File

@@ -424,12 +424,6 @@ class DeliveryStateMenu extends PureComponent {
>
Geliefert (Zugestellt)
</MenuItem>
<MenuItem
selected={currentState === 'returned'}
onClick={() => this.handleSelectState('returned')}
>
Retoure (Retourniert)
</MenuItem>
</Menu>
</Box>
);
@@ -824,14 +818,6 @@ class ShipmentSearchField extends PureComponent {
onClick={() => shipmentAutomat.actions.setStateFilter('delivered')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
<Chip
size="small"
label="Retoure"
color={stateFilter === 'returned' ? 'error' : 'default'}
variant={stateFilter === 'returned' ? 'filled' : 'outlined'}
onClick={() => shipmentAutomat.actions.setStateFilter('returned')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
</Box>
{/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}

View File

@@ -18,6 +18,7 @@ export { default as BookDeliveryDialog } from './BookDeliveryDialog';
export { default as OrderShipmentDialog } from './OrderShipmentDialog';
export { default as ShipmentDetailDialog } from './ShipmentDetailDialog';
export { default as OrderDocumentsTiles } from './OrderDocumentsTiles';
export { default as OrderLedgerPanel } from './OrderLedgerPanel';
export { default as PartyFilterPanel, PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
export { withManifestId } from './withManifestId';
export {

View File

@@ -1,6 +1,3 @@
@import 'ag-grid-community/styles/ag-grid.css';
@import 'ag-grid-community/styles/ag-theme-alpine.css';
/* App background. Provided as a CSS custom property by src/colors.js
(installCssVars), with a hard-coded fallback for the very first paint. */
html,

184
src/utils/orderLedger.js Normal file
View File

@@ -0,0 +1,184 @@
import { ledger as defaultLedgerApi, shipment as defaultShipmentApi } from '../api';
import warehouseAutomat from '../automats/warehouseAutomat';
/**
* Fetches ledger entries for an order by inspecting:
* 1. All lines of the order's shipments (each shipment line id is a manifestarticle_id)
* 2. All lines of the order itself (in case order lines have direct ledger bookings)
*
* @param {number|string} orderId
* @param {Object} [options]
* @param {Array<object>} [options.shipments] Preloaded shipments
* @param {Array<object>} [options.lines] Order lines
* @param {Object} [options.ledgerApi]
* @param {Object} [options.shipmentApi]
* @returns {Promise<Array<object>>} Flattened, correlated and sorted ledger entries
*/
export async function fetchOrderLedgerEntries(orderId, {
shipments = null,
lines = null,
ledgerApi = defaultLedgerApi,
shipmentApi = defaultShipmentApi,
} = {}) {
let shipmentList = shipments;
if (!shipmentList && orderId) {
try {
const res = await shipmentApi.listByOrder(orderId);
shipmentList = Array.isArray(res?.shipments) ? res.shipments : [];
} catch (err) {
console.warn('[orderLedger] Failed to fetch shipments for order:', err);
shipmentList = [];
}
}
// Ensure shipments have their lines loaded
if (Array.isArray(shipmentList)) {
shipmentList = await Promise.all(
shipmentList.map(async (sh) => {
if (Array.isArray(sh.lines) && sh.lines.length > 0) return sh;
if (!sh.id) return sh;
try {
const detailRes = await shipmentApi.get(sh.id);
const detail = detailRes?.shipment ?? detailRes;
return detail?.lines ? detail : sh;
} catch {
return sh;
}
})
);
}
// Collect target lines to check for ledger bookings
const targets = [];
const seenIds = new Set();
for (const sh of shipmentList || []) {
const docNum = sh.number || `Lieferschein #${sh.id}`;
for (const sLine of sh.lines || []) {
if (sLine?.id && !seenIds.has(sLine.id)) {
seenIds.add(sLine.id);
targets.push({
manifestarticleId: sLine.id,
sku: sLine.sku || null,
name: sLine.name || null,
lineAmount: sLine.amount ?? null,
documentNumber: docNum,
documentType: 'shipment',
});
}
}
}
for (const oLine of lines || []) {
if (oLine?.id && !seenIds.has(oLine.id)) {
seenIds.add(oLine.id);
targets.push({
manifestarticleId: oLine.id,
sku: oLine.sku || null,
name: oLine.name || null,
lineAmount: oLine.amount ?? null,
documentNumber: null,
documentType: 'order',
});
}
}
if (targets.length === 0) return [];
// Query ledger entries for each manifestarticleId in parallel
const results = await Promise.all(
targets.map(async (t) => {
try {
const res = await ledgerApi.listByManifestarticleId(t.manifestarticleId);
const entries = Array.isArray(res?.ledger) ? res.ledger : [];
return { target: t, entries };
} catch (err) {
console.warn(`[orderLedger] Failed to fetch ledger for line ${t.manifestarticleId}:`, err);
return { target: t, entries: [] };
}
})
);
const warehouses = warehouseAutomat.state.warehouses || [];
const allEntries = [];
for (const { target, entries } of results) {
if (!entries || entries.length === 0) continue;
const docEntries = entries.filter((e) => {
const type = String(e.transaction_type || '').toUpperCase();
return type !== 'WAREHOUSE';
});
const warehouseEntries = entries.filter((e) => {
const type = String(e.transaction_type || '').toUpperCase();
return type === 'WAREHOUSE';
});
if (docEntries.length > 0) {
docEntries.forEach((docEntry, idx) => {
const matchingWh =
warehouseEntries.find(
(w) =>
w.warehouse_id != null &&
(w.transaction_text === docEntry.transaction_text ||
Math.abs(new Date(w.created_at) - new Date(docEntry.created_at)) < 15000)
) || warehouseEntries[idx] || null;
const warehouseId = matchingWh?.warehouse_id ?? docEntry.warehouse_id ?? null;
const wh = warehouseId != null
? warehouses.find((w) => Number(w.id) === Number(warehouseId))
: null;
const warehouseName = wh?.name || (warehouseId != null ? `Lager #${warehouseId}` : '–');
const rawAmount = matchingWh?.amount ?? docEntry.amount;
const mengeVerbucht = rawAmount != null
? (Number(rawAmount) > 0 ? `+${rawAmount}` : String(rawAmount))
: '–';
allEntries.push({
id: docEntry.id,
created_at: docEntry.created_at,
sku: docEntry.sku || target.sku || '–',
name: target.name || '–',
lineAmount: target.lineAmount != null ? String(target.lineAmount) : '–',
documentNumber: target.documentNumber,
warehouseName,
warehouseId,
mengeVerbucht,
rawAmount: rawAmount != null ? Number(rawAmount) : null,
transaction_type: docEntry.transaction_type,
transaction_text: docEntry.transaction_text || '–',
});
});
} else if (warehouseEntries.length > 0) {
for (const w of warehouseEntries) {
const wh = w.warehouse_id != null
? warehouses.find((item) => Number(item.id) === Number(w.warehouse_id))
: null;
const warehouseName = wh?.name || (w.warehouse_id != null ? `Lager #${w.warehouse_id}` : '–');
const rawAmount = w.amount;
const mengeVerbucht = rawAmount != null
? (Number(rawAmount) > 0 ? `+${rawAmount}` : String(rawAmount))
: '–';
allEntries.push({
id: w.id,
created_at: w.created_at,
sku: w.sku || target.sku || '–',
name: target.name || '–',
lineAmount: target.lineAmount != null ? String(target.lineAmount) : '–',
documentNumber: target.documentNumber,
warehouseName,
warehouseId: w.warehouse_id,
mengeVerbucht,
rawAmount: rawAmount != null ? Number(rawAmount) : null,
transaction_type: w.transaction_type,
transaction_text: w.transaction_text || '–',
});
}
}
}
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
return allEntries;
}