@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.
@@ -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', transaction: 'row' } // defines the keys we can join on
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
- function propFunction(propertyName: string, getPropertyName: Function, obj: Record<string, any>): any {
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
- if (positionOfDot < 0) return obj[propertyName]
107
- const table = propertyName.substring(0, positionOfDot)
108
- const path = propertyName.substring(positionOfDot + 1).split('.')
109
- if (table == 'transaction') return resolvePath(obj, path)
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
- // This is how the actual joining of 2 vectors happens
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
- // This function takes a transaction and adds it to the aggregations
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
- type AggregateKeyTrieNode = { aggregateKey?: string; children?: Map<unknown, AggregateKeyTrieNode> }
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 = Object.entries(globalJoinableTables)
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
- // Prepare joined values for derived fields
246
- if (transaction.type == transactionTypeE.SALE) {
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) addTransactionToAggregations(beginTimestamp, endTimestamp, '** TOTAL **', rawAggregations, transaction, maxRows)
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
- addTransactionToAggregations(beginTimestamp, endTimestamp, `${aggregateKey.substr(0, aggregateKey.indexOf('\t'))} Σ`, rawAggregations, transaction, maxRows)
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 (parentGroupTotals) {
279
- const KPIKey = prepareSubTotalAggKey(aggregateKey)
280
- addTransactionToAggregations(beginTimestamp, endTimestamp, KPIKey, rawAggregations, transaction, maxRows)
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)