@softwear/latestcollectioncore 1.0.205 → 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.d.ts +11 -0
- package/dist/barcodeCanvas.js +61 -0
- package/dist/pdf.d.ts +10 -2
- package/dist/reports.js +25 -2
- package/package.json +2 -1
- package/src/articleStatus.ts +294 -51
- package/src/barcodeCanvas.ts +59 -0
- package/src/pdf.ts +11 -2
- package/src/reports.ts +23 -1
- package/test/articleStatus.spec.js +81 -0
- package/test/reports.spec.ts +78 -0
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({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import JsBarcodeImport from 'jsbarcode'
|
|
2
|
+
|
|
3
|
+
export interface BarcodeCanvasResult {
|
|
4
|
+
dataUrl: string
|
|
5
|
+
width: number
|
|
6
|
+
height: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Vite / CJS interop sometimes wraps the function as `{ default: fn }`. */
|
|
10
|
+
function resolveJsBarcode(): ((element: unknown, value: string, options?: object) => void) | null {
|
|
11
|
+
const mod: any = JsBarcodeImport
|
|
12
|
+
if (typeof mod === 'function') return mod
|
|
13
|
+
if (mod && typeof mod.default === 'function') return mod.default
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createCanvasElement(): HTMLCanvasElement | null {
|
|
18
|
+
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
|
19
|
+
return document.createElement('canvas')
|
|
20
|
+
}
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a barcode to a PNG data URL.
|
|
26
|
+
* Module width is fixed; the caller must scale the image uniformly into the layout box
|
|
27
|
+
* so bars are not distorted.
|
|
28
|
+
*/
|
|
29
|
+
export function renderBarcodeToDataUrl(value: string): BarcodeCanvasResult | null {
|
|
30
|
+
if (!value) return null
|
|
31
|
+
const canvas = createCanvasElement()
|
|
32
|
+
if (!canvas) return null
|
|
33
|
+
|
|
34
|
+
const JsBarcode = resolveJsBarcode()
|
|
35
|
+
if (!JsBarcode) return null
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
JsBarcode(canvas, value, {
|
|
39
|
+
// Fixed module geometry — do not stretch independently in X/Y when placing on the page.
|
|
40
|
+
// CODE128 accepts arbitrary values.
|
|
41
|
+
format: 'CODE128',
|
|
42
|
+
width: 2,
|
|
43
|
+
height: 60,
|
|
44
|
+
displayValue: false,
|
|
45
|
+
margin: 0,
|
|
46
|
+
})
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (typeof console !== 'undefined') console.warn('Barcode render failed', value, e)
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!canvas.width || !canvas.height) return null
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
dataUrl: canvas.toDataURL('image/png'),
|
|
56
|
+
width: canvas.width,
|
|
57
|
+
height: canvas.height,
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/pdf.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface PaperSize {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export type TextAlign = 1 | 2 | 3 // 1: left, 2: center, 3: right
|
|
10
|
-
export type ObjectType = 'text' | 'field' | 'rectangle' | 'image' | 'container'
|
|
10
|
+
export type ObjectType = 'text' | 'field' | 'rectangle' | 'image' | 'container' | 'barcode'
|
|
11
11
|
export type RepeatContainer = 'horizontal' | 'vertical' | undefined
|
|
12
12
|
|
|
13
13
|
export interface BaseLayoutObject {
|
|
@@ -52,6 +52,15 @@ export interface ImageLayoutObject extends BaseLayoutObject {
|
|
|
52
52
|
image?: HTMLImageElement
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Renders a 1D barcode from printBuffer via `source` (default `barcode`).
|
|
57
|
+
* Drawn with uniform scale to fit width×height (never independently stretched).
|
|
58
|
+
*/
|
|
59
|
+
export interface BarcodeLayoutObject extends BaseLayoutObject {
|
|
60
|
+
type: 'barcode'
|
|
61
|
+
source?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
export interface ContainerLayoutObject extends BaseLayoutObject {
|
|
56
65
|
type: 'container'
|
|
57
66
|
source: string
|
|
@@ -70,7 +79,7 @@ export interface ContainerLayoutObject extends BaseLayoutObject {
|
|
|
70
79
|
printOnlyAtStart?: boolean
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
export type LayoutObject = TextLayoutObject | FieldLayoutObject | RectangleLayoutObject | ImageLayoutObject | ContainerLayoutObject
|
|
82
|
+
export type LayoutObject = TextLayoutObject | FieldLayoutObject | RectangleLayoutObject | ImageLayoutObject | BarcodeLayoutObject | ContainerLayoutObject
|
|
74
83
|
|
|
75
84
|
export interface Layout {
|
|
76
85
|
name?: string
|
package/src/reports.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsPDF } from 'jspdf'
|
|
2
2
|
import { format } from 'date-fns'
|
|
3
|
+
import { renderBarcodeToDataUrl } from './barcodeCanvas'
|
|
3
4
|
import deepCopy from './deepCopy'
|
|
4
5
|
import { resolveLayoutFieldText } from './layoutFieldFormat'
|
|
5
|
-
import { PaperSize, Layout, LayoutObject, ContainerLayoutObject } from './pdf'
|
|
6
|
+
import { PaperSize, Layout, LayoutObject, ContainerLayoutObject, BarcodeLayoutObject } from './pdf'
|
|
6
7
|
|
|
7
8
|
type RenderMode = 'measurement' | 'rendering'
|
|
8
9
|
|
|
@@ -453,6 +454,27 @@ function drawSimpleObject(x: number, y: number, doc: jsPDF, object: LayoutObject
|
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
456
|
|
|
457
|
+
// Draw barcode for barcode objects (uniform scale into the layout box — never stretch bars)
|
|
458
|
+
if (object.type == 'barcode') {
|
|
459
|
+
const barcodeObj = object as BarcodeLayoutObject
|
|
460
|
+
const source = barcodeObj.source || 'barcode'
|
|
461
|
+
const raw = getProperty(source, printBuffer)
|
|
462
|
+
const value = raw == null ? '' : String(raw)
|
|
463
|
+
if (!value) {
|
|
464
|
+
options.onAlert?.({ header: 'fieldnotfound', body: source, type: 'warning', timeout: 10000 })
|
|
465
|
+
} else {
|
|
466
|
+
const rendered = renderBarcodeToDataUrl(value)
|
|
467
|
+
if (rendered) {
|
|
468
|
+
const { x: drawX, y: drawY, width: drawWidth, height: drawHeight } = computeImageDrawRect(x, y, width, height, rendered.width, rendered.height)
|
|
469
|
+
try {
|
|
470
|
+
doc.addImage(rendered.dataUrl, 'PNG', drawX, drawY, drawWidth, drawHeight)
|
|
471
|
+
} catch (_e) {
|
|
472
|
+
// Skip if jsPDF cannot embed the canvas data URL
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
456
478
|
// Draw text for text and field objets
|
|
457
479
|
if ((object.type == 'text' || object.type == 'field') && text) {
|
|
458
480
|
let textAlign: 'left' | 'center' | 'right' | undefined
|
|
@@ -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)
|