@softwear/latestcollectioncore 1.0.198 → 1.0.201

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.
@@ -159,10 +159,14 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
159
159
  .filter((join) => filterExpression.includes(join[0] + '.'))
160
160
  .map((join) => join[1]);
161
161
  }
162
- const aggregationEvaluator = buildAggregationEvaluator(aggregationExpression);
163
- const aggregationGranularity = Object.entries(globalJoinableTables)
164
- .filter((join) => aggregationExpression.includes(join[0] + '.'))
165
- .map((join) => join[1]);
162
+ // Empty aggregation expression = no groupers → only the totals row
163
+ const totalsOnly = !aggregationExpression;
164
+ const aggregationEvaluator = totalsOnly ? undefined : buildAggregationEvaluator(aggregationExpression);
165
+ const aggregationGranularity = totalsOnly
166
+ ? []
167
+ : Object.entries(globalJoinableTables)
168
+ .filter((join) => aggregationExpression.includes(join[0] + '.'))
169
+ .map((join) => join[1]);
166
170
  const aggregationTrieRoot = {};
167
171
  return function (transaction) {
168
172
  if (filter) {
@@ -175,11 +179,6 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
175
179
  return;
176
180
  }
177
181
  }
178
- const aggregateKeyLeaf = getTrieLeaf(aggregationTrieRoot, transaction, aggregationGranularity);
179
- if (!aggregateKeyLeaf.aggregateKey) {
180
- aggregateKeyLeaf.aggregateKey = aggregationEvaluator(transaction);
181
- }
182
- const aggregateKey = aggregateKeyLeaf.aggregateKey;
183
182
  // Prepare joined values for derived fields
184
183
  if (transaction.type == types_1.transactionTypeE.SALE) {
185
184
  const sku = globalRelations['sku'][transaction.ean];
@@ -194,8 +193,16 @@ function aggregator({ beginTimestamp, endTimestamp, filterExpression, aggregatio
194
193
  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]);
195
194
  v[AMOUNT_SOLD_DISCOUNT] = (0, index_1.round2)(v[AMOUNT_SOLD_MAX] - v[AMOUNT_SOLD_EXCL]);
196
195
  }
197
- if (totals)
196
+ if (totals || totalsOnly)
198
197
  addTransactionToAggregations(beginTimestamp, endTimestamp, '** TOTAL **', rawAggregations, transaction, maxRows);
198
+ // No detail/subtotal rows when there are no groupers
199
+ if (totalsOnly)
200
+ return;
201
+ const aggregateKeyLeaf = getTrieLeaf(aggregationTrieRoot, transaction, aggregationGranularity);
202
+ if (!aggregateKeyLeaf.aggregateKey) {
203
+ aggregateKeyLeaf.aggregateKey = aggregationEvaluator(transaction);
204
+ }
205
+ const aggregateKey = aggregateKeyLeaf.aggregateKey;
199
206
  // Single subtotals are subtotals for first grouper level
200
207
  // Example: for 4 groupers we have rows like
201
208
  // g0 g1 g2 g3 <- normal row with no subtotal
@@ -357,6 +364,51 @@ function runQuery(timeframe, rawAggregations, beginStock, transactions, selected
357
364
  }
358
365
  return rawAggregations;
359
366
  }
