@bisondesk/core-sdk 1.0.682 → 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.
Files changed (100) hide show
  1. package/lib/types/leasing-debtors.d.ts +3 -0
  2. package/lib/types/leasing-debtors.d.ts.map +1 -1
  3. package/lib/types/leasing-debtors.js.map +1 -1
  4. package/lib/types/leasing-search.d.ts +2 -0
  5. package/lib/types/leasing-search.d.ts.map +1 -1
  6. package/lib/types/leasing-search.js.map +1 -1
  7. package/lib/types/mileage.d.ts +38 -0
  8. package/lib/types/mileage.d.ts.map +1 -0
  9. package/lib/types/mileage.js +15 -0
  10. package/lib/types/mileage.js.map +1 -0
  11. package/lib/types/vehicle-purchase-payments.d.ts +0 -15
  12. package/lib/types/vehicle-purchase-payments.d.ts.map +1 -1
  13. package/lib/types/vehicle-purchase-payments.js +0 -6
  14. package/lib/types/vehicle-purchase-payments.js.map +1 -1
  15. package/lib/utils/mileage.d.ts +18 -0
  16. package/lib/utils/mileage.d.ts.map +1 -0
  17. package/lib/utils/mileage.js +70 -0
  18. package/lib/utils/mileage.js.map +1 -0
  19. package/lib/utils/mileage.test.d.ts +2 -0
  20. package/lib/utils/mileage.test.d.ts.map +1 -0
  21. package/lib/utils/mileage.test.js +103 -0
  22. package/lib/utils/mileage.test.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/types/leasing-debtors.ts +4 -0
  25. package/src/types/leasing-search.ts +2 -0
  26. package/src/types/mileage.ts +51 -0
  27. package/src/types/vehicle-purchase-payments.ts +5 -36
  28. package/src/utils/mileage.test.ts +151 -0
  29. package/src/utils/mileage.ts +111 -0
  30. package/tsconfig.tsbuildinfo +1 -1
  31. package/src/types/activities.js +0 -13
  32. package/src/types/activities.js.map +0 -1
  33. package/src/types/comparables.js +0 -2
  34. package/src/types/comparables.js.map +0 -1
  35. package/src/types/crm.js +0 -58
  36. package/src/types/crm.js.map +0 -1
  37. package/src/types/dashboard.js +0 -2
  38. package/src/types/dashboard.js.map +0 -1
  39. package/src/types/definitions.js +0 -2
  40. package/src/types/definitions.js.map +0 -1
  41. package/src/types/fields.js +0 -2
  42. package/src/types/fields.js.map +0 -1
  43. package/src/types/insights.js +0 -2
  44. package/src/types/insights.js.map +0 -1
  45. package/src/types/interests.js +0 -6
  46. package/src/types/interests.js.map +0 -1
  47. package/src/types/internal-events.js +0 -18
  48. package/src/types/internal-events.js.map +0 -1
  49. package/src/types/internet-opportunities.js +0 -2
  50. package/src/types/internet-opportunities.js.map +0 -1
  51. package/src/types/internet-vehicles.js +0 -2
  52. package/src/types/internet-vehicles.js.map +0 -1
  53. package/src/types/leasing-administration.js +0 -2
  54. package/src/types/leasing-administration.js.map +0 -1
  55. package/src/types/leasing-debtors.js +0 -2
  56. package/src/types/leasing-debtors.js.map +0 -1
  57. package/src/types/leasing-search.js +0 -2
  58. package/src/types/leasing-search.js.map +0 -1
  59. package/src/types/leasing-settings.js +0 -7
  60. package/src/types/leasing-settings.js.map +0 -1
  61. package/src/types/leasing.js +0 -38
  62. package/src/types/leasing.js.map +0 -1
  63. package/src/types/marketing.js +0 -35
  64. package/src/types/marketing.js.map +0 -1
  65. package/src/types/offers.js +0 -56
  66. package/src/types/opportunities.js +0 -107
  67. package/src/types/opportunities.js.map +0 -1
  68. package/src/types/payments.js +0 -7
  69. package/src/types/picklists.js +0 -2
  70. package/src/types/picklists.js.map +0 -1
  71. package/src/types/quotes.js +0 -42
  72. package/src/types/recently-viewed.js +0 -2
  73. package/src/types/recently-viewed.js.map +0 -1
  74. package/src/types/reservations.js +0 -5
  75. package/src/types/reservations.js.map +0 -1
  76. package/src/types/roi-ratings.js +0 -5
  77. package/src/types/roi-ratings.js.map +0 -1
  78. package/src/types/saved-filters.js +0 -9
  79. package/src/types/saved-filters.js.map +0 -1
  80. package/src/types/search.js +0 -2
  81. package/src/types/search.js.map +0 -1
  82. package/src/types/settings.js +0 -2
  83. package/src/types/settings.js.map +0 -1
  84. package/src/types/templates.js +0 -2
  85. package/src/types/templates.js.map +0 -1
  86. package/src/types/tenants.js +0 -21
  87. package/src/types/tenants.js.map +0 -1
  88. package/src/types/user-performance.js +0 -7
  89. package/src/types/user-performance.js.map +0 -1
  90. package/src/types/utils.js +0 -2
  91. package/src/types/validation.js +0 -2
  92. package/src/types/validation.js.map +0 -1
  93. package/src/types/vehicle-sales.js +0 -6
  94. package/src/types/vehicle-sales.js.map +0 -1
  95. package/src/types/vehicles.js +0 -98
  96. package/src/types/vehicles.js.map +0 -1
  97. package/src/utils/leasing.js +0 -6
  98. package/src/utils/leasing.js.map +0 -1
  99. package/src/utils/opportunities.js +0 -48
  100. package/src/utils/opportunities.js.map +0 -1
