@softwear/latestcollectioncore 1.0.206 → 1.0.207
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/articleStatus.js +295 -54
- package/dist/barcodeCanvas.js +1 -1
- package/dist/pdf.d.ts +1 -5
- package/dist/reports.js +3 -4
- package/package.json +1 -1
- package/src/articleStatus.ts +294 -51
- package/test/articleStatus.spec.js +81 -0
package/dist/articleStatus.js
CHANGED
|
@@ -18,13 +18,13 @@ SELLOUT_PERCENTAGE, ROI, QTY_AVG_STOCK, VALUE_AVG_STOCK, QTY_TURNOVER_VELOCITY,
|
|
|
18
18
|
const VECTOR_LENGTH = 50;
|
|
19
19
|
const BEGIN_STOCK_VECTOR_START = QTY_STOCK;
|
|
20
20
|
const BEGIN_STOCK_VECTOR_END = AMOUNT_SHELF_STOCK + 1;
|
|
21
|
+
const TOTAL_KEY = '** TOTAL **';
|
|
21
22
|
const msYear = 24 * 3600 * 365 * 1000;
|
|
22
|
-
const globalJoinableTables = { customer: 'custnr', sku: 'ean', wh: 'wh'
|
|
23
|
+
const globalJoinableTables = { customer: 'custnr', sku: 'ean', wh: 'wh' }; // defines the keys we can join on
|
|
23
24
|
/**
|
|
24
25
|
* Global variables
|
|
25
26
|
*/
|
|
26
27
|
let globalRelations = {}; // holds the joined tables
|
|
27
|
-
let globalNrAggegationExpressions = 0; // tracks the # aggregations rows so we can quit computing if it grows out of bounds
|
|
28
28
|
/**
|
|
29
29
|
* The propFunction for FiltreX needs some data that can't be passed as parameters. That's why the 'globalsRelations' variable is needed.
|
|
30
30
|
* TODO: Functional: Experiment with curried functions
|
|
@@ -32,17 +32,28 @@ let globalNrAggegationExpressions = 0; // tracks the # aggregations rows so we c
|
|
|
32
32
|
function resolvePath(obj, path) {
|
|
33
33
|
return path.reduce((o, key) => o === null || o === void 0 ? void 0 : o[key], obj);
|
|
34
34
|
}
|
|
35
|
+
const MAX_PARSED_PROPERTIES = 1000;
|
|
36
|
+
const parsedProperties = new Map();
|
|
37
|
+
function parseProperty(propertyName) {
|
|
38
|
+
let parsed = parsedProperties.get(propertyName);
|
|
39
|
+
if (parsed)
|
|
40
|
+
return parsed;
|
|
41
|
+
const positionOfDot = propertyName.indexOf('.');
|
|
42
|
+
parsed = positionOfDot < 0 ? { table: undefined, path: [propertyName] } : { table: propertyName.substring(0, positionOfDot), path: propertyName.substring(positionOfDot + 1).split('.') };
|
|
43
|
+
if (parsedProperties.size >= MAX_PARSED_PROPERTIES)
|
|
44
|
+
parsedProperties.clear();
|
|
45
|
+
parsedProperties.set(propertyName, parsed);
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
35
48
|
function propFunction(propertyName, getPropertyName, obj) {
|
|
36
49
|
var _a;
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
50
|
+
const { table, path } = parseProperty(propertyName);
|
|
51
|
+
if (table === undefined)
|
|
39
52
|
return obj[propertyName];
|
|
40
|
-
const table = propertyName.substring(0, positionOfDot);
|
|
41
|
-
const path = propertyName.substring(positionOfDot + 1).split('.');
|
|
42
53
|
if (table == 'transaction')
|
|
43
|
-
return resolvePath(obj, path);
|
|
54
|
+
return path.length == 1 ? obj[path[0]] : resolvePath(obj, path);
|
|
44
55
|
const joinedRecord = (_a = globalRelations[table]) === null || _a === void 0 ? void 0 : _a[obj[globalJoinableTables[table]]];
|
|
45
|
-
return resolvePath(joinedRecord, path) || '~ ?';
|
|
56
|
+
return (path.length == 1 ? joinedRecord === null || joinedRecord === void 0 ? void 0 : joinedRecord[path[0]] : resolvePath(joinedRecord, path)) || '~ ?';
|
|
46
57
|
}
|
|
47
58
|
const lower = (s) => s.toLowerCase();
|
|
48
59
|
const upper = (s) => s.toUpperCase();
|
|
@@ -50,23 +61,50 @@ const filtrexOptions = {
|
|
|
50
61
|
extraFunctions: { lower, upper },
|
|
51
62
|
customProp: propFunction,
|
|
52
63
|
};
|
|
53
|
-
|
|
64
|
+
const filtrexKeywords = ['and', 'or', 'not', 'in', 'of', 'if', 'then', 'else', 'mod'];
|
|
65
|
+
/**
|
|
66
|
+
* The transaction fields an expression result depends on. Two transactions with the same values for
|
|
67
|
+
* these fields give the same result, so the expression is evaluated once per combination.
|
|
68
|
+
* - A joined table (sku., wh., customer.) depends on its join key.
|
|
69
|
+
* - transaction.<field> and a bare <field> depend on that field of the transaction itself.
|
|
70
|
+
* A field too many only costs cache hits; a field too few would give wrong results.
|
|
71
|
+
*/
|
|
72
|
+
function getExpressionGranularity(expression) {
|
|
73
|
+
const fields = [];
|
|
74
|
+
const addField = (field) => {
|
|
75
|
+
if (fields.indexOf(field) < 0)
|
|
76
|
+
fields.push(field);
|
|
77
|
+
};
|
|
78
|
+
Object.entries(globalJoinableTables).forEach(([table, joinKey]) => {
|
|
79
|
+
if (expression.includes(table + '.'))
|
|
80
|
+
addField(joinKey);
|
|
81
|
+
});
|
|
82
|
+
const withoutStrings = expression.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
83
|
+
// 'quoted symbol' | symbol, optionally followed by "(" when it is a function call
|
|
84
|
+
const symbolPattern = /'((?:[^'\\]|\\.)*)'|([A-Za-z_$][A-Za-z0-9_$.]*)(\s*\()?/g;
|
|
85
|
+
let match;
|
|
86
|
+
while ((match = symbolPattern.exec(withoutStrings))) {
|
|
87
|
+
let symbol = match[2];
|
|
88
|
+
if (match[1] !== undefined)
|
|
89
|
+
symbol = match[1].replace(/\\(.)/g, '$1');
|
|
90
|
+
else if (match[3] || filtrexKeywords.indexOf(symbol) >= 0)
|
|
91
|
+
continue;
|
|
92
|
+
const positionOfDot = symbol.indexOf('.');
|
|
93
|
+
if (positionOfDot < 0)
|
|
94
|
+
addField(symbol);
|
|
95
|
+
else if (symbol.substring(0, positionOfDot) == 'transaction')
|
|
96
|
+
addField(symbol.substring(positionOfDot + 1).split('.')[0]);
|
|
97
|
+
}
|
|
98
|
+
return fields;
|
|
99
|
+
}
|
|
100
|
+
// Deprecated schema: transactions that still carry a stored vector are added slot by slot.
|
|
54
101
|
function addVectors(targetVector, otherVector, begin, end) {
|
|
102
|
+
if (end > otherVector.length)
|
|
103
|
+
end = otherVector.length;
|
|
55
104
|
for (let i = begin; i < end; i++)
|
|
56
105
|
targetVector[i] += otherVector[i] || 0;
|
|
57
106
|
}
|
|
58
|
-
|
|
59
|
-
function addTransactionToAggregations(beginTimestamp, endTimestamp, aggregateKey, rawAggregations, transaction, maxRows) {
|
|
60
|
-
let aggregateVector = rawAggregations[aggregateKey];
|
|
61
|
-
if (globalNrAggegationExpressions >= maxRows)
|
|
62
|
-
return;
|
|
63
|
-
if (!aggregateVector) {
|
|
64
|
-
globalNrAggegationExpressions++;
|
|
65
|
-
aggregateVector = new Float64Array(VECTOR_LENGTH);
|
|
66
|
-
rawAggregations[aggregateKey] = aggregateVector;
|
|
67
|
-
}
|
|
68
|
-
if (!transaction.vector)
|
|
69
|
-
transaction.vector = transaction_1.default.buildVector(transaction);
|
|
107
|
+
function addStoredVector(beginTimestamp, endTimestamp, aggregateVector, transaction) {
|
|
70
108
|
if (Array.isArray(transaction.vector))
|
|
71
109
|
transaction.vector = Float64Array.from(transaction.vector);
|
|
72
110
|
const v = transaction.vector;
|
|
@@ -118,6 +156,8 @@ function getTrieLeaf(root, transaction, keys) {
|
|
|
118
156
|
}
|
|
119
157
|
return node;
|
|
120
158
|
}
|
|
159
|
+
// The most fields a single transaction adds to an aggregation row (a returned sale: 10)
|
|
160
|
+
const MAX_CONTRIBUTIONS = 12;
|
|
121
161
|
/*
|
|
122
162
|
* A factory function that returns a function that can be used to aggregate transactions
|
|
123
163
|
* The factory function is called once for each aggregationExpression
|
|
@@ -146,19 +186,228 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
|
|
|
146
186
|
const filterTrieRoot = {};
|
|
147
187
|
if (filterExpression) {
|
|
148
188
|
filter = (0, filtrex_1.compileExpression)(filterExpression, filtrexOptions);
|
|
149
|
-
filterGranularity =
|
|
150
|
-
.filter((join) => filterExpression.includes(join[0] + '.'))
|
|
151
|
-
.map((join) => join[1]);
|
|
189
|
+
filterGranularity = getExpressionGranularity(filterExpression);
|
|
152
190
|
}
|
|
153
191
|
// Empty aggregation expression = no groupers → only the totals row
|
|
154
192
|
const totalsOnly = !aggregationExpression;
|
|
155
193
|
const aggregationEvaluator = totalsOnly ? undefined : buildAggregationEvaluator(aggregationExpression);
|
|
156
|
-
const aggregationGranularity = totalsOnly
|
|
157
|
-
? []
|
|
158
|
-
: Object.entries(globalJoinableTables)
|
|
159
|
-
.filter((join) => aggregationExpression.includes(join[0] + '.'))
|
|
160
|
-
.map((join) => join[1]);
|
|
194
|
+
const aggregationGranularity = totalsOnly ? [] : getExpressionGranularity(aggregationExpression);
|
|
161
195
|
const aggregationTrieRoot = {};
|
|
196
|
+
let nrNewRows = 0; // tracks the # rows added so we can quit computing if it grows out of bounds
|
|
197
|
+
let totalVector;
|
|
198
|
+
// What the current transaction adds to an aggregation row: computed once, added to its row, subtotal and total
|
|
199
|
+
const slots = new Int32Array(MAX_CONTRIBUTIONS);
|
|
200
|
+
const values = new Float64Array(MAX_CONTRIBUTIONS);
|
|
201
|
+
let nrContributions = 0;
|
|
202
|
+
// NaN (a missing qty or price) counts as 0
|
|
203
|
+
function contribute(slot, value) {
|
|
204
|
+
slots[nrContributions] = slot;
|
|
205
|
+
values[nrContributions++] = value || 0;
|
|
206
|
+
}
|
|
207
|
+
function contributeStockTime(qty, amount, timeLeft) {
|
|
208
|
+
slots[nrContributions] = STOCK_TIME_PRODUCT;
|
|
209
|
+
values[nrContributions++] = qty * timeLeft;
|
|
210
|
+
slots[nrContributions] = STOCKAMOUNT_TIME_PRODUCT;
|
|
211
|
+
values[nrContributions++] = amount * timeLeft;
|
|
212
|
+
}
|
|
213
|
+
// Same numbers as transaction.buildVector, without building a vector per transaction
|
|
214
|
+
function prepareContributions(transaction) {
|
|
215
|
+
nrContributions = 0;
|
|
216
|
+
const time = transaction.time;
|
|
217
|
+
const type = typeof transaction.type == 'string' ? transaction.type : String(transaction.type);
|
|
218
|
+
const qty = +transaction.qty;
|
|
219
|
+
if (time <= beginTimestamp) {
|
|
220
|
+
// Before the period: begin stock, plus outstanding picklists/consignments so endShelf remains stable
|
|
221
|
+
// when only the start date changes for a fixed end date.
|
|
222
|
+
switch (type) {
|
|
223
|
+
case types_1.transactionTypeE.RECEIVING:
|
|
224
|
+
case types_1.transactionTypeE.TRANSIT:
|
|
225
|
+
case types_1.transactionTypeE.CHANGE:
|
|
226
|
+
case types_1.transactionTypeE.START_STOCK:
|
|
227
|
+
case types_1.transactionTypeE.RETURN_CUSTOMER: {
|
|
228
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
229
|
+
contribute(QTY_STOCK, qty);
|
|
230
|
+
contribute(AMOUNT_STOCK, cost);
|
|
231
|
+
contribute(QTY_SHELF_STOCK, qty);
|
|
232
|
+
contribute(AMOUNT_SHELF_STOCK, cost);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
case types_1.transactionTypeE.SALE:
|
|
236
|
+
case types_1.transactionTypeE.B2B_SOLD:
|
|
237
|
+
case types_1.transactionTypeE.B2B_RE_SOLD: {
|
|
238
|
+
const cost = (0, index_1.round2)(-transaction.buyprice * transaction.qty);
|
|
239
|
+
contribute(QTY_STOCK, -qty);
|
|
240
|
+
contribute(AMOUNT_STOCK, cost);
|
|
241
|
+
contribute(QTY_SHELF_STOCK, -qty);
|
|
242
|
+
contribute(AMOUNT_SHELF_STOCK, cost);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
case types_1.transactionTypeE.RE_VALUATION: {
|
|
246
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
247
|
+
contribute(AMOUNT_STOCK, cost);
|
|
248
|
+
contribute(AMOUNT_SHELF_STOCK, cost);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
case types_1.transactionTypeE.START_SHELF_STOCK:
|
|
252
|
+
// Type 10 subtracts outstanding picktickets of previous shards from the shelf stock
|
|
253
|
+
contribute(QTY_SHELF_STOCK, qty);
|
|
254
|
+
contribute(AMOUNT_SHELF_STOCK, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
255
|
+
return;
|
|
256
|
+
case types_1.transactionTypeE.PICK_LIST:
|
|
257
|
+
contribute(QTY_PICKLIST, qty);
|
|
258
|
+
contribute(AMOUNT_PICKLIST, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
259
|
+
contribute(COSTPRICE_PICKLIST, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
260
|
+
return;
|
|
261
|
+
case types_1.transactionTypeE.CONSIGNMENT:
|
|
262
|
+
contribute(QTY_CONSIGNMENT, qty);
|
|
263
|
+
contribute(AMOUNT_CONSIGNMENT, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
264
|
+
contribute(COSTPRICE_CONSIGNMENT, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (time > endTimestamp)
|
|
270
|
+
return;
|
|
271
|
+
// Period transactions
|
|
272
|
+
const timeLeft = endTimestamp - time;
|
|
273
|
+
switch (type) {
|
|
274
|
+
case types_1.transactionTypeE.SALE: {
|
|
275
|
+
const sku = globalRelations['sku'][transaction.ean];
|
|
276
|
+
const amountSoldExcl = (0, index_1.round2)((transaction.sellprice / ((100 + (transaction.vat || 0)) / 100)) * transaction.qty);
|
|
277
|
+
const costprice = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
278
|
+
const amountSoldMax = (0, index_1.round2)((((sku === null || sku === void 0 ? void 0 : sku.price) || 0) / ((100 + (transaction.vat || (sku === null || sku === void 0 ? void 0 : sku.vat) || 21)) / 100)) * qty);
|
|
279
|
+
contribute(QTY_SOLD, qty);
|
|
280
|
+
contribute(AMOUNT_SOLD, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
281
|
+
contribute(AMOUNT_SOLD_EXCL, amountSoldExcl);
|
|
282
|
+
contribute(COSTPRICE_SOLD, costprice);
|
|
283
|
+
contribute(AMOUNT_SOLD_MAX, amountSoldMax);
|
|
284
|
+
contribute(AMOUNT_SOLD_DISCOUNT, (0, index_1.round2)(amountSoldMax - amountSoldExcl));
|
|
285
|
+
if (qty < 0)
|
|
286
|
+
contribute(QTY_RETURN, qty);
|
|
287
|
+
if (amountSoldExcl < 0)
|
|
288
|
+
contribute(AMOUNT_RETURN, amountSoldExcl);
|
|
289
|
+
contributeStockTime(-(qty || 0), -(costprice || 0), timeLeft);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case types_1.transactionTypeE.RECEIVING: {
|
|
293
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
294
|
+
contribute(QTY_RECIEVED, qty);
|
|
295
|
+
contribute(AMOUNT_RECIEVED, cost);
|
|
296
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
case types_1.transactionTypeE.TRANSIT: {
|
|
300
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
301
|
+
contribute(QTY_TRANSIT, qty);
|
|
302
|
+
contribute(AMOUNT_TRANSIT, cost);
|
|
303
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
case types_1.transactionTypeE.CHANGE:
|
|
307
|
+
case types_1.transactionTypeE.START_STOCK: {
|
|
308
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
309
|
+
contribute(QTY_CHANGE, qty);
|
|
310
|
+
contribute(AMOUNT_CHANGE, cost);
|
|
311
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
case types_1.transactionTypeE.B2B_SOLD: {
|
|
315
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
316
|
+
contribute(QTY_B2B_SOLD, qty);
|
|
317
|
+
contribute(AMOUNT_B2B_SOLD, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
318
|
+
contribute(COSTPRICE_B2B_SOLD, cost);
|
|
319
|
+
contributeStockTime(-(qty || 0), -(cost || 0), timeLeft);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case types_1.transactionTypeE.B2B_RE_SOLD: {
|
|
323
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
324
|
+
contribute(QTY_B2B_RESOLD, qty);
|
|
325
|
+
contribute(AMOUNT_B2B_RESOLD, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
326
|
+
contribute(COSTPRICE_B2B_RESOLD, cost);
|
|
327
|
+
contributeStockTime(-(qty || 0), -(cost || 0), timeLeft);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
case types_1.transactionTypeE.RETURN_CUSTOMER: {
|
|
331
|
+
const cost = (0, index_1.round2)(transaction.buyprice * transaction.qty);
|
|
332
|
+
contribute(QTY_B2B_RETURN, qty);
|
|
333
|
+
contribute(AMOUNT_B2B_RETURN, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
334
|
+
contribute(COSTPRICE_B2B_RETURN, cost);
|
|
335
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
case types_1.transactionTypeE.CREDIT_INVOICE:
|
|
339
|
+
contribute(AMOUNT_B2B_RETURN, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
340
|
+
return;
|
|
341
|
+
case types_1.transactionTypeE.RE_VALUATION:
|
|
342
|
+
contribute(AMOUNT_REVALUATE, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
343
|
+
return;
|
|
344
|
+
case types_1.transactionTypeE.PURCHASE_ORDER:
|
|
345
|
+
contribute(QTY_PO, qty);
|
|
346
|
+
contribute(AMOUNT_PO, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
347
|
+
return;
|
|
348
|
+
case types_1.transactionTypeE.PURCHASE_ORDER_COMPLETE:
|
|
349
|
+
contribute(QTY_PO_COMPLETE, qty);
|
|
350
|
+
contribute(AMOUNT_PO_COMPLETE, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
351
|
+
return;
|
|
352
|
+
case types_1.transactionTypeE.MINIMUM_STOCK:
|
|
353
|
+
contribute(QTY_MINIMUM_STOCK, qty);
|
|
354
|
+
contribute(AMOUNT_MINIMUM_STOCK, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
355
|
+
return;
|
|
356
|
+
case types_1.transactionTypeE.ORDER:
|
|
357
|
+
contribute(QTY_ORDER, qty);
|
|
358
|
+
contribute(AMOUNT_ORDER, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
359
|
+
contribute(COSTPRICE_ORDER, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
360
|
+
return;
|
|
361
|
+
case types_1.transactionTypeE.RE_ORDER:
|
|
362
|
+
contribute(QTY_REORDER, qty);
|
|
363
|
+
contribute(AMOUNT_REORDER, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
364
|
+
contribute(COSTPRICE_REORDER, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
365
|
+
return;
|
|
366
|
+
case types_1.transactionTypeE.PICK_LIST:
|
|
367
|
+
// Picklists reduce shelfStock, which is handled by subtracting QTY_PICKLIST in postAgg
|
|
368
|
+
contribute(QTY_PICKLIST, qty);
|
|
369
|
+
contribute(AMOUNT_PICKLIST, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
370
|
+
contribute(COSTPRICE_PICKLIST, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
371
|
+
return;
|
|
372
|
+
case types_1.transactionTypeE.CONSIGNMENT:
|
|
373
|
+
// Consignments reduce shelfStock, which is handled by subtracting QTY_CONSIGNMENT in postAgg
|
|
374
|
+
contribute(QTY_CONSIGNMENT, qty);
|
|
375
|
+
contribute(AMOUNT_CONSIGNMENT, (0, index_1.round2)(transaction.sellprice * transaction.qty));
|
|
376
|
+
contribute(COSTPRICE_CONSIGNMENT, (0, index_1.round2)(transaction.buyprice * transaction.qty));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Deprecated schema: derived sale fields live in the stored vector
|
|
381
|
+
function prepareStoredVector(transaction) {
|
|
382
|
+
if (transaction.type != types_1.transactionTypeE.SALE)
|
|
383
|
+
return;
|
|
384
|
+
const sku = globalRelations['sku'][transaction.ean];
|
|
385
|
+
if (Array.isArray(transaction.vector)) {
|
|
386
|
+
const tmp = new Float64Array(AMOUNT_SOLD_DISCOUNT + 1);
|
|
387
|
+
tmp.set(transaction.vector);
|
|
388
|
+
transaction.vector = tmp;
|
|
389
|
+
}
|
|
390
|
+
const v = transaction.vector;
|
|
391
|
+
v[AMOUNT_SOLD_MAX] = (0, index_1.round2)((((sku === null || sku === void 0 ? void 0 : sku.price) || 0) / ((100 + (transaction.vat || (sku === null || sku === void 0 ? void 0 : sku.vat) || 21)) / 100)) * v[QTY_SOLD]);
|
|
392
|
+
v[AMOUNT_SOLD_DISCOUNT] = (0, index_1.round2)(v[AMOUNT_SOLD_MAX] - v[AMOUNT_SOLD_EXCL]);
|
|
393
|
+
}
|
|
394
|
+
function getAggregateVector(aggregateKey) {
|
|
395
|
+
let aggregateVector = rawAggregations[aggregateKey];
|
|
396
|
+
if (!aggregateVector) {
|
|
397
|
+
nrNewRows++;
|
|
398
|
+
aggregateVector = new Float64Array(VECTOR_LENGTH);
|
|
399
|
+
rawAggregations[aggregateKey] = aggregateVector;
|
|
400
|
+
}
|
|
401
|
+
return aggregateVector;
|
|
402
|
+
}
|
|
403
|
+
function addTransactionToVector(aggregateVector, transaction) {
|
|
404
|
+
if (transaction.vector)
|
|
405
|
+
return addStoredVector(beginTimestamp, endTimestamp, aggregateVector, transaction);
|
|
406
|
+
if (transaction.type == types_1.transactionTypeE.RECEIVING && transaction.time > aggregateVector[MAX_RECIEVE_TIMESTAMP])
|
|
407
|
+
aggregateVector[MAX_RECIEVE_TIMESTAMP] = transaction.time;
|
|
408
|
+
for (let i = 0; i < nrContributions; i++)
|
|
409
|
+
aggregateVector[slots[i]] += values[i];
|
|
410
|
+
}
|
|
162
411
|
return function (transaction) {
|
|
163
412
|
if (filter) {
|
|
164
413
|
const filterLeaf = getTrieLeaf(filterTrieRoot, transaction, filterGranularity);
|
|
@@ -170,22 +419,15 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
|
|
|
170
419
|
return;
|
|
171
420
|
}
|
|
172
421
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
transaction.vector = tmp;
|
|
182
|
-
}
|
|
183
|
-
const v = transaction.vector;
|
|
184
|
-
v[AMOUNT_SOLD_MAX] = (0, index_1.round2)((((sku === null || sku === void 0 ? void 0 : sku.price) || 0) / ((100 + (transaction.vat || (sku === null || sku === void 0 ? void 0 : sku.vat) || 21)) / 100)) * v[QTY_SOLD]);
|
|
185
|
-
v[AMOUNT_SOLD_DISCOUNT] = (0, index_1.round2)(v[AMOUNT_SOLD_MAX] - v[AMOUNT_SOLD_EXCL]);
|
|
422
|
+
if (transaction.vector)
|
|
423
|
+
prepareStoredVector(transaction);
|
|
424
|
+
else
|
|
425
|
+
prepareContributions(transaction);
|
|
426
|
+
if ((totals || totalsOnly) && nrNewRows < maxRows) {
|
|
427
|
+
if (!totalVector)
|
|
428
|
+
totalVector = getAggregateVector(TOTAL_KEY);
|
|
429
|
+
addTransactionToVector(totalVector, transaction);
|
|
186
430
|
}
|
|
187
|
-
if (totals || totalsOnly)
|
|
188
|
-
addTransactionToAggregations(beginTimestamp, endTimestamp, '** TOTAL **', rawAggregations, transaction, maxRows);
|
|
189
431
|
// No detail/subtotal rows when there are no groupers
|
|
190
432
|
if (totalsOnly)
|
|
191
433
|
return;
|
|
@@ -198,20 +440,19 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
|
|
|
198
440
|
// Example: for 4 groupers we have rows like
|
|
199
441
|
// g0 g1 g2 g3 <- normal row with no subtotal
|
|
200
442
|
// g0 <- subtotal for g0
|
|
201
|
-
if (subtotalsForOuterGrouper == true) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const KPIKey = prepareSubTotalAggKey(aggregateKey);
|
|
206
|
-
addTransactionToAggregations(beginTimestamp, endTimestamp, KPIKey, rawAggregations, transaction, maxRows);
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
addTransactionToAggregations(beginTimestamp, endTimestamp, aggregateKey, rawAggregations, transaction, maxRows);
|
|
443
|
+
if (subtotalsForOuterGrouper == true && nrNewRows < maxRows) {
|
|
444
|
+
if (!aggregateKeyLeaf.subtotalVector)
|
|
445
|
+
aggregateKeyLeaf.subtotalVector = getAggregateVector(`${aggregateKey.substr(0, aggregateKey.indexOf('\t'))} Σ`);
|
|
446
|
+
addTransactionToVector(aggregateKeyLeaf.subtotalVector, transaction);
|
|
210
447
|
}
|
|
448
|
+
if (nrNewRows >= maxRows)
|
|
449
|
+
return;
|
|
450
|
+
if (!aggregateKeyLeaf.vector)
|
|
451
|
+
aggregateKeyLeaf.vector = getAggregateVector(parentGroupTotals ? prepareSubTotalAggKey(aggregateKey) : aggregateKey);
|
|
452
|
+
addTransactionToVector(aggregateKeyLeaf.vector, transaction);
|
|
211
453
|
};
|
|
212
454
|
}
|
|
213
455
|
function aggregate({ beginTimestamp, endTimestamp, filterExpression, aggregationExpression, transactions, beginStock, relations, rawAggregations, maxRows, totals, subtotalsForOuterGrouper, parentGroupTotals }) {
|
|
214
|
-
globalNrAggegationExpressions = 0;
|
|
215
456
|
globalRelations = relations;
|
|
216
457
|
const compiledAggregator = aggregator({
|
|
217
458
|
beginTimestamp,
|
package/dist/barcodeCanvas.js
CHANGED
|
@@ -37,7 +37,7 @@ function renderBarcodeToDataUrl(value) {
|
|
|
37
37
|
try {
|
|
38
38
|
JsBarcode(canvas, value, {
|
|
39
39
|
// Fixed module geometry — do not stretch independently in X/Y when placing on the page.
|
|
40
|
-
// CODE128 accepts arbitrary values
|
|
40
|
+
// CODE128 accepts arbitrary values.
|
|
41
41
|
format: 'CODE128',
|
|
42
42
|
width: 2,
|
|
43
43
|
height: 60,
|
package/dist/pdf.d.ts
CHANGED
|
@@ -50,7 +50,6 @@ export interface ImageLayoutObject extends BaseLayoutObject {
|
|
|
50
50
|
*/
|
|
51
51
|
export interface BarcodeLayoutObject extends BaseLayoutObject {
|
|
52
52
|
type: 'barcode';
|
|
53
|
-
/** Path into the current print buffer item; defaults to `barcode`. */
|
|
54
53
|
source?: string;
|
|
55
54
|
}
|
|
56
55
|
export interface ContainerLayoutObject extends BaseLayoutObject {
|
|
@@ -60,10 +59,7 @@ export interface ContainerLayoutObject extends BaseLayoutObject {
|
|
|
60
59
|
repeatContainer?: RepeatContainer;
|
|
61
60
|
repeatOnOverflow?: boolean;
|
|
62
61
|
pageBreak?: boolean;
|
|
63
|
-
/**
|
|
64
|
-
* Minimum remaining space before drawing the next item; if less, break to the next page first.
|
|
65
|
-
* Same units as layout x/y/width/height (tenths of mm), e.g. 220 ≈ 22mm.
|
|
66
|
-
*/
|
|
62
|
+
/** Minimum mm of remaining space before drawing next item; if less, break to next page first */
|
|
67
63
|
minHeightBeforeBreak?: number;
|
|
68
64
|
/** When true, container and children are drawn only once at the end of the report, not on every page */
|
|
69
65
|
printOnlyAtEnd?: boolean;
|
package/dist/reports.js
CHANGED
|
@@ -554,10 +554,9 @@ addObjectToPDF = function (originX, originY, doc, object, printBuffer, paperSize
|
|
|
554
554
|
});
|
|
555
555
|
const container = source[containerIndex];
|
|
556
556
|
absoluteLowerRightHandSide = { x: originX / 10, y: originY / 10 };
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
if (useOrphanCheck && (object.pageBreak || originY + minHeightBeforeBreak > paperSize.height - paperSize.footerHeight)) {
|
|
557
|
+
const minHeightMm = object.minHeightBeforeBreak;
|
|
558
|
+
const useOrphanCheck = minHeightMm != null && minHeightMm > 0;
|
|
559
|
+
if (useOrphanCheck && (object.pageBreak || originY + minHeightMm > paperSize.height - paperSize.footerHeight)) {
|
|
561
560
|
// We ran out of paper
|
|
562
561
|
originX = layoutXtenths;
|
|
563
562
|
// Continuation sheets must not reuse the page-1-only overlap bump: addPage hasn't run yet so getCurrentPageNumber is still 1.
|
package/package.json
CHANGED
package/src/articleStatus.ts
CHANGED
|
@@ -84,14 +84,15 @@ const VECTOR_LENGTH = 50
|
|
|
84
84
|
const BEGIN_STOCK_VECTOR_START = QTY_STOCK
|
|
85
85
|
const BEGIN_STOCK_VECTOR_END = AMOUNT_SHELF_STOCK + 1
|
|
86
86
|
|
|
87
|
+
const TOTAL_KEY = '** TOTAL **'
|
|
88
|
+
|
|
87
89
|
const msYear = 24 * 3600 * 365 * 1000
|
|
88
|
-
const globalJoinableTables = { customer: 'custnr', sku: 'ean', wh: 'wh'
|
|
90
|
+
const globalJoinableTables = { customer: 'custnr', sku: 'ean', wh: 'wh' } // defines the keys we can join on
|
|
89
91
|
|
|
90
92
|
/**
|
|
91
93
|
* Global variables
|
|
92
94
|
*/
|
|
93
95
|
let globalRelations = {} // holds the joined tables
|
|
94
|
-
let globalNrAggegationExpressions = 0 // tracks the # aggregations rows so we can quit computing if it grows out of bounds
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
98
|
* The propFunction for FiltreX needs some data that can't be passed as parameters. That's why the 'globalsRelations' variable is needed.
|
|
@@ -101,14 +102,27 @@ function resolvePath(obj: Record<string, any> | undefined, path: string[]): any
|
|
|
101
102
|
return path.reduce((o, key) => o?.[key], obj)
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
// Property names are parsed once; FiltreX asks for the same few names over and over.
|
|
106
|
+
type ParsedPropertyI = { table: string | undefined; path: string[] }
|
|
107
|
+
const MAX_PARSED_PROPERTIES = 1000
|
|
108
|
+
const parsedProperties = new Map<string, ParsedPropertyI>()
|
|
109
|
+
|
|
110
|
+
function parseProperty(propertyName: string): ParsedPropertyI {
|
|
111
|
+
let parsed = parsedProperties.get(propertyName)
|
|
112
|
+
if (parsed) return parsed
|
|
105
113
|
const positionOfDot = propertyName.indexOf('.')
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
parsed = positionOfDot < 0 ? { table: undefined, path: [propertyName] } : { table: propertyName.substring(0, positionOfDot), path: propertyName.substring(positionOfDot + 1).split('.') }
|
|
115
|
+
if (parsedProperties.size >= MAX_PARSED_PROPERTIES) parsedProperties.clear()
|
|
116
|
+
parsedProperties.set(propertyName, parsed)
|
|
117
|
+
return parsed
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function propFunction(propertyName: string, getPropertyName: Function, obj: Record<string, any>): any {
|
|
121
|
+
const { table, path } = parseProperty(propertyName)
|
|
122
|
+
if (table === undefined) return obj[propertyName]
|
|
123
|
+
if (table == 'transaction') return path.length == 1 ? obj[path[0]] : resolvePath(obj, path)
|
|
110
124
|
const joinedRecord = globalRelations[table]?.[obj[globalJoinableTables[table]]]
|
|
111
|
-
return resolvePath(joinedRecord, path) || '~ ?'
|
|
125
|
+
return (path.length == 1 ? joinedRecord?.[path[0]] : resolvePath(joinedRecord, path)) || '~ ?'
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
const lower = (s: string) => s.toLowerCase()
|
|
@@ -119,21 +133,45 @@ const filtrexOptions = {
|
|
|
119
133
|
customProp: propFunction,
|
|
120
134
|
}
|
|
121
135
|
|
|
122
|
-
|
|
136
|
+
const filtrexKeywords = ['and', 'or', 'not', 'in', 'of', 'if', 'then', 'else', 'mod']
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The transaction fields an expression result depends on. Two transactions with the same values for
|
|
140
|
+
* these fields give the same result, so the expression is evaluated once per combination.
|
|
141
|
+
* - A joined table (sku., wh., customer.) depends on its join key.
|
|
142
|
+
* - transaction.<field> and a bare <field> depend on that field of the transaction itself.
|
|
143
|
+
* A field too many only costs cache hits; a field too few would give wrong results.
|
|
144
|
+
*/
|
|
145
|
+
function getExpressionGranularity(expression: string): string[] {
|
|
146
|
+
const fields: string[] = []
|
|
147
|
+
const addField = (field: string) => {
|
|
148
|
+
if (fields.indexOf(field) < 0) fields.push(field)
|
|
149
|
+
}
|
|
150
|
+
Object.entries(globalJoinableTables).forEach(([table, joinKey]) => {
|
|
151
|
+
if (expression.includes(table + '.')) addField(joinKey)
|
|
152
|
+
})
|
|
153
|
+
const withoutStrings = expression.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
154
|
+
// 'quoted symbol' | symbol, optionally followed by "(" when it is a function call
|
|
155
|
+
const symbolPattern = /'((?:[^'\\]|\\.)*)'|([A-Za-z_$][A-Za-z0-9_$.]*)(\s*\()?/g
|
|
156
|
+
let match: RegExpExecArray | null
|
|
157
|
+
while ((match = symbolPattern.exec(withoutStrings))) {
|
|
158
|
+
let symbol = match[2]
|
|
159
|
+
if (match[1] !== undefined) symbol = match[1].replace(/\\(.)/g, '$1')
|
|
160
|
+
else if (match[3] || filtrexKeywords.indexOf(symbol) >= 0) continue
|
|
161
|
+
const positionOfDot = symbol.indexOf('.')
|
|
162
|
+
if (positionOfDot < 0) addField(symbol)
|
|
163
|
+
else if (symbol.substring(0, positionOfDot) == 'transaction') addField(symbol.substring(positionOfDot + 1).split('.')[0])
|
|
164
|
+
}
|
|
165
|
+
return fields
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Deprecated schema: transactions that still carry a stored vector are added slot by slot.
|
|
123
169
|
function addVectors(targetVector: Float64Array, otherVector: Float64Array, begin: number, end: number): void {
|
|
170
|
+
if (end > otherVector.length) end = otherVector.length
|
|
124
171
|
for (let i = begin; i < end; i++) targetVector[i] += otherVector[i] || 0
|
|
125
172
|
}
|
|
126
173
|
|
|
127
|
-
|
|
128
|
-
function addTransactionToAggregations(beginTimestamp: number, endTimestamp: number, aggregateKey: string, rawAggregations: RaGI, transaction: TransactionI, maxRows: number): void {
|
|
129
|
-
let aggregateVector = rawAggregations[aggregateKey]
|
|
130
|
-
if (globalNrAggegationExpressions >= maxRows) return
|
|
131
|
-
if (!aggregateVector) {
|
|
132
|
-
globalNrAggegationExpressions++
|
|
133
|
-
aggregateVector = new Float64Array(VECTOR_LENGTH)
|
|
134
|
-
rawAggregations[aggregateKey] = aggregateVector
|
|
135
|
-
}
|
|
136
|
-
if (!transaction.vector) transaction.vector = transactionFunctions.buildVector(transaction)
|
|
174
|
+
function addStoredVector(beginTimestamp: number, endTimestamp: number, aggregateVector: Float64Array, transaction: TransactionI): void {
|
|
137
175
|
if (Array.isArray(transaction.vector)) transaction.vector = Float64Array.from(transaction.vector)
|
|
138
176
|
const v = transaction.vector
|
|
139
177
|
if (transaction.type == transactionTypeE.RECEIVING && transaction.time > aggregateVector[MAX_RECIEVE_TIMESTAMP]) aggregateVector[MAX_RECIEVE_TIMESTAMP] = transaction.time
|
|
@@ -171,7 +209,8 @@ function coerceJoinKeyValue(v: unknown): unknown {
|
|
|
171
209
|
return typeof v === 'boolean' ? +v : v
|
|
172
210
|
}
|
|
173
211
|
|
|
174
|
-
|
|
212
|
+
// A leaf remembers the aggregation rows of its key so a transaction is added without looking them up again
|
|
213
|
+
type AggregateKeyTrieNode = { aggregateKey?: string; vector?: Float64Array; subtotalVector?: Float64Array; children?: Map<unknown, AggregateKeyTrieNode> }
|
|
175
214
|
type FilterResultTrieNode = { filterResult?: 1 | -1; children?: Map<unknown, FilterResultTrieNode> }
|
|
176
215
|
|
|
177
216
|
function getTrieLeaf<T extends { children?: Map<unknown, T> }>(root: T, transaction: TransactionI, keys: string[]): T {
|
|
@@ -191,6 +230,9 @@ function getTrieLeaf<T extends { children?: Map<unknown, T> }>(root: T, transact
|
|
|
191
230
|
return node
|
|
192
231
|
}
|
|
193
232
|
|
|
233
|
+
// The most fields a single transaction adds to an aggregation row (a returned sale: 10)
|
|
234
|
+
const MAX_CONTRIBUTIONS = 12
|
|
235
|
+
|
|
194
236
|
/*
|
|
195
237
|
* A factory function that returns a function that can be used to aggregate transactions
|
|
196
238
|
* The factory function is called once for each aggregationExpression
|
|
@@ -219,20 +261,232 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
|
|
|
219
261
|
const filterTrieRoot: FilterResultTrieNode = {}
|
|
220
262
|
if (filterExpression) {
|
|
221
263
|
filter = compileExpression(filterExpression, filtrexOptions)
|
|
222
|
-
filterGranularity =
|
|
223
|
-
.filter((join) => filterExpression.includes(join[0] + '.'))
|
|
224
|
-
.map((join) => join[1])
|
|
264
|
+
filterGranularity = getExpressionGranularity(filterExpression)
|
|
225
265
|
}
|
|
226
266
|
// Empty aggregation expression = no groupers → only the totals row
|
|
227
267
|
const totalsOnly = !aggregationExpression
|
|
228
268
|
const aggregationEvaluator = totalsOnly ? undefined : buildAggregationEvaluator(aggregationExpression)
|
|
229
|
-
const aggregationGranularity = totalsOnly
|
|
230
|
-
? []
|
|
231
|
-
: Object.entries(globalJoinableTables)
|
|
232
|
-
.filter((join) => aggregationExpression.includes(join[0] + '.'))
|
|
233
|
-
.map((join) => join[1])
|
|
269
|
+
const aggregationGranularity = totalsOnly ? [] : getExpressionGranularity(aggregationExpression)
|
|
234
270
|
const aggregationTrieRoot: AggregateKeyTrieNode = {}
|
|
235
271
|
|
|
272
|
+
let nrNewRows = 0 // tracks the # rows added so we can quit computing if it grows out of bounds
|
|
273
|
+
let totalVector: Float64Array | undefined
|
|
274
|
+
|
|
275
|
+
// What the current transaction adds to an aggregation row: computed once, added to its row, subtotal and total
|
|
276
|
+
const slots = new Int32Array(MAX_CONTRIBUTIONS)
|
|
277
|
+
const values = new Float64Array(MAX_CONTRIBUTIONS)
|
|
278
|
+
let nrContributions = 0
|
|
279
|
+
|
|
280
|
+
// NaN (a missing qty or price) counts as 0
|
|
281
|
+
function contribute(slot: number, value: number): void {
|
|
282
|
+
slots[nrContributions] = slot
|
|
283
|
+
values[nrContributions++] = value || 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function contributeStockTime(qty: number, amount: number, timeLeft: number): void {
|
|
287
|
+
slots[nrContributions] = STOCK_TIME_PRODUCT
|
|
288
|
+
values[nrContributions++] = qty * timeLeft
|
|
289
|
+
slots[nrContributions] = STOCKAMOUNT_TIME_PRODUCT
|
|
290
|
+
values[nrContributions++] = amount * timeLeft
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Same numbers as transaction.buildVector, without building a vector per transaction
|
|
294
|
+
function prepareContributions(transaction: TransactionI): void {
|
|
295
|
+
nrContributions = 0
|
|
296
|
+
const time = transaction.time
|
|
297
|
+
const type = typeof transaction.type == 'string' ? transaction.type : String(transaction.type)
|
|
298
|
+
const qty = +transaction.qty
|
|
299
|
+
|
|
300
|
+
if (time <= beginTimestamp) {
|
|
301
|
+
// Before the period: begin stock, plus outstanding picklists/consignments so endShelf remains stable
|
|
302
|
+
// when only the start date changes for a fixed end date.
|
|
303
|
+
switch (type) {
|
|
304
|
+
case transactionTypeE.RECEIVING:
|
|
305
|
+
case transactionTypeE.TRANSIT:
|
|
306
|
+
case transactionTypeE.CHANGE:
|
|
307
|
+
case transactionTypeE.START_STOCK:
|
|
308
|
+
case transactionTypeE.RETURN_CUSTOMER: {
|
|
309
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
310
|
+
contribute(QTY_STOCK, qty)
|
|
311
|
+
contribute(AMOUNT_STOCK, cost)
|
|
312
|
+
contribute(QTY_SHELF_STOCK, qty)
|
|
313
|
+
contribute(AMOUNT_SHELF_STOCK, cost)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
case transactionTypeE.SALE:
|
|
317
|
+
case transactionTypeE.B2B_SOLD:
|
|
318
|
+
case transactionTypeE.B2B_RE_SOLD: {
|
|
319
|
+
const cost = round2(-transaction.buyprice * transaction.qty)
|
|
320
|
+
contribute(QTY_STOCK, -qty)
|
|
321
|
+
contribute(AMOUNT_STOCK, cost)
|
|
322
|
+
contribute(QTY_SHELF_STOCK, -qty)
|
|
323
|
+
contribute(AMOUNT_SHELF_STOCK, cost)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
case transactionTypeE.RE_VALUATION: {
|
|
327
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
328
|
+
contribute(AMOUNT_STOCK, cost)
|
|
329
|
+
contribute(AMOUNT_SHELF_STOCK, cost)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
case transactionTypeE.START_SHELF_STOCK:
|
|
333
|
+
// Type 10 subtracts outstanding picktickets of previous shards from the shelf stock
|
|
334
|
+
contribute(QTY_SHELF_STOCK, qty)
|
|
335
|
+
contribute(AMOUNT_SHELF_STOCK, round2(transaction.buyprice * transaction.qty))
|
|
336
|
+
return
|
|
337
|
+
case transactionTypeE.PICK_LIST:
|
|
338
|
+
contribute(QTY_PICKLIST, qty)
|
|
339
|
+
contribute(AMOUNT_PICKLIST, round2(transaction.sellprice * transaction.qty))
|
|
340
|
+
contribute(COSTPRICE_PICKLIST, round2(transaction.buyprice * transaction.qty))
|
|
341
|
+
return
|
|
342
|
+
case transactionTypeE.CONSIGNMENT:
|
|
343
|
+
contribute(QTY_CONSIGNMENT, qty)
|
|
344
|
+
contribute(AMOUNT_CONSIGNMENT, round2(transaction.sellprice * transaction.qty))
|
|
345
|
+
contribute(COSTPRICE_CONSIGNMENT, round2(transaction.buyprice * transaction.qty))
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
if (time > endTimestamp) return
|
|
351
|
+
|
|
352
|
+
// Period transactions
|
|
353
|
+
const timeLeft = endTimestamp - time
|
|
354
|
+
switch (type) {
|
|
355
|
+
case transactionTypeE.SALE: {
|
|
356
|
+
const sku = globalRelations['sku'][transaction.ean]
|
|
357
|
+
const amountSoldExcl = round2((transaction.sellprice / ((100 + (transaction.vat || 0)) / 100)) * transaction.qty)
|
|
358
|
+
const costprice = round2(transaction.buyprice * transaction.qty)
|
|
359
|
+
const amountSoldMax = round2(((sku?.price || 0) / ((100 + (transaction.vat || sku?.vat || 21)) / 100)) * qty)
|
|
360
|
+
contribute(QTY_SOLD, qty)
|
|
361
|
+
contribute(AMOUNT_SOLD, round2(transaction.sellprice * transaction.qty))
|
|
362
|
+
contribute(AMOUNT_SOLD_EXCL, amountSoldExcl)
|
|
363
|
+
contribute(COSTPRICE_SOLD, costprice)
|
|
364
|
+
contribute(AMOUNT_SOLD_MAX, amountSoldMax)
|
|
365
|
+
contribute(AMOUNT_SOLD_DISCOUNT, round2(amountSoldMax - amountSoldExcl))
|
|
366
|
+
if (qty < 0) contribute(QTY_RETURN, qty)
|
|
367
|
+
if (amountSoldExcl < 0) contribute(AMOUNT_RETURN, amountSoldExcl)
|
|
368
|
+
contributeStockTime(-(qty || 0), -(costprice || 0), timeLeft)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
case transactionTypeE.RECEIVING: {
|
|
372
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
373
|
+
contribute(QTY_RECIEVED, qty)
|
|
374
|
+
contribute(AMOUNT_RECIEVED, cost)
|
|
375
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft)
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
case transactionTypeE.TRANSIT: {
|
|
379
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
380
|
+
contribute(QTY_TRANSIT, qty)
|
|
381
|
+
contribute(AMOUNT_TRANSIT, cost)
|
|
382
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
case transactionTypeE.CHANGE:
|
|
386
|
+
case transactionTypeE.START_STOCK: {
|
|
387
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
388
|
+
contribute(QTY_CHANGE, qty)
|
|
389
|
+
contribute(AMOUNT_CHANGE, cost)
|
|
390
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
case transactionTypeE.B2B_SOLD: {
|
|
394
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
395
|
+
contribute(QTY_B2B_SOLD, qty)
|
|
396
|
+
contribute(AMOUNT_B2B_SOLD, round2(transaction.sellprice * transaction.qty))
|
|
397
|
+
contribute(COSTPRICE_B2B_SOLD, cost)
|
|
398
|
+
contributeStockTime(-(qty || 0), -(cost || 0), timeLeft)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
case transactionTypeE.B2B_RE_SOLD: {
|
|
402
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
403
|
+
contribute(QTY_B2B_RESOLD, qty)
|
|
404
|
+
contribute(AMOUNT_B2B_RESOLD, round2(transaction.sellprice * transaction.qty))
|
|
405
|
+
contribute(COSTPRICE_B2B_RESOLD, cost)
|
|
406
|
+
contributeStockTime(-(qty || 0), -(cost || 0), timeLeft)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
case transactionTypeE.RETURN_CUSTOMER: {
|
|
410
|
+
const cost = round2(transaction.buyprice * transaction.qty)
|
|
411
|
+
contribute(QTY_B2B_RETURN, qty)
|
|
412
|
+
contribute(AMOUNT_B2B_RETURN, round2(transaction.sellprice * transaction.qty))
|
|
413
|
+
contribute(COSTPRICE_B2B_RETURN, cost)
|
|
414
|
+
contributeStockTime(qty || 0, cost || 0, timeLeft)
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
case transactionTypeE.CREDIT_INVOICE:
|
|
418
|
+
contribute(AMOUNT_B2B_RETURN, round2(transaction.sellprice * transaction.qty))
|
|
419
|
+
return
|
|
420
|
+
case transactionTypeE.RE_VALUATION:
|
|
421
|
+
contribute(AMOUNT_REVALUATE, round2(transaction.buyprice * transaction.qty))
|
|
422
|
+
return
|
|
423
|
+
case transactionTypeE.PURCHASE_ORDER:
|
|
424
|
+
contribute(QTY_PO, qty)
|
|
425
|
+
contribute(AMOUNT_PO, round2(transaction.buyprice * transaction.qty))
|
|
426
|
+
return
|
|
427
|
+
case transactionTypeE.PURCHASE_ORDER_COMPLETE:
|
|
428
|
+
contribute(QTY_PO_COMPLETE, qty)
|
|
429
|
+
contribute(AMOUNT_PO_COMPLETE, round2(transaction.buyprice * transaction.qty))
|
|
430
|
+
return
|
|
431
|
+
case transactionTypeE.MINIMUM_STOCK:
|
|
432
|
+
contribute(QTY_MINIMUM_STOCK, qty)
|
|
433
|
+
contribute(AMOUNT_MINIMUM_STOCK, round2(transaction.buyprice * transaction.qty))
|
|
434
|
+
return
|
|
435
|
+
case transactionTypeE.ORDER:
|
|
436
|
+
contribute(QTY_ORDER, qty)
|
|
437
|
+
contribute(AMOUNT_ORDER, round2(transaction.sellprice * transaction.qty))
|
|
438
|
+
contribute(COSTPRICE_ORDER, round2(transaction.buyprice * transaction.qty))
|
|
439
|
+
return
|
|
440
|
+
case transactionTypeE.RE_ORDER:
|
|
441
|
+
contribute(QTY_REORDER, qty)
|
|
442
|
+
contribute(AMOUNT_REORDER, round2(transaction.sellprice * transaction.qty))
|
|
443
|
+
contribute(COSTPRICE_REORDER, round2(transaction.buyprice * transaction.qty))
|
|
444
|
+
return
|
|
445
|
+
case transactionTypeE.PICK_LIST:
|
|
446
|
+
// Picklists reduce shelfStock, which is handled by subtracting QTY_PICKLIST in postAgg
|
|
447
|
+
contribute(QTY_PICKLIST, qty)
|
|
448
|
+
contribute(AMOUNT_PICKLIST, round2(transaction.sellprice * transaction.qty))
|
|
449
|
+
contribute(COSTPRICE_PICKLIST, round2(transaction.buyprice * transaction.qty))
|
|
450
|
+
return
|
|
451
|
+
case transactionTypeE.CONSIGNMENT:
|
|
452
|
+
// Consignments reduce shelfStock, which is handled by subtracting QTY_CONSIGNMENT in postAgg
|
|
453
|
+
contribute(QTY_CONSIGNMENT, qty)
|
|
454
|
+
contribute(AMOUNT_CONSIGNMENT, round2(transaction.sellprice * transaction.qty))
|
|
455
|
+
contribute(COSTPRICE_CONSIGNMENT, round2(transaction.buyprice * transaction.qty))
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Deprecated schema: derived sale fields live in the stored vector
|
|
461
|
+
function prepareStoredVector(transaction: TransactionI): void {
|
|
462
|
+
if (transaction.type != transactionTypeE.SALE) return
|
|
463
|
+
const sku = globalRelations['sku'][transaction.ean]
|
|
464
|
+
if (Array.isArray(transaction.vector)) {
|
|
465
|
+
const tmp = new Float64Array(AMOUNT_SOLD_DISCOUNT + 1)
|
|
466
|
+
tmp.set(transaction.vector)
|
|
467
|
+
transaction.vector = tmp
|
|
468
|
+
}
|
|
469
|
+
const v = transaction.vector
|
|
470
|
+
v[AMOUNT_SOLD_MAX] = round2(((sku?.price || 0) / ((100 + (transaction.vat || sku?.vat || 21)) / 100)) * v[QTY_SOLD])
|
|
471
|
+
v[AMOUNT_SOLD_DISCOUNT] = round2(v[AMOUNT_SOLD_MAX] - v[AMOUNT_SOLD_EXCL])
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function getAggregateVector(aggregateKey: string): Float64Array {
|
|
475
|
+
let aggregateVector = rawAggregations[aggregateKey]
|
|
476
|
+
if (!aggregateVector) {
|
|
477
|
+
nrNewRows++
|
|
478
|
+
aggregateVector = new Float64Array(VECTOR_LENGTH)
|
|
479
|
+
rawAggregations[aggregateKey] = aggregateVector
|
|
480
|
+
}
|
|
481
|
+
return aggregateVector
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function addTransactionToVector(aggregateVector: Float64Array, transaction: TransactionI): void {
|
|
485
|
+
if (transaction.vector) return addStoredVector(beginTimestamp, endTimestamp, aggregateVector, transaction)
|
|
486
|
+
if (transaction.type == transactionTypeE.RECEIVING && transaction.time > aggregateVector[MAX_RECIEVE_TIMESTAMP]) aggregateVector[MAX_RECIEVE_TIMESTAMP] = transaction.time
|
|
487
|
+
for (let i = 0; i < nrContributions; i++) aggregateVector[slots[i]] += values[i]
|
|
488
|
+
}
|
|
489
|
+
|
|
236
490
|
return function (transaction: TransactionI): void {
|
|
237
491
|
if (filter) {
|
|
238
492
|
const filterLeaf = getTrieLeaf<FilterResultTrieNode>(filterTrieRoot, transaction, filterGranularity)
|
|
@@ -242,21 +496,13 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
|
|
|
242
496
|
if (filterLeaf.filterResult === -1) return
|
|
243
497
|
}
|
|
244
498
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const sku = globalRelations['sku'][transaction.ean]
|
|
248
|
-
if (!transaction.vector) transaction.vector = transactionFunctions.buildVector(transaction)
|
|
249
|
-
if (Array.isArray(transaction.vector)) {
|
|
250
|
-
const tmp = new Float64Array(AMOUNT_SOLD_DISCOUNT + 1)
|
|
251
|
-
tmp.set(transaction.vector)
|
|
252
|
-
transaction.vector = tmp
|
|
253
|
-
}
|
|
254
|
-
const v = transaction.vector
|
|
255
|
-
v[AMOUNT_SOLD_MAX] = round2(((sku?.price || 0) / ((100 + (transaction.vat || sku?.vat || 21)) / 100)) * v[QTY_SOLD])
|
|
256
|
-
v[AMOUNT_SOLD_DISCOUNT] = round2(v[AMOUNT_SOLD_MAX] - v[AMOUNT_SOLD_EXCL])
|
|
257
|
-
}
|
|
499
|
+
if (transaction.vector) prepareStoredVector(transaction)
|
|
500
|
+
else prepareContributions(transaction)
|
|
258
501
|
|
|
259
|
-
if (totals || totalsOnly)
|
|
502
|
+
if ((totals || totalsOnly) && nrNewRows < maxRows) {
|
|
503
|
+
if (!totalVector) totalVector = getAggregateVector(TOTAL_KEY)
|
|
504
|
+
addTransactionToVector(totalVector, transaction)
|
|
505
|
+
}
|
|
260
506
|
|
|
261
507
|
// No detail/subtotal rows when there are no groupers
|
|
262
508
|
if (totalsOnly) return
|
|
@@ -271,21 +517,18 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
|
|
|
271
517
|
// Example: for 4 groupers we have rows like
|
|
272
518
|
// g0 g1 g2 g3 <- normal row with no subtotal
|
|
273
519
|
// g0 <- subtotal for g0
|
|
274
|
-
if (subtotalsForOuterGrouper == true) {
|
|
275
|
-
|
|
520
|
+
if (subtotalsForOuterGrouper == true && nrNewRows < maxRows) {
|
|
521
|
+
if (!aggregateKeyLeaf.subtotalVector) aggregateKeyLeaf.subtotalVector = getAggregateVector(`${aggregateKey.substr(0, aggregateKey.indexOf('\t'))} Σ`)
|
|
522
|
+
addTransactionToVector(aggregateKeyLeaf.subtotalVector, transaction)
|
|
276
523
|
}
|
|
277
524
|
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
} else {
|
|
282
|
-
addTransactionToAggregations(beginTimestamp, endTimestamp, aggregateKey, rawAggregations, transaction, maxRows)
|
|
283
|
-
}
|
|
525
|
+
if (nrNewRows >= maxRows) return
|
|
526
|
+
if (!aggregateKeyLeaf.vector) aggregateKeyLeaf.vector = getAggregateVector(parentGroupTotals ? prepareSubTotalAggKey(aggregateKey) : aggregateKey)
|
|
527
|
+
addTransactionToVector(aggregateKeyLeaf.vector, transaction)
|
|
284
528
|
}
|
|
285
529
|
}
|
|
286
530
|
|
|
287
531
|
function aggregate({ beginTimestamp, endTimestamp, filterExpression, aggregationExpression, transactions, beginStock, relations, rawAggregations, maxRows, totals, subtotalsForOuterGrouper, parentGroupTotals }: AggregateFnI) {
|
|
288
|
-
globalNrAggegationExpressions = 0
|
|
289
532
|
globalRelations = relations
|
|
290
533
|
|
|
291
534
|
const compiledAggregator = aggregator({
|
|
@@ -532,6 +532,87 @@ describe('articleStatus', () => {
|
|
|
532
532
|
expect(lateStart[articleStatus.QTY_END_SHELF_STOCK]).toBe(9)
|
|
533
533
|
})
|
|
534
534
|
|
|
535
|
+
describe('transaction fields in expressions', () => {
|
|
536
|
+
const timeframe = { beginTimeStamp: 1000, endTimeStamp: 9000 }
|
|
537
|
+
// Document snapshots carry no row number
|
|
538
|
+
const purchaseOrders = () => [
|
|
539
|
+
{ time: 2000, type: '14', ean: 'A', wh: '50', qty: 1, buyprice: 10, docnr: 'PO-A' },
|
|
540
|
+
{ time: 2000, type: '14', ean: 'A', wh: '50', qty: 2, buyprice: 10, docnr: 'PO-B' },
|
|
541
|
+
{ time: 2000, type: '14', ean: 'B', wh: '50', qty: 4, buyprice: 10, docnr: 'PO-B' },
|
|
542
|
+
]
|
|
543
|
+
const query = (groups, filter) => articleStatus.runQuery(timeframe, {}, false, purchaseOrders(), groups, { A: {}, B: {} }, {}, {}, false, filter, false)
|
|
544
|
+
|
|
545
|
+
it('should group transactions without a row number by a transaction field', () => {
|
|
546
|
+
const rawAggregations = query(['transaction.docnr'], '')
|
|
547
|
+
expect(Object.keys(rawAggregations)).toEqual(['** TOTAL **', 'PO-A', 'PO-B'])
|
|
548
|
+
expect(rawAggregations['PO-A'][articleStatus.QTY_PO]).toBe(1)
|
|
549
|
+
expect(rawAggregations['PO-B'][articleStatus.QTY_PO]).toBe(6)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('should filter transactions without a row number by a transaction field', () => {
|
|
553
|
+
const rawAggregations = query([], 'transaction.docnr == "PO-B"')
|
|
554
|
+
expect(rawAggregations['** TOTAL **'][articleStatus.QTY_PO]).toBe(6)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('should filter by a field that is not prefixed with transaction.', () => {
|
|
558
|
+
const rawAggregations = query([], 'qty > 1 and lower(docnr) == "po-b"')
|
|
559
|
+
expect(rawAggregations['** TOTAL **'][articleStatus.QTY_PO]).toBe(6)
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
describe('transaction schemas', () => {
|
|
564
|
+
const begin = 10000
|
|
565
|
+
const end = 20000
|
|
566
|
+
const timeframe = { beginTimeStamp: begin, endTimeStamp: end }
|
|
567
|
+
const types = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '10', '14', '15', '18', '92', '93', '94', '95', '96', '97', '98']
|
|
568
|
+
const skus = { A: { brand: 'a', price: 49.95, vat: 21 }, B: { brand: 'b' } }
|
|
569
|
+
const buildTransactions = () => {
|
|
570
|
+
const transactions = []
|
|
571
|
+
types.forEach((type, t) => {
|
|
572
|
+
// before the period, on its begin, in the period, on its end and after the period
|
|
573
|
+
;[begin - 500, begin, begin + 2500, end, end + 500].forEach((time, i) => {
|
|
574
|
+
;[3, -2].forEach((qty) => {
|
|
575
|
+
transactions.push({ time, type, ean: i % 2 ? 'A' : 'B', wh: '50', qty, buyprice: 12.345 + t, sellprice: 29.95 + i, vat: i % 3 ? 21 : undefined, row: transactions.length })
|
|
576
|
+
})
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
return transactions
|
|
580
|
+
}
|
|
581
|
+
const query = (transactions) => articleStatus.runQuery(timeframe, {}, true, transactions, ['sku.brand', 'transaction.type'], skus, {}, {}, true, '', false)
|
|
582
|
+
|
|
583
|
+
it('should not add a vector to the transactions', () => {
|
|
584
|
+
const transactions = buildTransactions()
|
|
585
|
+
query(transactions)
|
|
586
|
+
expect(transactions.some((transaction) => 'vector' in transaction)).toBe(false)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('should give the same result for the deprecated stored vector as for qty and prices', () => {
|
|
590
|
+
const expected = query(buildTransactions())
|
|
591
|
+
const storedVector = (transaction) => Array.from(core.transaction.buildVector(transaction))
|
|
592
|
+
const withVector = buildTransactions().map((transaction) => ({ ...transaction, vector: storedVector(transaction) }))
|
|
593
|
+
const vectorOnly = buildTransactions().map(({ qty, buyprice, sellprice, ...transaction }) => ({ ...transaction, vector: storedVector({ ...transaction, qty, buyprice, sellprice }) }))
|
|
594
|
+
const mixed = buildTransactions().map((transaction, i) => (i % 2 ? transaction : { ...transaction, vector: storedVector(transaction) }))
|
|
595
|
+
|
|
596
|
+
expect(Object.keys(expected).length).toBe(1 + 2 + 2 * types.length)
|
|
597
|
+
expect(expected['a\t2'][articleStatus.QTY_SOLD]).not.toBe(0)
|
|
598
|
+
expect(query(withVector)).toEqual(expected)
|
|
599
|
+
expect(query(vectorOnly)).toEqual(expected)
|
|
600
|
+
expect(query(mixed)).toEqual(expected)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('should count a missing qty or price as 0', () => {
|
|
604
|
+
const transactions = [
|
|
605
|
+
{ time: begin + 1, type: '1', ean: 'A', wh: '50', qty: 2, buyprice: 10 },
|
|
606
|
+
{ time: begin + 1, type: '1', ean: 'A', wh: '50', buyprice: 10 },
|
|
607
|
+
{ time: begin + 1, type: '1', ean: 'A', wh: '50', qty: 3 },
|
|
608
|
+
]
|
|
609
|
+
const total = articleStatus.runQuery(timeframe, {}, true, transactions, [], skus, {}, {}, false, '', false)['** TOTAL **']
|
|
610
|
+
expect(total[articleStatus.QTY_RECIEVED]).toBe(5)
|
|
611
|
+
expect(total[articleStatus.AMOUNT_RECIEVED]).toBe(20)
|
|
612
|
+
expect(total[articleStatus.STOCK_TIME_PRODUCT]).toBe(5 * (end - begin - 1))
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
535
616
|
it('#getDateRangeFromPicker', () => {
|
|
536
617
|
const dateRange = articleStatus.getDateRangeFromPicker(['2024-01-01', '2024-12-31'])
|
|
537
618
|
expect(dateRange.beginTimeStamp).toBe(1704063600000)
|