367
+ /** True when the bound casts to an ISO instant. A calendar day or a stray "T" does not. */
368
+ function hasClockTime(raw) {
369
+ const value = String(raw).trim();
370
+ const cast = new Date(value);
371
+ if (Number.isNaN(cast.getTime()))
372
+ return false;
373
+ const iso = cast.toISOString();
374
+ // yyyy-MM-dd casts to UTC midnight, but the string is only that date prefix.
375
+ if (iso.slice(0, 10) === value)
376
+ return false;
377
+ // The time designator is the character after yyyy-MM-dd, not a stray "T".
378
+ return value.length > 10 && value[10] === 'T';
379
+ }
380
+ /**
381
+ * Exact begin/end for two ISO instants. The end bound is that instant, not end-of-day.
382
+ */
383
+ function getDateRangeFromIsoInstant(inputDates) {
384
+ const dates = inputDates.slice();
385
+ dates.sort((a, b) => {
386
+ return ('' + a).localeCompare(b);
387
+ });
388
+ const fromDate = new Date(Date.parse(dates[0]));
389
+ const toDate = new Date(Date.parse(dates[1]));
390
+ const beginTimeStamp = fromDate.getTime();
391
+ const endTimeStamp = toDate.getTime();
392
+ const timeFrame = endTimeStamp - beginTimeStamp;
393
+ return {
394
+ beginTimeStamp,
395
+ endTimeStamp,
396
+ timeFrame,
397
+ utcFromYear: fromDate.getUTCFullYear(),
398
+ utcToYear: toDate.getUTCFullYear(),
399
+ utcFromMonth: fromDate.getUTCMonth() + 1,
400
+ utcToMonth: toDate.getUTCMonth() + 1,
401
+ fromYear: parseInt((0, date_fns_1.format)(fromDate, 'yyyy')),
402
+ toYear: parseInt((0, date_fns_1.format)(toDate, 'yyyy')),
403
+ fromMonth: parseInt((0, date_fns_1.format)(fromDate, 'MM')),
404
+ toMonth: parseInt((0, date_fns_1.format)(toDate, 'MM')),
405
+ hrYear: (0, date_fns_1.format)(fromDate, 'yyyy'),
406
+ hrQuarter: (0, date_fns_1.format)(fromDate, 'yyyy-QQQ'),
407
+ hrMonth: (0, date_fns_1.format)(fromDate, 'yyyy-MM'),
408
+ hrWeek: (0, date_fns_1.format)(fromDate, 'RRRR-II'),
409
+ hrDay: (0, date_fns_1.format)(fromDate, 'yyyy-MM-dd'),
410
+ };
411
+ }
360
412
  /**
361
413
  * @param inputDates array of two YYYY-MM-DD strings
362
414
  */
@@ -380,6 +432,8 @@ const getDateRangeFromPicker = function (inputDates) {
380
432
  hrWeek: '',
381
433
  hrDay: '',
382
434
  };
435
+ if (hasClockTime(inputDates[0]) || hasClockTime(inputDates[1]))
436
+ return getDateRangeFromIsoInstant(inputDates);
383
437
  const dates = inputDates.slice();
