@bisondesk/core-sdk 1.0.682 → 1.0.684
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/lib/types/crm.d.ts +1 -0
- package/lib/types/crm.d.ts.map +1 -1
- package/lib/types/crm.js.map +1 -1
- package/lib/types/leasing-debtors.d.ts +5 -0
- package/lib/types/leasing-debtors.d.ts.map +1 -1
- package/lib/types/leasing-debtors.js.map +1 -1
- package/lib/types/leasing-search.d.ts +2 -0
- package/lib/types/leasing-search.d.ts.map +1 -1
- package/lib/types/leasing-search.js.map +1 -1
- package/lib/types/mileage.d.ts +38 -0
- package/lib/types/mileage.d.ts.map +1 -0
- package/lib/types/mileage.js +15 -0
- package/lib/types/mileage.js.map +1 -0
- package/lib/types/vehicle-purchase-payments.d.ts +10 -0
- package/lib/types/vehicle-purchase-payments.d.ts.map +1 -1
- package/lib/types/vehicle-purchase-payments.js +7 -0
- package/lib/types/vehicle-purchase-payments.js.map +1 -1
- package/lib/utils/mileage.d.ts +18 -0
- package/lib/utils/mileage.d.ts.map +1 -0
- package/lib/utils/mileage.js +70 -0
- package/lib/utils/mileage.js.map +1 -0
- package/lib/utils/mileage.test.d.ts +2 -0
- package/lib/utils/mileage.test.d.ts.map +1 -0
- package/lib/utils/mileage.test.js +103 -0
- package/lib/utils/mileage.test.js.map +1 -0
- package/package.json +1 -1
- package/src/types/crm.ts +1 -0
- package/src/types/leasing-debtors.ts +7 -0
- package/src/types/leasing-search.ts +2 -0
- package/src/types/mileage.ts +51 -0
- package/src/types/vehicle-purchase-payments.ts +27 -4
- package/src/utils/mileage.test.ts +151 -0
- package/src/utils/mileage.ts +111 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { Categories } from '../constants.js';
|
|
3
|
+
import { MileageStatus, MileageTrendDirection } from '../types/mileage.js';
|
|
4
|
+
import { getMileageAlert, getMileageStatus, getMileageSummary, getMileageTrend, } from './mileage.js';
|
|
5
|
+
const now = new Date('2026-09-30T12:00:00Z');
|
|
6
|
+
const lastReadingAt = '2026-09-30T06:00:00Z';
|
|
7
|
+
describe('getMileageStatus', () => {
|
|
8
|
+
test.each([
|
|
9
|
+
{ name: 'silent for more than three days', lastReadingAt: '2026-09-26T09:00:00Z' },
|
|
10
|
+
{ name: 'that never reported', lastReadingAt: undefined },
|
|
11
|
+
])('a device $name has no signal, whatever its last figures say', ({ lastReadingAt }) => {
|
|
12
|
+
expect(getMileageStatus({ category: Categories.Van, last30DaysKm: 8_950, lastReadingAt }, now)).toBe(MileageStatus.NoSignal);
|
|
13
|
+
});
|
|
14
|
+
test('a device quiet for three days still has a signal', () => {
|
|
15
|
+
expect(getMileageStatus({ category: Categories.Van, last30DaysKm: 3_000, lastReadingAt: '2026-09-27T09:00:00Z' }, now)).toBe(MileageStatus.Normal);
|
|
16
|
+
});
|
|
17
|
+
test('a device installed less than 30 days ago is too early to judge', () => {
|
|
18
|
+
expect(getMileageStatus({ last30DaysKm: 40, lastReadingAt, trackedSince: '2026-09-15T08:00:00Z' }, now)).toBe(MileageStatus.TooEarly);
|
|
19
|
+
});
|
|
20
|
+
test.each([
|
|
21
|
+
{ category: Categories.Van, last30DaysKm: 249, expected: MileageStatus.Low },
|
|
22
|
+
{ category: Categories.Van, last30DaysKm: 250, expected: MileageStatus.Normal },
|
|
23
|
+
{ category: Categories.Van, last30DaysKm: 7_500, expected: MileageStatus.Normal },
|
|
24
|
+
{ category: Categories.Van, last30DaysKm: 7_501, expected: MileageStatus.High },
|
|
25
|
+
{ category: Categories.Car, last30DaysKm: 7_501, expected: MileageStatus.High },
|
|
26
|
+
{ category: Categories.TractorHead, last30DaysKm: 12_000, expected: MileageStatus.Normal },
|
|
27
|
+
{ category: Categories.TractorHead, last30DaysKm: 12_001, expected: MileageStatus.High },
|
|
28
|
+
{ category: Categories.Truck, last30DaysKm: 12_001, expected: MileageStatus.High },
|
|
29
|
+
{ category: Categories.Machine, last30DaysKm: 50_000, expected: MileageStatus.Normal },
|
|
30
|
+
{ category: undefined, last30DaysKm: 50_000, expected: MileageStatus.Normal },
|
|
31
|
+
])('$category at $last30DaysKm km is $expected', ({ category, last30DaysKm, expected }) => {
|
|
32
|
+
expect(getMileageStatus({ category, last30DaysKm, lastReadingAt, trackedSince: '2025-01-01T00:00:00Z' }, now)).toBe(expected);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('getMileageAlert', () => {
|
|
36
|
+
test.each([
|
|
37
|
+
MileageStatus.Low,
|
|
38
|
+
MileageStatus.High,
|
|
39
|
+
MileageStatus.NoSignal,
|
|
40
|
+
])('%s on an overdue contract is an alert', (status) => {
|
|
41
|
+
expect(getMileageAlert(status, true)).toBe(status);
|
|
42
|
+
});
|
|
43
|
+
test.each([MileageStatus.Normal, MileageStatus.TooEarly])('%s is never an alert', (status) => {
|
|
44
|
+
expect(getMileageAlert(status, true)).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
test('a contract without overdue invoices has no alert', () => {
|
|
47
|
+
expect(getMileageAlert(MileageStatus.Low, false)).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('getMileageTrend', () => {
|
|
51
|
+
test('has no trend without a yearly average', () => {
|
|
52
|
+
expect(getMileageTrend(1_000, undefined)).toBeUndefined();
|
|
53
|
+
expect(getMileageTrend(1_000, 0)).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
test.each([
|
|
56
|
+
{ last30DaysKm: 13_200, direction: MileageTrendDirection.Up, percent: 32 },
|
|
57
|
+
{ last30DaysKm: 11_500, direction: MileageTrendDirection.Up, percent: 15 },
|
|
58
|
+
{ last30DaysKm: 11_400, direction: MileageTrendDirection.Flat, percent: 14 },
|
|
59
|
+
{ last30DaysKm: 8_600, direction: MileageTrendDirection.Flat, percent: -14 },
|
|
60
|
+
{ last30DaysKm: 8_500, direction: MileageTrendDirection.Down, percent: -15 },
|
|
61
|
+
{ last30DaysKm: 300, direction: MileageTrendDirection.Down, percent: -97 },
|
|
62
|
+
])('$last30DaysKm km against 10 000 is $direction $percent%', ({ last30DaysKm, direction, percent, }) => {
|
|
63
|
+
expect(getMileageTrend(last30DaysKm, 10_000)).toEqual({ direction, percent });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('getMileageSummary', () => {
|
|
67
|
+
const vehicle = (contractId, status, alert, last30DaysKm, yearAvg30DaysKm) => ({ contractId, status, alert, last30DaysKm, yearAvg30DaysKm });
|
|
68
|
+
test('counts flagged vehicles per alert and leaves silent devices out of the km', () => {
|
|
69
|
+
expect(getMileageSummary([
|
|
70
|
+
vehicle('a', MileageStatus.NoSignal, MileageStatus.NoSignal, 9_000, 9_000),
|
|
71
|
+
vehicle('b', MileageStatus.NoSignal, undefined, 4_000),
|
|
72
|
+
vehicle('c', MileageStatus.High, MileageStatus.High, 13_000, 10_000),
|
|
73
|
+
vehicle('d', MileageStatus.Low, MileageStatus.Low, 100, 2_000),
|
|
74
|
+
vehicle('e', MileageStatus.Low, undefined, 200),
|
|
75
|
+
vehicle('f', MileageStatus.TooEarly, undefined, 1_500),
|
|
76
|
+
])).toEqual({
|
|
77
|
+
vehicles: 6,
|
|
78
|
+
noSignal: 1,
|
|
79
|
+
high: 1,
|
|
80
|
+
low: 1,
|
|
81
|
+
issues: 3,
|
|
82
|
+
last30DaysKm: 14_800,
|
|
83
|
+
trend: { direction: MileageTrendDirection.Flat, percent: 9 },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
test('has no km and no trend when no device is reporting', () => {
|
|
87
|
+
const summary = getMileageSummary([
|
|
88
|
+
vehicle('a', MileageStatus.NoSignal, MileageStatus.NoSignal, 9_000, 9_000),
|
|
89
|
+
]);
|
|
90
|
+
expect(summary.last30DaysKm).toBeUndefined();
|
|
91
|
+
expect(summary.trend).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
test('is all zeros for a dossier without tracked vehicles', () => {
|
|
94
|
+
expect(getMileageSummary([])).toEqual({
|
|
95
|
+
vehicles: 0,
|
|
96
|
+
noSignal: 0,
|
|
97
|
+
high: 0,
|
|
98
|
+
low: 0,
|
|
99
|
+
issues: 0,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
//# sourceMappingURL=mileage.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mileage.test.js","sourceRoot":"/","sources":["utils/mileage.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAmB,aAAa,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5F,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,GAChB,MAAM,cAAc,CAAC;AAEtB,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC;AAC7C,MAAM,aAAa,GAAG,sBAAsB,CAAC;AAE7C,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,IAAI,CAAC,IAAI,CAAC;QACR,EAAE,IAAI,EAAE,iCAAiC,EAAE,aAAa,EAAE,sBAAsB,EAAE;QAClF,EAAE,IAAI,EAAE,qBAAqB,EAAE,aAAa,EAAE,SAAS,EAAE;KAC1D,CAAC,CAAC,6DAA6D,EAAE,CAAC,EAAE,aAAa,EAAE,EAAE,EAAE;QACtF,MAAM,CACJ,gBAAgB,CAAC,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,GAAG,CAAC,CACxF,CAAC,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IACjC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC5D,MAAM,CACJ,gBAAgB,CACd,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,aAAa,EAAE,sBAAsB,EAAE,EACxF,GAAG,CACJ,CACF,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,gEAAgE,EAAE,GAAG,EAAE;QAC1E,MAAM,CACJ,gBAAgB,CACd,EAAE,YAAY,EAAE,EAAE,EAAE,aAAa,EAAE,YAAY,EAAE,sBAAsB,EAAE,EACzE,GAAG,CACJ,CACF,CAAC,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IACjC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,IAAI,CAAC;QACR,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,QAAQ,EAAE,aAAa,CAAC,GAAG,EAAE;QAC5E,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,EAAE;QAC/E,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,EAAE;QACjF,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,CAAC,IAAI,EAAE;QAC/E,EAAE,QAAQ,EAAE,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,CAAC,IAAI,EAAE;QAC/E,EAAE,QAAQ,EAAE,UAAU,CAAC,WAAW,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,EAAE;QAC1F,EAAE,QAAQ,EAAE,UAAU,CAAC,WAAW,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,IAAI,EAAE;QACxF,EAAE,QAAQ,EAAE,UAAU,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,IAAI,EAAE;QAClF,EAAE,QAAQ,EAAE,UAAU,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,EAAE;QACtF,EAAE,QAAQ,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,EAAE;KAC9E,CAAC,CAAC,4CAA4C,EAAE,CAAC,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,EAAE;QACxF,MAAM,CACJ,gBAAgB,CACd,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,sBAAsB,EAAE,EAC/E,GAAG,CACJ,CACF,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACnB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE;IAC/B,IAAI,CAAC,IAAI,CAAC;QACR,aAAa,CAAC,GAAG;QACjB,aAAa,CAAC,IAAI;QAClB,aAAa,CAAC,QAAQ;KACvB,CAAC,CAAC,uCAAuC,EAAE,CAAC,MAAM,EAAE,EAAE;QACrD,MAAM,CAAC,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACrD,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,IAAI,CAAC,CAAC,aAAa,CAAC,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,sBAAsB,EAAE,CAAC,MAAM,EAAE,EAAE;QAC3F,MAAM,CAAC,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC5D,MAAM,CAAC,eAAe,CAAC,aAAa,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;IACpE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE;IAC/B,IAAI,CAAC,uCAAuC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,eAAe,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;QAC1D,MAAM,CAAC,eAAe,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;IACpD,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,IAAI,CAAC;QACR,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,qBAAqB,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;QAC1E,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,qBAAqB,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;QAC1E,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;QAC5E,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE;QAC5E,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE;QAC5E,EAAE,YAAY,EAAE,GAAG,EAAE,SAAS,EAAE,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE;KAC3E,CAAC,CAAC,yDAAyD,EAAE,CAAC,EAC7D,YAAY,EACZ,SAAS,EACT,OAAO,GACR,EAAE,EAAE;QACH,MAAM,CAAC,eAAe,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;IAChF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IACjC,MAAM,OAAO,GAAG,CACd,UAAkB,EAClB,MAAqB,EACrB,KAA+B,EAC/B,YAAoB,EACpB,eAAwB,EACP,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,CAAC,CAAC;IAErF,IAAI,CAAC,2EAA2E,EAAE,GAAG,EAAE;QACrF,MAAM,CACJ,iBAAiB,CAAC;YAChB,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,QAAQ,EAAE,aAAa,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;YAC1E,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,QAAQ,EAAE,SAAS,EAAE,KAAK,CAAC;YACtD,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC;YACpE,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,GAAG,EAAE,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC;YAC9D,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,CAAC;YAC/C,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,QAAQ,EAAE,SAAS,EAAE,KAAK,CAAC;SACvD,CAAC,CACH,CAAC,OAAO,CAAC;YACR,QAAQ,EAAE,CAAC;YACX,QAAQ,EAAE,CAAC;YACX,IAAI,EAAE,CAAC;YACP,GAAG,EAAE,CAAC;YACN,MAAM,EAAE,CAAC;YACT,YAAY,EAAE,MAAM;YACpB,KAAK,EAAE,EAAE,SAAS,EAAE,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE;SAC7D,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC9D,MAAM,OAAO,GAAG,iBAAiB,CAAC;YAChC,OAAO,CAAC,GAAG,EAAE,aAAa,CAAC,QAAQ,EAAE,aAAa,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;SAC3E,CAAC,CAAC;QAEH,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,aAAa,EAAE,CAAC;QAC7C,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,aAAa,EAAE,CAAC;IACxC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC/D,MAAM,CAAC,iBAAiB,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;YACpC,QAAQ,EAAE,CAAC;YACX,QAAQ,EAAE,CAAC;YACX,IAAI,EAAE,CAAC;YACP,GAAG,EAAE,CAAC;YACN,MAAM,EAAE,CAAC;SACV,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { describe, expect, test } from 'vitest';\nimport { Categories } from '../constants.js';\nimport { ContractMileage, MileageStatus, MileageTrendDirection } from '../types/mileage.js';\nimport {\n getMileageAlert,\n getMileageStatus,\n getMileageSummary,\n getMileageTrend,\n} from './mileage.js';\n\nconst now = new Date('2026-09-30T12:00:00Z');\nconst lastReadingAt = '2026-09-30T06:00:00Z';\n\ndescribe('getMileageStatus', () => {\n test.each([\n { name: 'silent for more than three days', lastReadingAt: '2026-09-26T09:00:00Z' },\n { name: 'that never reported', lastReadingAt: undefined },\n ])('a device $name has no signal, whatever its last figures say', ({ lastReadingAt }) => {\n expect(\n getMileageStatus({ category: Categories.Van, last30DaysKm: 8_950, lastReadingAt }, now),\n ).toBe(MileageStatus.NoSignal);\n });\n\n test('a device quiet for three days still has a signal', () => {\n expect(\n getMileageStatus(\n { category: Categories.Van, last30DaysKm: 3_000, lastReadingAt: '2026-09-27T09:00:00Z' },\n now,\n ),\n ).toBe(MileageStatus.Normal);\n });\n\n test('a device installed less than 30 days ago is too early to judge', () => {\n expect(\n getMileageStatus(\n { last30DaysKm: 40, lastReadingAt, trackedSince: '2026-09-15T08:00:00Z' },\n now,\n ),\n ).toBe(MileageStatus.TooEarly);\n });\n\n test.each([\n { category: Categories.Van, last30DaysKm: 249, expected: MileageStatus.Low },\n { category: Categories.Van, last30DaysKm: 250, expected: MileageStatus.Normal },\n { category: Categories.Van, last30DaysKm: 7_500, expected: MileageStatus.Normal },\n { category: Categories.Van, last30DaysKm: 7_501, expected: MileageStatus.High },\n { category: Categories.Car, last30DaysKm: 7_501, expected: MileageStatus.High },\n { category: Categories.TractorHead, last30DaysKm: 12_000, expected: MileageStatus.Normal },\n { category: Categories.TractorHead, last30DaysKm: 12_001, expected: MileageStatus.High },\n { category: Categories.Truck, last30DaysKm: 12_001, expected: MileageStatus.High },\n { category: Categories.Machine, last30DaysKm: 50_000, expected: MileageStatus.Normal },\n { category: undefined, last30DaysKm: 50_000, expected: MileageStatus.Normal },\n ])('$category at $last30DaysKm km is $expected', ({ category, last30DaysKm, expected }) => {\n expect(\n getMileageStatus(\n { category, last30DaysKm, lastReadingAt, trackedSince: '2025-01-01T00:00:00Z' },\n now,\n ),\n ).toBe(expected);\n });\n});\n\ndescribe('getMileageAlert', () => {\n test.each([\n MileageStatus.Low,\n MileageStatus.High,\n MileageStatus.NoSignal,\n ])('%s on an overdue contract is an alert', (status) => {\n expect(getMileageAlert(status, true)).toBe(status);\n });\n\n test.each([MileageStatus.Normal, MileageStatus.TooEarly])('%s is never an alert', (status) => {\n expect(getMileageAlert(status, true)).toBeUndefined();\n });\n\n test('a contract without overdue invoices has no alert', () => {\n expect(getMileageAlert(MileageStatus.Low, false)).toBeUndefined();\n });\n});\n\ndescribe('getMileageTrend', () => {\n test('has no trend without a yearly average', () => {\n expect(getMileageTrend(1_000, undefined)).toBeUndefined();\n expect(getMileageTrend(1_000, 0)).toBeUndefined();\n });\n\n test.each([\n { last30DaysKm: 13_200, direction: MileageTrendDirection.Up, percent: 32 },\n { last30DaysKm: 11_500, direction: MileageTrendDirection.Up, percent: 15 },\n { last30DaysKm: 11_400, direction: MileageTrendDirection.Flat, percent: 14 },\n { last30DaysKm: 8_600, direction: MileageTrendDirection.Flat, percent: -14 },\n { last30DaysKm: 8_500, direction: MileageTrendDirection.Down, percent: -15 },\n { last30DaysKm: 300, direction: MileageTrendDirection.Down, percent: -97 },\n ])('$last30DaysKm km against 10 000 is $direction $percent%', ({\n last30DaysKm,\n direction,\n percent,\n }) => {\n expect(getMileageTrend(last30DaysKm, 10_000)).toEqual({ direction, percent });\n });\n});\n\ndescribe('getMileageSummary', () => {\n const vehicle = (\n contractId: string,\n status: MileageStatus,\n alert: ContractMileage['alert'],\n last30DaysKm: number,\n yearAvg30DaysKm?: number,\n ): ContractMileage => ({ contractId, status, alert, last30DaysKm, yearAvg30DaysKm });\n\n test('counts flagged vehicles per alert and leaves silent devices out of the km', () => {\n expect(\n getMileageSummary([\n vehicle('a', MileageStatus.NoSignal, MileageStatus.NoSignal, 9_000, 9_000),\n vehicle('b', MileageStatus.NoSignal, undefined, 4_000),\n vehicle('c', MileageStatus.High, MileageStatus.High, 13_000, 10_000),\n vehicle('d', MileageStatus.Low, MileageStatus.Low, 100, 2_000),\n vehicle('e', MileageStatus.Low, undefined, 200),\n vehicle('f', MileageStatus.TooEarly, undefined, 1_500),\n ]),\n ).toEqual({\n vehicles: 6,\n noSignal: 1,\n high: 1,\n low: 1,\n issues: 3,\n last30DaysKm: 14_800,\n trend: { direction: MileageTrendDirection.Flat, percent: 9 },\n });\n });\n\n test('has no km and no trend when no device is reporting', () => {\n const summary = getMileageSummary([\n vehicle('a', MileageStatus.NoSignal, MileageStatus.NoSignal, 9_000, 9_000),\n ]);\n\n expect(summary.last30DaysKm).toBeUndefined();\n expect(summary.trend).toBeUndefined();\n });\n\n test('is all zeros for a dossier without tracked vehicles', () => {\n expect(getMileageSummary([])).toEqual({\n vehicles: 0,\n noSignal: 0,\n high: 0,\n low: 0,\n issues: 0,\n });\n });\n});\n"]}
|
package/package.json
CHANGED
package/src/types/crm.ts
CHANGED
|
@@ -204,6 +204,7 @@ export type SearchOrganization = {
|
|
|
204
204
|
inUse: boolean; // is this organization in use by any of the branches of this tenant, or is it an organization from another tenant.
|
|
205
205
|
lastActivityAt: string | undefined; // last activity at this organization
|
|
206
206
|
lastActivityBy: string | undefined; // user that did the last activity at this organization
|
|
207
|
+
contactedBy: string[]; // users who logged any activity tagged with this organization
|
|
207
208
|
engagementTypes: OrgEngagementType[] | undefined; // types of engagements this organization has in the tenant
|
|
208
209
|
|
|
209
210
|
accountManagers: string[]; // dynamically determined account managers for this organization
|
|
@@ -2,6 +2,7 @@ import { EmailMessage } from '@bisondesk/commons-sdk/messages';
|
|
|
2
2
|
import { NextList, PaginatedList } from '@bisondesk/commons-sdk/types';
|
|
3
3
|
import { Contact, Organization } from './crm.js';
|
|
4
4
|
import { LeasingContract } from './leasing.js';
|
|
5
|
+
import { ContractMileage, MileageSummary } from './mileage.js';
|
|
5
6
|
import { BaseSearchRequest, SortFilter, SortOrder } from './search.js';
|
|
6
7
|
import { DataRecord, ReferenceData } from './utils.js';
|
|
7
8
|
|
|
@@ -50,6 +51,9 @@ export type SearchDebtorInfo = {
|
|
|
50
51
|
riskLevelLabel: string;
|
|
51
52
|
hasActiveContracts: boolean;
|
|
52
53
|
};
|
|
54
|
+
/** One entry per tracked contract of the organization, copied from the contracts index. */
|
|
55
|
+
mileage?: ContractMileage[];
|
|
56
|
+
mileageSummary?: MileageSummary;
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
export enum DebtorsDossierStatus {
|
|
@@ -342,6 +346,9 @@ export type SearchDebtorsDossier = {
|
|
|
342
346
|
remindersSent: number;
|
|
343
347
|
hasActiveLdrs?: boolean;
|
|
344
348
|
};
|
|
349
|
+
/** One entry per tracked contract of the organization, copied from the contracts index. */
|
|
350
|
+
mileage?: ContractMileage[];
|
|
351
|
+
mileageSummary?: MileageSummary;
|
|
345
352
|
};
|
|
346
353
|
|
|
347
354
|
/************
|
|
@@ -2,6 +2,7 @@ import { InsuranceEntry } from '@bisondesk/commons-sdk/insurance';
|
|
|
2
2
|
import { Contact, Organization } from './crm.js';
|
|
3
3
|
import { PublicSearchDefinition } from './definitions.js';
|
|
4
4
|
import { LeasingContract } from './leasing.js';
|
|
5
|
+
import { ContractMileage } from './mileage.js';
|
|
5
6
|
import { Vehicle } from './vehicles.js';
|
|
6
7
|
|
|
7
8
|
export type LeasingPublicSearchDefinitions = {
|
|
@@ -27,6 +28,7 @@ export type SearchLeasingContract = {
|
|
|
27
28
|
supplierName?: string;
|
|
28
29
|
hasActiveRecurrence?: boolean;
|
|
29
30
|
lastRecurrenceRunAt?: string;
|
|
31
|
+
mileage?: ContractMileage;
|
|
30
32
|
};
|
|
31
33
|
};
|
|
32
34
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export enum MileageStatus {
|
|
2
|
+
NoSignal = 'no-signal',
|
|
3
|
+
TooEarly = 'too-early',
|
|
4
|
+
Low = 'low',
|
|
5
|
+
Normal = 'normal',
|
|
6
|
+
High = 'high',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type MileageAlert = MileageStatus.Low | MileageStatus.High | MileageStatus.NoSignal;
|
|
10
|
+
|
|
11
|
+
export enum MileageTrendDirection {
|
|
12
|
+
Up = 'up',
|
|
13
|
+
Down = 'down',
|
|
14
|
+
Flat = 'flat',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MileageTrend = {
|
|
18
|
+
direction: MileageTrendDirection;
|
|
19
|
+
/** The last 30 days against the 12-month average, in percent. */
|
|
20
|
+
percent: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Km driven by a contract's vehicle, read from its wired track-and-trace device. */
|
|
24
|
+
export type ContractMileage = {
|
|
25
|
+
contractId: string;
|
|
26
|
+
stockNumber?: string;
|
|
27
|
+
status: MileageStatus;
|
|
28
|
+
/** The status, when it is outside the limits and the contract has overdue invoices. */
|
|
29
|
+
alert?: MileageAlert;
|
|
30
|
+
last30DaysKm: number;
|
|
31
|
+
/** Absent while the device document predates the yearly figures: unknown, not zero. */
|
|
32
|
+
currentYearKm?: number;
|
|
33
|
+
/** Km per 30 days over the past 12 months; absent under 60 tracked days. */
|
|
34
|
+
yearAvg30DaysKm?: number;
|
|
35
|
+
trend?: MileageTrend;
|
|
36
|
+
lastReadingAt?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A debtor dossier's tracked vehicles at a glance, for the dossier lists and boards. */
|
|
40
|
+
export type MileageSummary = {
|
|
41
|
+
vehicles: number;
|
|
42
|
+
/** Vehicles flagged per alert: outside the limits on a contract with overdue invoices. */
|
|
43
|
+
noSignal: number;
|
|
44
|
+
high: number;
|
|
45
|
+
low: number;
|
|
46
|
+
issues: number;
|
|
47
|
+
/** Km over the last 30 days of the vehicles still reporting; absent when none is. */
|
|
48
|
+
last30DaysKm?: number;
|
|
49
|
+
/** Those vehicles' km against their combined 12-month average. */
|
|
50
|
+
trend?: MileageTrend;
|
|
51
|
+
};
|
|
@@ -102,6 +102,10 @@ export type VehiclePurchasePaymentBff = {
|
|
|
102
102
|
supplierName?: string;
|
|
103
103
|
/** Export checks on the supplier bank account; filled for the Processing list only. */
|
|
104
104
|
exportChecks?: PaymentExportChecks;
|
|
105
|
+
/** The documents the vehicle already has, as on the Unpaid list; filled for the Allowed list only. */
|
|
106
|
+
documents?: UnpaidPurchaseDocument[];
|
|
107
|
+
/** Due date of the vehicle's latest main purchase invoice; filled for the Allowed list only. */
|
|
108
|
+
invoiceDueDate?: string;
|
|
105
109
|
};
|
|
106
110
|
|
|
107
111
|
/**
|
|
@@ -117,16 +121,31 @@ export enum UnpaidPurchaseGroup {
|
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
/**
|
|
120
|
-
* Where the vehicle behind an unpaid purchase stands, in the order the Unpaid list ranks
|
|
121
|
-
* delivered to the client, arrived on our park,
|
|
122
|
-
*
|
|
124
|
+
* Where the vehicle behind an unpaid purchase stands, in the order the Unpaid list ranks them:
|
|
125
|
+
* delivered to the client, arrived on our park, going straight from the supplier to the client
|
|
126
|
+
* (`deal.readyToDeliver`), or still expected from the supplier. Delivered comes from the sale side,
|
|
127
|
+
* so only a sold vehicle is delivered, whether or not it passed our park.
|
|
123
128
|
*/
|
|
124
129
|
export enum UnpaidPurchaseLogisticStatus {
|
|
125
130
|
Delivered = 'delivered',
|
|
126
131
|
Arrived = 'arrived',
|
|
132
|
+
DirectDelivery = 'directDelivery',
|
|
127
133
|
Expected = 'expected',
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/**
|
|
137
|
+
* A document the payments page tracks for the vehicle behind a purchase: the main purchase invoice (a
|
|
138
|
+
* purchase invoice with a vehicle line, the Purchase Invoice of the vehicle's Document Checks; invoices
|
|
139
|
+
* for transport or repairs do not count), and the vehicle's registration, the original or a copy. The
|
|
140
|
+
* registration values are the administrative document types, and only documents filed on the vehicle
|
|
141
|
+
* count.
|
|
142
|
+
*/
|
|
143
|
+
export enum UnpaidPurchaseDocument {
|
|
144
|
+
PurchaseInvoice = 'purchase_invoice',
|
|
145
|
+
Registration = 'registration',
|
|
146
|
+
RegistrationCopy = 'registration_copy',
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
/** The filters on the Unpaid list. A filter left out keeps every purchase. */
|
|
131
150
|
export type UnpaidPurchasesFilters = {
|
|
132
151
|
/**
|
|
@@ -141,6 +160,8 @@ export type UnpaidPurchasesFilters = {
|
|
|
141
160
|
branchId?: string;
|
|
142
161
|
/** The vehicle's condition (`external.condition.used`); a vehicle without one matches neither. */
|
|
143
162
|
used?: boolean;
|
|
163
|
+
/** Documents the vehicle must have, every one of them. */
|
|
164
|
+
documents?: UnpaidPurchaseDocument[];
|
|
144
165
|
group?: UnpaidPurchaseGroup;
|
|
145
166
|
};
|
|
146
167
|
|
|
@@ -160,8 +181,10 @@ export type UnpaidPurchase = {
|
|
|
160
181
|
paidAmount: string;
|
|
161
182
|
/** `purchaseAmount − allowedAmount`; always greater than zero for rows shown here. */
|
|
162
183
|
remainingAmount: string;
|
|
163
|
-
/** Due date of the purchase invoice. */
|
|
184
|
+
/** Due date of the vehicle's latest main purchase invoice, latest by issue date. */
|
|
164
185
|
invoiceDueDate?: string;
|
|
186
|
+
/** The documents the vehicle already has. */
|
|
187
|
+
documents: UnpaidPurchaseDocument[];
|
|
165
188
|
/** Name of the supplier organisation (`vehicle.purchase.organizationId`), resolved for display. */
|
|
166
189
|
supplierName?: string;
|
|
167
190
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { Categories } from '../constants.js';
|
|
3
|
+
import { ContractMileage, MileageStatus, MileageTrendDirection } from '../types/mileage.js';
|
|
4
|
+
import {
|
|
5
|
+
getMileageAlert,
|
|
6
|
+
getMileageStatus,
|
|
7
|
+
getMileageSummary,
|
|
8
|
+
getMileageTrend,
|
|
9
|
+
} from './mileage.js';
|
|
10
|
+
|
|
11
|
+
const now = new Date('2026-09-30T12:00:00Z');
|
|
12
|
+
const lastReadingAt = '2026-09-30T06:00:00Z';
|
|
13
|
+
|
|
14
|
+
describe('getMileageStatus', () => {
|
|
15
|
+
test.each([
|
|
16
|
+
{ name: 'silent for more than three days', lastReadingAt: '2026-09-26T09:00:00Z' },
|
|
17
|
+
{ name: 'that never reported', lastReadingAt: undefined },
|
|
18
|
+
])('a device $name has no signal, whatever its last figures say', ({ lastReadingAt }) => {
|
|
19
|
+
expect(
|
|
20
|
+
getMileageStatus({ category: Categories.Van, last30DaysKm: 8_950, lastReadingAt }, now),
|
|
21
|
+
).toBe(MileageStatus.NoSignal);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('a device quiet for three days still has a signal', () => {
|
|
25
|
+
expect(
|
|
26
|
+
getMileageStatus(
|
|
27
|
+
{ category: Categories.Van, last30DaysKm: 3_000, lastReadingAt: '2026-09-27T09:00:00Z' },
|
|
28
|
+
now,
|
|
29
|
+
),
|
|
30
|
+
).toBe(MileageStatus.Normal);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a device installed less than 30 days ago is too early to judge', () => {
|
|
34
|
+
expect(
|
|
35
|
+
getMileageStatus(
|
|
36
|
+
{ last30DaysKm: 40, lastReadingAt, trackedSince: '2026-09-15T08:00:00Z' },
|
|
37
|
+
now,
|
|
38
|
+
),
|
|
39
|
+
).toBe(MileageStatus.TooEarly);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test.each([
|
|
43
|
+
{ category: Categories.Van, last30DaysKm: 249, expected: MileageStatus.Low },
|
|
44
|
+
{ category: Categories.Van, last30DaysKm: 250, expected: MileageStatus.Normal },
|
|
45
|
+
{ category: Categories.Van, last30DaysKm: 7_500, expected: MileageStatus.Normal },
|
|
46
|
+
{ category: Categories.Van, last30DaysKm: 7_501, expected: MileageStatus.High },
|
|
47
|
+
{ category: Categories.Car, last30DaysKm: 7_501, expected: MileageStatus.High },
|
|
48
|
+
{ category: Categories.TractorHead, last30DaysKm: 12_000, expected: MileageStatus.Normal },
|
|
49
|
+
{ category: Categories.TractorHead, last30DaysKm: 12_001, expected: MileageStatus.High },
|
|
50
|
+
{ category: Categories.Truck, last30DaysKm: 12_001, expected: MileageStatus.High },
|
|
51
|
+
{ category: Categories.Machine, last30DaysKm: 50_000, expected: MileageStatus.Normal },
|
|
52
|
+
{ category: undefined, last30DaysKm: 50_000, expected: MileageStatus.Normal },
|
|
53
|
+
])('$category at $last30DaysKm km is $expected', ({ category, last30DaysKm, expected }) => {
|
|
54
|
+
expect(
|
|
55
|
+
getMileageStatus(
|
|
56
|
+
{ category, last30DaysKm, lastReadingAt, trackedSince: '2025-01-01T00:00:00Z' },
|
|
57
|
+
now,
|
|
58
|
+
),
|
|
59
|
+
).toBe(expected);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('getMileageAlert', () => {
|
|
64
|
+
test.each([
|
|
65
|
+
MileageStatus.Low,
|
|
66
|
+
MileageStatus.High,
|
|
67
|
+
MileageStatus.NoSignal,
|
|
68
|
+
])('%s on an overdue contract is an alert', (status) => {
|
|
69
|
+
expect(getMileageAlert(status, true)).toBe(status);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test.each([MileageStatus.Normal, MileageStatus.TooEarly])('%s is never an alert', (status) => {
|
|
73
|
+
expect(getMileageAlert(status, true)).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a contract without overdue invoices has no alert', () => {
|
|
77
|
+
expect(getMileageAlert(MileageStatus.Low, false)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('getMileageTrend', () => {
|
|
82
|
+
test('has no trend without a yearly average', () => {
|
|
83
|
+
expect(getMileageTrend(1_000, undefined)).toBeUndefined();
|
|
84
|
+
expect(getMileageTrend(1_000, 0)).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test.each([
|
|
88
|
+
{ last30DaysKm: 13_200, direction: MileageTrendDirection.Up, percent: 32 },
|
|
89
|
+
{ last30DaysKm: 11_500, direction: MileageTrendDirection.Up, percent: 15 },
|
|
90
|
+
{ last30DaysKm: 11_400, direction: MileageTrendDirection.Flat, percent: 14 },
|
|
91
|
+
{ last30DaysKm: 8_600, direction: MileageTrendDirection.Flat, percent: -14 },
|
|
92
|
+
{ last30DaysKm: 8_500, direction: MileageTrendDirection.Down, percent: -15 },
|
|
93
|
+
{ last30DaysKm: 300, direction: MileageTrendDirection.Down, percent: -97 },
|
|
94
|
+
])('$last30DaysKm km against 10 000 is $direction $percent%', ({
|
|
95
|
+
last30DaysKm,
|
|
96
|
+
direction,
|
|
97
|
+
percent,
|
|
98
|
+
}) => {
|
|
99
|
+
expect(getMileageTrend(last30DaysKm, 10_000)).toEqual({ direction, percent });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('getMileageSummary', () => {
|
|
104
|
+
const vehicle = (
|
|
105
|
+
contractId: string,
|
|
106
|
+
status: MileageStatus,
|
|
107
|
+
alert: ContractMileage['alert'],
|
|
108
|
+
last30DaysKm: number,
|
|
109
|
+
yearAvg30DaysKm?: number,
|
|
110
|
+
): ContractMileage => ({ contractId, status, alert, last30DaysKm, yearAvg30DaysKm });
|
|
111
|
+
|
|
112
|
+
test('counts flagged vehicles per alert and leaves silent devices out of the km', () => {
|
|
113
|
+
expect(
|
|
114
|
+
getMileageSummary([
|
|
115
|
+
vehicle('a', MileageStatus.NoSignal, MileageStatus.NoSignal, 9_000, 9_000),
|
|
116
|
+
vehicle('b', MileageStatus.NoSignal, undefined, 4_000),
|
|
117
|
+
vehicle('c', MileageStatus.High, MileageStatus.High, 13_000, 10_000),
|
|
118
|
+
vehicle('d', MileageStatus.Low, MileageStatus.Low, 100, 2_000),
|
|
119
|
+
vehicle('e', MileageStatus.Low, undefined, 200),
|
|
120
|
+
vehicle('f', MileageStatus.TooEarly, undefined, 1_500),
|
|
121
|
+
]),
|
|
122
|
+
).toEqual({
|
|
123
|
+
vehicles: 6,
|
|
124
|
+
noSignal: 1,
|
|
125
|
+
high: 1,
|
|
126
|
+
low: 1,
|
|
127
|
+
issues: 3,
|
|
128
|
+
last30DaysKm: 14_800,
|
|
129
|
+
trend: { direction: MileageTrendDirection.Flat, percent: 9 },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('has no km and no trend when no device is reporting', () => {
|
|
134
|
+
const summary = getMileageSummary([
|
|
135
|
+
vehicle('a', MileageStatus.NoSignal, MileageStatus.NoSignal, 9_000, 9_000),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
expect(summary.last30DaysKm).toBeUndefined();
|
|
139
|
+
expect(summary.trend).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('is all zeros for a dossier without tracked vehicles', () => {
|
|
143
|
+
expect(getMileageSummary([])).toEqual({
|
|
144
|
+
vehicles: 0,
|
|
145
|
+
noSignal: 0,
|
|
146
|
+
high: 0,
|
|
147
|
+
low: 0,
|
|
148
|
+
issues: 0,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { differenceInCalendarDays, parseISO } from 'date-fns';
|
|
2
|
+
import { sumBy } from 'lodash-es';
|
|
3
|
+
import { Categories } from '../constants.js';
|
|
4
|
+
import {
|
|
5
|
+
ContractMileage,
|
|
6
|
+
MileageAlert,
|
|
7
|
+
MileageStatus,
|
|
8
|
+
MileageSummary,
|
|
9
|
+
MileageTrend,
|
|
10
|
+
MileageTrendDirection,
|
|
11
|
+
} from '../types/mileage.js';
|
|
12
|
+
|
|
13
|
+
export const MILEAGE_LOW_KM_30_DAYS = 250;
|
|
14
|
+
export const MILEAGE_TREND_BAND = 0.15;
|
|
15
|
+
export const KM_FILTER_MAX = 15_000;
|
|
16
|
+
export const KM_FILTER_STEP = 250;
|
|
17
|
+
|
|
18
|
+
const MILEAGE_WINDOW_DAYS = 30;
|
|
19
|
+
const MILEAGE_SIGNAL_LOST_AFTER_DAYS = 3;
|
|
20
|
+
|
|
21
|
+
export const MILEAGE_HIGH_KM_30_DAYS: { [category: string]: number } = {
|
|
22
|
+
[Categories.Van]: 7_500,
|
|
23
|
+
[Categories.Car]: 7_500,
|
|
24
|
+
[Categories.TractorHead]: 12_000,
|
|
25
|
+
[Categories.Truck]: 12_000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A device that has not reported for more than three days, or never did, has no signal: its km
|
|
30
|
+
* figures stop refreshing, so they say nothing about how much the vehicle drives.
|
|
31
|
+
*/
|
|
32
|
+
export const getMileageStatus = (
|
|
33
|
+
input: { category?: string; last30DaysKm: number; lastReadingAt?: string; trackedSince?: string },
|
|
34
|
+
now: Date = new Date(),
|
|
35
|
+
): MileageStatus => {
|
|
36
|
+
if (
|
|
37
|
+
input.lastReadingAt == null ||
|
|
38
|
+
differenceInCalendarDays(now, parseISO(input.lastReadingAt)) > MILEAGE_SIGNAL_LOST_AFTER_DAYS
|
|
39
|
+
) {
|
|
40
|
+
return MileageStatus.NoSignal;
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
input.trackedSince != null &&
|
|
44
|
+
differenceInCalendarDays(now, parseISO(input.trackedSince)) < MILEAGE_WINDOW_DAYS
|
|
45
|
+
) {
|
|
46
|
+
return MileageStatus.TooEarly;
|
|
47
|
+
}
|
|
48
|
+
if (input.last30DaysKm < MILEAGE_LOW_KM_30_DAYS) {
|
|
49
|
+
return MileageStatus.Low;
|
|
50
|
+
}
|
|
51
|
+
const highLimit = input.category != null ? MILEAGE_HIGH_KM_30_DAYS[input.category] : undefined;
|
|
52
|
+
if (highLimit != null && input.last30DaysKm > highLimit) {
|
|
53
|
+
return MileageStatus.High;
|
|
54
|
+
}
|
|
55
|
+
return MileageStatus.Normal;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isOutsideLimits = (status: MileageStatus): status is MileageAlert =>
|
|
59
|
+
status === MileageStatus.Low ||
|
|
60
|
+
status === MileageStatus.High ||
|
|
61
|
+
status === MileageStatus.NoSignal;
|
|
62
|
+
|
|
63
|
+
/** Debt follow-up only cares about mileage outside the limits on a contract that is overdue. */
|
|
64
|
+
export const getMileageAlert = (
|
|
65
|
+
status: MileageStatus,
|
|
66
|
+
overdue: boolean,
|
|
67
|
+
): MileageAlert | undefined => (overdue && isOutsideLimits(status) ? status : undefined);
|
|
68
|
+
|
|
69
|
+
export const getMileageTrend = (
|
|
70
|
+
last30DaysKm: number,
|
|
71
|
+
yearAvg30DaysKm: number | undefined,
|
|
72
|
+
): MileageTrend | undefined => {
|
|
73
|
+
if (yearAvg30DaysKm == null || yearAvg30DaysKm === 0) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const change = (last30DaysKm - yearAvg30DaysKm) / yearAvg30DaysKm;
|
|
77
|
+
const percent = Math.round(change * 100);
|
|
78
|
+
if (change >= MILEAGE_TREND_BAND) {
|
|
79
|
+
return { direction: MileageTrendDirection.Up, percent };
|
|
80
|
+
}
|
|
81
|
+
if (change <= -MILEAGE_TREND_BAND) {
|
|
82
|
+
return { direction: MileageTrendDirection.Down, percent };
|
|
83
|
+
}
|
|
84
|
+
return { direction: MileageTrendDirection.Flat, percent };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Counts a dossier's flagged vehicles and adds up the km of the ones still reporting. A silent
|
|
89
|
+
* device's figures stay frozen at its last reading, so they are left out of the total and the trend.
|
|
90
|
+
*/
|
|
91
|
+
export const getMileageSummary = (mileage: ContractMileage[]): MileageSummary => {
|
|
92
|
+
const countAlert = (alert: MileageAlert) => mileage.filter((item) => item.alert === alert).length;
|
|
93
|
+
const noSignal = countAlert(MileageStatus.NoSignal);
|
|
94
|
+
const high = countAlert(MileageStatus.High);
|
|
95
|
+
const low = countAlert(MileageStatus.Low);
|
|
96
|
+
const reporting = mileage.filter((item) => item.status !== MileageStatus.NoSignal);
|
|
97
|
+
const withAverage = reporting.filter((item) => item.yearAvg30DaysKm != null);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
vehicles: mileage.length,
|
|
101
|
+
noSignal,
|
|
102
|
+
high,
|
|
103
|
+
low,
|
|
104
|
+
issues: noSignal + high + low,
|
|
105
|
+
last30DaysKm: reporting.length > 0 ? sumBy(reporting, (item) => item.last30DaysKm) : undefined,
|
|
106
|
+
trend: getMileageTrend(
|
|
107
|
+
sumBy(withAverage, (item) => item.last30DaysKm),
|
|
108
|
+
sumBy(withAverage, (item) => item.yearAvg30DaysKm ?? 0),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
};
|