@@ -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
+ };
@@ -105,10 +105,9 @@ export type VehiclePurchasePaymentBff = {
105
105
  };
106
106
 
107
107
  /**
108
- * The group an unpaid purchase falls in, in the order the Unpaid list ranks them: sold to a client
109
- * (whether or not it has arrived), arrived on our park and not sold, or still expected — neither
110
- * sold nor arrived. The single Unpaid list filters by `sold` and `logisticStatus` instead; the
111
- * `group` filter and `meta.counts` serve the client that paged each group on its own.
108
+ * The group an unpaid purchase is listed under, in the order the Unpaid list shows them: sold to a
109
+ * client (whether or not it has arrived), arrived on our park and not sold, or still expected —
110
+ * neither sold nor arrived.
112
111
  */
113
112
  export enum UnpaidPurchaseGroup {
114
113
  Sold = 'sold',
@@ -116,34 +115,6 @@ export enum UnpaidPurchaseGroup {
116
115
  Expected = 'expected',
117
116
  }
118
117
 
119
- /**
120
- * Where the vehicle behind an unpaid purchase stands, in the order the Unpaid list ranks its sales:
121
- * delivered to the client, arrived on our park, or still expected from the supplier. Delivered comes
122
- * from the sale side, so only a sold vehicle is delivered, whether or not it passed our park.
123
- */
124
- export enum UnpaidPurchaseLogisticStatus {
125
- Delivered = 'delivered',
126
- Arrived = 'arrived',
127
- Expected = 'expected',
128
- }
129
-
130
- /** The filters on the Unpaid list. A filter left out keeps every purchase. */
131
- export type UnpaidPurchasesFilters = {
132
- /**
133
- * Free text, case-insensitive: every word must appear in the stock number, VIN, vehicle title or
134
- * supplier name.
135
- */
136
- q?: string;
137
- sold?: boolean;
138
- logisticStatus?: UnpaidPurchaseLogisticStatus;
139
- /** Purchasers (`purchase.purchasedBy`); a purchase matches any of them. */
140
- purchasedBy?: string[];
141
- branchId?: string;
142
- /** The vehicle's condition (`external.condition.used`); a vehicle without one matches neither. */
143
- used?: boolean;
144
- group?: UnpaidPurchaseGroup;
145
- };
146
-
147
118
  /**
148
119
  * A row on the Unpaid tab: an agreed purchase that still has an amount to authorise for payment.
149
120
  * Derived from the purchase deal and the payments already authorised, not a payment record.
@@ -166,13 +137,11 @@ export type UnpaidPurchase = {
166
137
  supplierName?: string;
167
138
  };
168
139
 
169
- /** What a page of the Unpaid list carries besides its rows. */
140
+ /** What a page of one Unpaid group carries besides its rows. */
170
141
  export type UnpaidPurchasesMeta = {
171
142
  /** Whether the requester may authorise a payment, as on the vehicle purchase tab. */
172
143
  canAllowPayment: boolean;
173
- /** Everyone who purchased an unpaid purchase, unfiltered, for the purchaser filter. */
174
- purchasers: string[];
175
- /** How many purchases each group holds, unfiltered. */
144
+ /** How many purchases each group holds, for the group headers. */
176
145
  counts: Record<UnpaidPurchaseGroup, number>;
177
146
  };
178
147
 
@@ -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
+ };