384
438
  dates.sort((a, b) => {
385
439
  return ('' + a).localeCompare(b);
@@ -432,6 +486,8 @@ const getDateRangeFromDatePair = function (inputDates) {
432
486
  hrWeek: '',
433
487
  hrDay: '',
434
488
  };
489
+ if (hasClockTime(inputDates[0]) || hasClockTime(inputDates[1]))
490
+ return getDateRangeFromIsoInstant(inputDates);
435
491
  const dates = inputDates.slice();
436
492
  dates.sort((a, b) => {
437
493
  return ('' + a).localeCompare(b);
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { default as hashBrand } from './hashBrand';
13
13
  export { default as hasOnlyDigits } from './hasOnlyDigits';
14
14
  export { default as imageBinder } from './imageBinder';
15
15
  export { default as isean13 } from './isean13';
16
+ export { default as parseScannedBarcode } from './parseScannedBarcode';
16
17
  export { default as pivotTable } from './pivotTable';
17
18
  export { default as projectObject } from './projectObject';
18
19
  export { default as reports, genPDF } from './reports';
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.isAxiosError = exports.exponentialDelay = exports.axiosRetry = exports.createAxiosInstance = exports.lcAxios = exports.shouldSuppressZeroField = exports.resolveLayoutFieldText = exports.isZeroFieldValue = exports.isCurrencyFieldFormat = exports.transaction = exports.sizeToMap = exports.round2 = exports.genPDF = exports.reports = exports.projectObject = exports.pivotTable = exports.isean13 = exports.imageBinder = exports.hasOnlyDigits = exports.hashBrand = exports.getPreferedPropertyMappings = exports.getBrandName = exports.findMetaBrandSetting = exports.findSkuByBarcode = exports.ensureImpliedProperties = exports.ensureArray = exports.edifact = exports.ean13 = exports.deepCopy = exports.buildPropertyMappingFn = exports.articleStatus = void 0;
20
+ exports.isAxiosError = exports.exponentialDelay = exports.axiosRetry = exports.createAxiosInstance = exports.lcAxios = exports.shouldSuppressZeroField = exports.resolveLayoutFieldText = exports.isZeroFieldValue = exports.isCurrencyFieldFormat = exports.transaction = exports.sizeToMap = exports.round2 = exports.genPDF = exports.reports = exports.projectObject = exports.pivotTable = exports.parseScannedBarcode = exports.isean13 = exports.imageBinder = exports.hasOnlyDigits = exports.hashBrand = exports.getPreferedPropertyMappings = exports.getBrandName = exports.findMetaBrandSetting = exports.findSkuByBarcode = exports.ensureImpliedProperties = exports.ensureArray = exports.edifact = exports.ean13 = exports.deepCopy = exports.buildPropertyMappingFn = exports.articleStatus = void 0;
21
21
  var articleStatus_1 = require("./articleStatus");
22
22
  Object.defineProperty(exports, "articleStatus", { enumerable: true, get: function () { return __importDefault(articleStatus_1).default; } });
23
23
  var buildPropertyMappingFn_1 = require("./buildPropertyMappingFn");
@@ -48,6 +48,8 @@ var imageBinder_1 = require("./imageBinder");
48
48
  Object.defineProperty(exports, "imageBinder", { enumerable: true, get: function () { return __importDefault(imageBinder_1).default; } });
49
49
  var isean13_1 = require("./isean13");
50
50
  Object.defineProperty(exports, "isean13", { enumerable: true, get: function () { return __importDefault(isean13_1).default; } });
51
+ var parseScannedBarcode_1 = require("./parseScannedBarcode");
52
+ Object.defineProperty(exports, "parseScannedBarcode", { enumerable: true, get: function () { return __importDefault(parseScannedBarcode_1).default; } });
51
53
  var pivotTable_1 = require("./pivotTable");
52
54
  Object.defineProperty(exports, "pivotTable", { enumerable: true, get: function () { return __importDefault(pivotTable_1).default; } });
53
55
  var projectObject_1 = require("./projectObject");
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Gate for scanner / typed barcode input.
3
+ * Strips a leading `#` (scanner prefix), then accepts only 12- or 13-digit strings.
4
+ * Does not validate the EAN check digit (isean13) and does not pad a leading 0 (findSkuByBarcode).
5
+ */
6
+ export default function parseScannedBarcode(raw: unknown): string | null;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const hasOnlyDigits_1 = __importDefault(require("./hasOnlyDigits"));
7
+ /**
8
+ * Gate for scanner / typed barcode input.
9
+ * Strips a leading `#` (scanner prefix), then accepts only 12- or 13-digit strings.
10
+ * Does not validate the EAN check digit (isean13) and does not pad a leading 0 (findSkuByBarcode).
11
+ */
12
+ function parseScannedBarcode(raw) {
13
+ if (typeof raw !== 'string')
14
+ return null;
15
+ const scan = raw.startsWith('#') ? raw.substring(1) : raw;
16
+ if (scan.length !== 12 && scan.length !== 13)
17
+ return null;
18
+ if (!(0, hasOnlyDigits_1.default)(scan))
19
+ return null;
20
+ return scan;
21
+ }
22
+ exports.default = parseScannedBarcode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softwear/latestcollectioncore",
3
- "version": "1.0.198",
3
+ "version": "1.0.201",
4
4
  "description": "Core functions for LatestCollections applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -242,10 +242,14 @@ function aggregator({
242
242
  .filter((join) => filterExpression.includes(join[0] + '.'))
243
243
  .map((join) => join[1])
244
244
  }
245
- const aggregationEvaluator = buildAggregationEvaluator(aggregationExpression)
246
- const aggregationGranularity = Object.entries(globalJoinableTables)
247
- .filter((join) => aggregationExpression.includes(join[0] + '.'))
248
- .map((join) => join[1])
245
+ // Empty aggregation expression = no groupers → only the totals row
246
+ const totalsOnly = !aggregationExpression
247
+ const aggregationEvaluator = totalsOnly ? undefined : buildAggregationEvaluator(aggregationExpression)
248
+ const aggregationGranularity = totalsOnly
249
+ ? []
250
+ : Object.entries(globalJoinableTables)
251
+ .filter((join) => aggregationExpression.includes(join[0] + '.'))
252
+ .map((join) => join[1])
249
253
  const aggregationTrieRoot: AggregateKeyTrieNode = {}
250
254
 
251
255
  return function (transaction: TransactionI): void {
@@ -257,11 +261,6 @@ function aggregator({
257
261
  if (filterLeaf.filterResult === -1) return
258
262
  }
259
263
  }
260
- const aggregateKeyLeaf = getTrieLeaf<AggregateKeyTrieNode>(aggregationTrieRoot, transaction, aggregationGranularity)
261
- if (!aggregateKeyLeaf.aggregateKey) {
262
- aggregateKeyLeaf.aggregateKey = aggregationEvaluator(transaction) as string
263
- }
264
- const aggregateKey = aggregateKeyLeaf.aggregateKey as string
265
264
  // Prepare joined values for derived fields
266
265
  if (transaction.type == transactionTypeE.SALE) {
267
266
  const sku = globalRelations['sku'][transaction.ean]
@@ -276,7 +275,16 @@ function aggregator({
276
275
  v[AMOUNT_SOLD_DISCOUNT] = round2(v[AMOUNT_SOLD_MAX] - v[AMOUNT_SOLD_EXCL])
277
276
  }
278
277
 
279
- if (totals) addTransactionToAggregations(beginTimestamp, endTimestamp, '** TOTAL **', rawAggregations, transaction, maxRows)
278
+ if (totals || totalsOnly) addTransactionToAggregations(beginTimestamp, endTimestamp, '** TOTAL **', rawAggregations, transaction, maxRows)
279
+
280
+ // No detail/subtotal rows when there are no groupers
281
+ if (totalsOnly) return
282
+
283
+ const aggregateKeyLeaf = getTrieLeaf<AggregateKeyTrieNode>(aggregationTrieRoot, transaction, aggregationGranularity)
284
+ if (!aggregateKeyLeaf.aggregateKey) {
285
+ aggregateKeyLeaf.aggregateKey = aggregationEvaluator!(transaction) as string
286
+ }
287
+ const aggregateKey = aggregateKeyLeaf.aggregateKey as string
280
288
 
281
289
  // Single subtotals are subtotals for first grouper level
282
290
  // Example: for 4 groupers we have rows like
@@ -449,6 +457,51 @@ function runQuery(
449
457
  return rawAggregations
450
458
  }
451
459
 
460
+ /** True when the bound casts to an ISO instant. A calendar day or a stray "T" does not. */
461
+ function hasClockTime(raw: string): boolean {
462
+ const value = String(raw).trim()
463
+ const cast = new Date(value)
464
+ if (Number.isNaN(cast.getTime())) return false
465
+ const iso = cast.toISOString()
466
+ // yyyy-MM-dd casts to UTC midnight, but the string is only that date prefix.
467
+ if (iso.slice(0, 10) === value) return false
468
+ // The time designator is the character after yyyy-MM-dd, not a stray "T".
469
+ return value.length > 10 && value[10] === 'T'
470
+ }
471
+
472
+ /**
473
+ * Exact begin/end for two ISO instants. The end bound is that instant, not end-of-day.
474
+ */
475
+ function getDateRangeFromIsoInstant(inputDates: string[]): HrTimeframeI {
476
+ const dates = inputDates.slice()
477
+ dates.sort((a, b) => {
478
+ return ('' + a).localeCompare(b)
479
+ })
480
+ const fromDate = new Date(Date.parse(dates[0]))
481
+ const toDate = new Date(Date.parse(dates[1]))
482
+ const beginTimeStamp = fromDate.getTime()
483
+ const endTimeStamp = toDate.getTime()
484
+ const timeFrame = endTimeStamp - beginTimeStamp
485
+ return {
486
+ beginTimeStamp,
487
+ endTimeStamp,
488
+ timeFrame,
489
+ utcFromYear: fromDate.getUTCFullYear(),
490
+ utcToYear: toDate.getUTCFullYear(),
491
+ utcFromMonth: fromDate.getUTCMonth() + 1,
492
+ utcToMonth: toDate.getUTCMonth() + 1,
493
+ fromYear: parseInt(format(fromDate, 'yyyy')),
494
+ toYear: parseInt(format(toDate, 'yyyy')),
495
+ fromMonth: parseInt(format(fromDate, 'MM')),
496
+ toMonth: parseInt(format(toDate, 'MM')),
497
+ hrYear: format(fromDate, 'yyyy'),
498
+ hrQuarter: format(fromDate, 'yyyy-QQQ'),
499
+ hrMonth: format(fromDate, 'yyyy-MM'),
500
+ hrWeek: format(fromDate, 'RRRR-II'),
501
+ hrDay: format(fromDate, 'yyyy-MM-dd'),
502
+ }
503
+ }
504
+
452
505
  /**
453
506
  * @param inputDates array of two YYYY-MM-DD strings
454
507
  */
@@ -472,6 +525,7 @@ const getDateRangeFromPicker = function (inputDates: string[]): HrTimeframeI {
472
525
  hrWeek: '',
473
526
  hrDay: '',
474
527
  }
528
+ if (hasClockTime(inputDates[0]) || hasClockTime(inputDates[1])) return getDateRangeFromIsoInstant(inputDates)
475
529
  const dates = inputDates.slice()
476
530
  dates.sort((a, b) => {
477
531
  return ('' + a).localeCompare(b)
@@ -526,6 +580,7 @@ const getDateRangeFromDatePair = function (inputDates: string[]): HrTimeframeI {
526
580
  hrWeek: '',
527
581
  hrDay: '',
528
582
  }
583
+ if (hasClockTime(inputDates[0]) || hasClockTime(inputDates[1])) return getDateRangeFromIsoInstant(inputDates)
529
584
  const dates = inputDates.slice()
530
585
  dates.sort((a, b) => {
531
586
  return ('' + a).localeCompare(b)
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export { default as hashBrand } from './hashBrand'
13
13
  export { default as hasOnlyDigits } from './hasOnlyDigits'
14
14
  export { default as imageBinder } from './imageBinder'
15
15
  export { default as isean13 } from './isean13'
16
+ export { default as parseScannedBarcode } from './parseScannedBarcode'
16
17
  export { default as pivotTable } from './pivotTable'
17
18
  export { default as projectObject } from './projectObject'
18
19
  export { default as reports, genPDF } from './reports'
@@ -0,0 +1,14 @@
1
+ import hasOnlyDigits from './hasOnlyDigits'
2
+
3
+ /**
4
+ * Gate for scanner / typed barcode input.
5
+ * Strips a leading `#` (scanner prefix), then accepts only 12- or 13-digit strings.
6
+ * Does not validate the EAN check digit (isean13) and does not pad a leading 0 (findSkuByBarcode).
7
+ */
8
+ export default function parseScannedBarcode(raw: unknown): string | null {
9
+ if (typeof raw !== 'string') return null
10
+ const scan = raw.startsWith('#') ? raw.substring(1) : raw
11
+ if (scan.length !== 12 && scan.length !== 13) return null
12
+ if (!hasOnlyDigits(scan)) return null
13
+ return scan
14
+ }
@@ -178,6 +178,29 @@ describe('articleStatus', () => {
178
178
  expect(result[2].vector[articleStatus.VALUE_AVG_STOCK]).toBe(84.15000000000002)
179
179
  })
180
180
 
181
+ it('should calculate only the totals row when no groupers are selected', () => {
182
+ const rawAggregations = {}
183
+ articleStatus.runQuery(
184
+ { beginTimeStamp: 1569316408000, endTimeStamp: 1569316409000 + msYear },
185
+ rawAggregations,
186
+ true,
187
+ TRANSACTIONS,
188
+ [],
189
+ SKUS,
190
+ {},
191
+ {},
192
+ false,
193
+ 'transaction.ean=="000558000143"',
194
+ false
195
+ )
196
+
197
+ expect(Object.keys(rawAggregations)).toEqual(['** TOTAL **'])
198
+ const vector = [].slice.call(rawAggregations['** TOTAL **'])
199
+ articleStatus.postAgg(vector, msYear)
200
+ expect(vector[articleStatus.QTY_STOCK]).toBe(18)
201
+ expect(vector[articleStatus.QTY_END_STOCK]).toBe(17)
202
+ })
203
+
181
204
  it('should calculate aggregations', () => {
182
205
  const rawAggregations = {}
183
206
  articleStatus.runQuery(
@@ -526,6 +549,7 @@ describe('articleStatus', () => {
526
549
  it('#getDateRangeFromDatePair', () => {
527
550
  const dateRange = articleStatus.getDateRangeFromDatePair(['2023-12-31T23:00:00.000Z', '2024-12-31T23:00:00.000Z'])
528
551
  expect(dateRange.beginTimeStamp).toBe(1704063600000)
552
+ expect(dateRange.endTimeStamp).toBe(Date.parse('2024-12-31T23:00:00.000Z'))
529
553
  expect(dateRange.fromYear).toBe(2024)
530
554
  expect(dateRange.fromMonth).toBe(1)
531
555
  expect(dateRange.toYear).toBe(2025)
@@ -535,7 +559,39 @@ describe('articleStatus', () => {
535
559
  expect(dateRange.hrMonth).toBe('2024-01')
536
560
  expect(dateRange.hrWeek).toBe('2024-01')
537
561
  expect(dateRange.hrDay).toBe('2024-01-01')
538
- expect(dateRange.timeFrame).toBe(367 * 24 * 3600 * 1000)
562
+ expect(dateRange.timeFrame).toBe(366 * 24 * 3600 * 1000)
563
+ })
564
+
565
+ it('#getDateRangeFromDatePair keeps clock time for since-last-run windows', () => {
566
+ const from = '2026-09-18T11:04:01.000Z'
567
+ const to = '2026-09-21T15:04:32.000Z'
568
+ const dateRange = articleStatus.getDateRangeFromDatePair([from, to])
569
+ expect(dateRange.beginTimeStamp).toBe(Date.parse(from))
570
+ expect(dateRange.endTimeStamp).toBe(Date.parse(to))
571
+ expect(dateRange.timeFrame).toBe(Date.parse(to) - Date.parse(from))
572
+ })
573
+
574
+ it('#getDateRangeFromDatePair date-only still includes the end day', () => {
575
+ const dateRange = articleStatus.getDateRangeFromDatePair(['2024-01-01', '2024-01-01'])
576
+ expect(dateRange.beginTimeStamp).toBe(Date.parse('2024-01-01'))
577
+ expect(dateRange.endTimeStamp).toBe(Date.parse('2024-01-01') + 24 * 3600 * 1000)
578
+ expect(dateRange.timeFrame).toBe(24 * 3600 * 1000)
579
+ })
580
+
581
+ it('#getDateRangeFromPicker delegates ISO bounds to exact timestamps', () => {
582
+ const from = '2026-09-18T11:04:01.000Z'
583
+ const to = '2026-09-21T15:04:32.000Z'
584
+ const dateRange = articleStatus.getDateRangeFromPicker([from, to])
585
+ expect(dateRange.beginTimeStamp).toBe(Date.parse(from))
586
+ expect(dateRange.endTimeStamp).toBe(Date.parse(to))
587
+ })
588
+
589
+ it('#getDateRangeFromDatePair keeps a real ISO instant that is not the canonical spelling', () => {
590
+ const raw = '2024-01-01T00:00:00Z'
591
+ const dateRange = articleStatus.getDateRangeFromDatePair([raw, raw])
592
+ expect(new Date(raw).toISOString()).not.toBe(raw)
593
+ expect(dateRange.beginTimeStamp).toBe(new Date(raw).getTime())
594
+ expect(dateRange.endTimeStamp).toBe(new Date(raw).getTime())
539
595
  })
540
596
 
541
597
  it('should calculate ROI percentage from post-aggregated values', () => {
@@ -0,0 +1,30 @@
1
+ const { parseScannedBarcode } = require('../dist/index')
2
+
3
+ describe('parseScannedBarcode', function () {
4
+ it('returns null for non-strings and empty input', function () {
5
+ expect(parseScannedBarcode(undefined)).toBe(null)
6
+ expect(parseScannedBarcode(null)).toBe(null)
7
+ expect(parseScannedBarcode(1234567890123)).toBe(null)
8
+ expect(parseScannedBarcode('')).toBe(null)
9
+ expect(parseScannedBarcode('#')).toBe(null)
10
+ })
11
+
12
+ it('strips a leading # and accepts 12- or 13-digit scans', function () {
13
+ expect(parseScannedBarcode('195093100888')).toBe('195093100888')
14
+ expect(parseScannedBarcode('0195093100888')).toBe('0195093100888')
15
+ expect(parseScannedBarcode('#195093100888')).toBe('195093100888')
16
+ expect(parseScannedBarcode('#0195093100888')).toBe('0195093100888')
17
+ })
18
+
19
+ it('rejects wrong length, letters, and commas', function () {
20
+ expect(parseScannedBarcode('12345678901')).toBe(null)
21
+ expect(parseScannedBarcode('12345678901234')).toBe(null)
22
+ expect(parseScannedBarcode('19509310088a')).toBe(null)
23
+ expect(parseScannedBarcode('19509310088,8')).toBe(null)
24
+ expect(parseScannedBarcode('12e3456789012')).toBe(null)
25
+ })
26
+
27
+ it('does not require a valid EAN check digit', function () {
28
+ expect(parseScannedBarcode('1234567890129')).toBe('1234567890129')
29
+ })
30
+ })