@bisondesk/core-sdk 1.0.681 → 1.0.683
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/leasing-debtors.d.ts +3 -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/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/leasing-debtors.ts +4 -0
- package/src/types/leasing-search.ts +2 -0
- package/src/types/mileage.ts +51 -0
- package/src/utils/mileage.test.ts +151 -0
- package/src/utils/mileage.ts +111 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/types/activities.js +0 -13
- package/src/types/activities.js.map +0 -1
- package/src/types/comparables.js +0 -2
- package/src/types/comparables.js.map +0 -1
- package/src/types/crm.js +0 -58
- package/src/types/crm.js.map +0 -1
- package/src/types/dashboard.js +0 -2
- package/src/types/dashboard.js.map +0 -1
- package/src/types/definitions.js +0 -2
- package/src/types/definitions.js.map +0 -1
- package/src/types/fields.js +0 -2
- package/src/types/fields.js.map +0 -1
- package/src/types/insights.js +0 -2
- package/src/types/insights.js.map +0 -1
- package/src/types/interests.js +0 -6
- package/src/types/interests.js.map +0 -1
- package/src/types/internal-events.js +0 -18
- package/src/types/internal-events.js.map +0 -1
- package/src/types/internet-opportunities.js +0 -2
- package/src/types/internet-opportunities.js.map +0 -1
- package/src/types/internet-vehicles.js +0 -2
- package/src/types/internet-vehicles.js.map +0 -1
- package/src/types/leasing-administration.js +0 -2
- package/src/types/leasing-administration.js.map +0 -1
- package/src/types/leasing-debtors.js +0 -2
- package/src/types/leasing-debtors.js.map +0 -1
- package/src/types/leasing-search.js +0 -2
- package/src/types/leasing-search.js.map +0 -1
- package/src/types/leasing-settings.js +0 -7
- package/src/types/leasing-settings.js.map +0 -1
- package/src/types/leasing.js +0 -38
- package/src/types/leasing.js.map +0 -1
- package/src/types/marketing.js +0 -35
- package/src/types/marketing.js.map +0 -1
- package/src/types/offers.js +0 -56
- package/src/types/opportunities.js +0 -107
- package/src/types/opportunities.js.map +0 -1
- package/src/types/payments.js +0 -7
- package/src/types/picklists.js +0 -2
- package/src/types/picklists.js.map +0 -1
- package/src/types/quotes.js +0 -42
- package/src/types/recently-viewed.js +0 -2
- package/src/types/recently-viewed.js.map +0 -1
- package/src/types/reservations.js +0 -5
- package/src/types/reservations.js.map +0 -1
- package/src/types/roi-ratings.js +0 -5
- package/src/types/roi-ratings.js.map +0 -1
- package/src/types/saved-filters.js +0 -9
- package/src/types/saved-filters.js.map +0 -1
- package/src/types/search.js +0 -2
- package/src/types/search.js.map +0 -1
- package/src/types/settings.js +0 -2
- package/src/types/settings.js.map +0 -1
- package/src/types/templates.js +0 -2
- package/src/types/templates.js.map +0 -1
- package/src/types/tenants.js +0 -21
- package/src/types/tenants.js.map +0 -1
- package/src/types/user-performance.js +0 -7
- package/src/types/user-performance.js.map +0 -1
- package/src/types/utils.js +0 -2
- package/src/types/validation.js +0 -2
- package/src/types/validation.js.map +0 -1
- package/src/types/vehicle-sales.js +0 -6
- package/src/types/vehicle-sales.js.map +0 -1
- package/src/types/vehicles.js +0 -98
- package/src/types/vehicles.js.map +0 -1
- package/src/utils/leasing.js +0 -6
- package/src/utils/leasing.js.map +0 -1
- package/src/utils/opportunities.js +0 -48
- package/src/utils/opportunities.js.map +0 -1
|
@@ -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
|
+
};
|