@fleetkeep/cli 0.8.0 → 0.9.0
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/README.md +43 -1
- package/fleetkeep.mjs +49 -17
- package/import-batches.mjs +267 -0
- package/operations.mjs +2 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -26,6 +26,40 @@ opening a browser or starting an interactive login. `--no-browser` prints links
|
|
|
26
26
|
for you to open yourself. Setup never starts a subscription, imports records or
|
|
27
27
|
enables reminders on its own. It requires the matching setup API deployment.
|
|
28
28
|
|
|
29
|
+
## Spreadsheet imports
|
|
30
|
+
|
|
31
|
+
CLI 0.9.0 supports files containing up to 1,000 vehicle rows when the matching
|
|
32
|
+
backend is deployed. The account's existing vehicle allowance still applies.
|
|
33
|
+
The preview checks the whole selection's unique new registrations against the
|
|
34
|
+
available capacity before the first batch is saved.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
fleetkeep import preview vehicles.csv --selected-rows '[0,3,7]'
|
|
38
|
+
fleetkeep import apply vehicles.csv --selected-rows '[0,3,7]' --confirm <digest-from-preview>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`--selected-rows` is a JSON array of original zero-based data-row indices,
|
|
42
|
+
excluding the header. Repeat the same selection on preview and apply. Omit it
|
|
43
|
+
to import all rows. The CLI rejects a changed file locally before contacting
|
|
44
|
+
the API, then obtains fresh preview metadata and saves serial batches of five.
|
|
45
|
+
Original spreadsheet line numbers remain attached to each outcome.
|
|
46
|
+
|
|
47
|
+
Progress goes to stderr; `--json` keeps the result machine-readable on stdout.
|
|
48
|
+
If a batch cannot be confirmed, the command exits unsuccessfully and reports
|
|
49
|
+
only the earlier confirmed outcomes. It never silently skips rows or retries an
|
|
50
|
+
uncertain write. A confirmed provider-throttling outcome also stops further
|
|
51
|
+
batches. Only an explicit pre-write HTTP 429 rejection is retried, at most twice,
|
|
52
|
+
using its bounded `Retry-After` delay.
|
|
53
|
+
|
|
54
|
+
An explicit rerun of the identical file and selection uses stable batch keys;
|
|
55
|
+
keep the same `--idempotency-key` if you supplied one. Replayed batches are
|
|
56
|
+
reported separately from new results. Confirmed provider failures are stored too:
|
|
57
|
+
review their outcomes and use a **new** `--idempotency-key` to deliberately retry
|
|
58
|
+
confirmed failed rows. The same key returns the stored failures without another
|
|
59
|
+
provider lookup. Keep the existing key when resolving an uncertain write. Older backends support the existing
|
|
60
|
+
all-row import for files of at most 50 rows; selected or larger imports require
|
|
61
|
+
the upgraded backend.
|
|
62
|
+
|
|
29
63
|
## Shared jobs and running costs
|
|
30
64
|
|
|
31
65
|
Friendly commands return JSON, including missing-data and stale-tracking fields:
|
|
@@ -193,4 +227,12 @@ retry with the same key returns the original record instead of creating a duplic
|
|
|
193
227
|
|
|
194
228
|
The private email-forwarding address requires `tolls:write`, including when reading it: anyone holding that address can submit statements. Read-only integrations do not receive it.
|
|
195
229
|
|
|
196
|
-
CLI toll statement imports accept CSV files up to 256 KiB, the published API limit. Email forwarding accepts up to five CSV attachments of 512 KiB each. Preview and confirm the exact file digest before a CLI import is applied.
|
|
230
|
+
CLI toll statement imports accept CSV files up to 256 KiB, the published API limit. Email forwarding accepts up to five PDF or CSV attachments of 512 KiB each. PDF charges always wait for approval. Preview and confirm the exact file digest before a CLI import is applied.
|
|
231
|
+
|
|
232
|
+
### PDF invoices
|
|
233
|
+
|
|
234
|
+
Forward the original PDF invoice to the account’s private forwarding address, or upload it in Toll Control. PDF charges wait for review before saving. CSV statements remain supported. Text-based GBP PDFs from recognised UK toll providers are supported; scanned, encrypted or ambiguous documents return an actionable error rather than guessed charges.
|
|
235
|
+
|
|
236
|
+
`fleetkeep api call listTollPdfReviews` lists the signed-in account’s extracted rows and confirmation digests. Check the rows against the original invoice, then use `fleetkeep api call confirmTollPdfReview --data-file reviewed-pdf.json` with `batchId`, `confirmDigest`, `csv` and `confirmed: true`. The CSV is the reviewed structured data, not a file the customer needs to prepare. Keep the digest from the current review. Repeat confirmation is idempotent; a different account cannot read or confirm the invoice.
|
|
237
|
+
|
|
238
|
+
These operations require a CLI release containing the PDF operation definitions. The backend candidate must be deployed before use.
|
package/fleetkeep.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { basename, extname, resolve } from 'node:path';
|
|
6
6
|
import { runApiCommand } from './api-command.mjs';
|
|
7
7
|
import { runSetup } from './setup.mjs';
|
|
8
|
+
import { applySpreadsheetBatches, parseSelectedRows } from './import-batches.mjs';
|
|
8
9
|
import { authStatus, getAccessToken, login, logout } from './auth.mjs';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
|
|
@@ -38,8 +39,8 @@ Usage:
|
|
|
38
39
|
fleetkeep jobs save <reference> --title <title> [--json]
|
|
39
40
|
fleetkeep jobs close <job-id> [--json]
|
|
40
41
|
fleetkeep jobs reopen <job-id> [--json]
|
|
41
|
-
fleetkeep import preview <file.csv|file.xlsx> [--json]
|
|
42
|
-
fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
|
|
42
|
+
fleetkeep import preview <file.csv|file.xlsx> [--selected-rows <json>] [--json]
|
|
43
|
+
fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--selected-rows <json>] [--idempotency-key <key>] [--json]
|
|
43
44
|
fleetkeep tolls forwarding [--json]
|
|
44
45
|
fleetkeep tolls forwarding enable [--json]
|
|
45
46
|
fleetkeep tolls import preview <file.csv> [--json]
|
|
@@ -88,11 +89,12 @@ OAuth access and refresh tokens are never accepted as command arguments.`;
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
class FleetkeepApiError extends Error {
|
|
91
|
-
constructor(status, payload) {
|
|
92
|
+
constructor(status, payload, retryAfter) {
|
|
92
93
|
const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${status}`;
|
|
93
94
|
super(`Fleetkeep API rejected the request (${status}): ${code}`);
|
|
94
95
|
this.status = status;
|
|
95
96
|
this.payload = payload;
|
|
97
|
+
this.retryAfter = retryAfter;
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -255,7 +257,7 @@ async function request(path, { method = 'GET', body, headers: extraHeaders = {}
|
|
|
255
257
|
? null
|
|
256
258
|
: await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
|
257
259
|
if (!response.ok) {
|
|
258
|
-
throw new FleetkeepApiError(response.status, payload);
|
|
260
|
+
throw new FleetkeepApiError(response.status, payload, response.headers.get('retry-after'));
|
|
259
261
|
}
|
|
260
262
|
return payload;
|
|
261
263
|
}
|
|
@@ -406,7 +408,7 @@ async function spreadsheetPayload(fileArg) {
|
|
|
406
408
|
: { sourceName, contentBase64: bytes.toString('base64') };
|
|
407
409
|
}
|
|
408
410
|
|
|
409
|
-
function printPreview(payload, jsonOutput, fileArg) {
|
|
411
|
+
function printPreview(payload, jsonOutput, fileArg, selectedRows) {
|
|
410
412
|
if (jsonOutput) {
|
|
411
413
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
412
414
|
return;
|
|
@@ -420,15 +422,22 @@ function printPreview(payload, jsonOutput, fileArg) {
|
|
|
420
422
|
`Valid registrations: ${p.validRegistrations}`,
|
|
421
423
|
`Invalid registrations: ${p.invalidRegistrations}`,
|
|
422
424
|
`Duplicate registrations: ${p.duplicateRegistrations}`,
|
|
423
|
-
`
|
|
425
|
+
...(payload.batching ? [`Selected rows: ${payload.batching.totalRows}`] : []),
|
|
426
|
+
...(payload.capacity
|
|
427
|
+
? [
|
|
428
|
+
payload.capacity.canImport
|
|
429
|
+
? 'Account capacity: available for this selection'
|
|
430
|
+
: `Account capacity: ${payload.capacity.error || 'selection exceeds allowance'}`
|
|
431
|
+
]
|
|
432
|
+
: [`Over self-serve limit: ${p.overSelfServeLimit ? 'yes' : 'no'}`]),
|
|
424
433
|
'',
|
|
425
434
|
'No data was changed. To apply this exact file:',
|
|
426
|
-
`fleetkeep import apply ${JSON.stringify(fileArg)} --confirm ${payload.digest}`
|
|
435
|
+
`fleetkeep import apply ${JSON.stringify(fileArg)} --confirm ${payload.digest}${selectedRows ? ` --selected-rows '${JSON.stringify(selectedRows)}'` : ''}`
|
|
427
436
|
].join('\n') + '\n'
|
|
428
437
|
);
|
|
429
438
|
}
|
|
430
439
|
|
|
431
|
-
function printImport(payload, jsonOutput) {
|
|
440
|
+
function printImport(payload, jsonOutput, completed = true) {
|
|
432
441
|
if (jsonOutput) {
|
|
433
442
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
434
443
|
return;
|
|
@@ -436,11 +445,19 @@ function printImport(payload, jsonOutput) {
|
|
|
436
445
|
const summary = payload.summary;
|
|
437
446
|
process.stdout.write(
|
|
438
447
|
[
|
|
439
|
-
|
|
448
|
+
!completed
|
|
449
|
+
? 'Import interrupted. Confirmed results:'
|
|
450
|
+
: payload.replayed
|
|
451
|
+
? 'Import result replayed from the original request.'
|
|
452
|
+
: 'Import completed.',
|
|
453
|
+
...(payload.replayedBatches && !payload.replayed
|
|
454
|
+
? [`Previously confirmed batches: ${payload.replayedBatches}`]
|
|
455
|
+
: []),
|
|
440
456
|
`Added: ${summary.added}`,
|
|
441
457
|
`Existing vehicles updated with equipment: ${summary.updated}`,
|
|
442
458
|
`Duplicates or unchanged: ${summary.duplicates}`,
|
|
443
459
|
`Invalid: ${summary.invalid}`,
|
|
460
|
+
...(summary.dvlaErrors !== undefined ? [`DVLA errors: ${summary.dvlaErrors}`] : []),
|
|
444
461
|
`Database errors: ${summary.dbErrors}`
|
|
445
462
|
].join('\n') + '\n'
|
|
446
463
|
);
|
|
@@ -452,7 +469,8 @@ function printImport(payload, jsonOutput) {
|
|
|
452
469
|
function printTollForwarding(payload, jsonOutput) {
|
|
453
470
|
const lines = [
|
|
454
471
|
`Email forwarding: ${payload.available === false ? 'not available' : payload.enabled ? 'enabled' : 'not enabled'}`,
|
|
455
|
-
...(payload.address ? [`Forward CSV statements to: ${payload.address}`] : []),
|
|
472
|
+
...(payload.address ? [`Forward PDF invoices or CSV statements to: ${payload.address}`] : []),
|
|
473
|
+
'PDF charges wait for review: fleetkeep api call listTollPdfReviews',
|
|
456
474
|
`Inbox checked every ${payload.pollIntervalMinutes.toLocaleString('en-GB')} minutes.`,
|
|
457
475
|
...(payload.enabled || payload.available === false
|
|
458
476
|
? []
|
|
@@ -803,11 +821,12 @@ async function main() {
|
|
|
803
821
|
|
|
804
822
|
if (args[0] === 'import' && args[1] === 'preview') {
|
|
805
823
|
const body = await spreadsheetPayload(args[2]);
|
|
824
|
+
const selectedRows = parseSelectedRows(requiredValue(args, '--selected-rows'));
|
|
806
825
|
const payload = await request('/api/v1/imports/vehicles/preview', {
|
|
807
826
|
method: 'POST',
|
|
808
|
-
body
|
|
827
|
+
body: { ...body, ...(selectedRows ? { selectedRows } : {}) }
|
|
809
828
|
});
|
|
810
|
-
printPreview(payload, jsonOutput, args[2]);
|
|
829
|
+
printPreview(payload, jsonOutput, args[2], selectedRows);
|
|
811
830
|
return;
|
|
812
831
|
}
|
|
813
832
|
|
|
@@ -819,12 +838,25 @@ async function main() {
|
|
|
819
838
|
);
|
|
820
839
|
}
|
|
821
840
|
const body = await spreadsheetPayload(args[2]);
|
|
822
|
-
const idempotencyKey =
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
body
|
|
841
|
+
const idempotencyKey = requiredValue(args, '--idempotency-key') ?? `cli:${confirmDigest}`;
|
|
842
|
+
const selectedRows = parseSelectedRows(requiredValue(args, '--selected-rows'));
|
|
843
|
+
const applied = await applySpreadsheetBatches({
|
|
844
|
+
body,
|
|
845
|
+
confirmDigest,
|
|
846
|
+
idempotencyKey,
|
|
847
|
+
selectedRows,
|
|
848
|
+
request,
|
|
849
|
+
progress: (done, total) =>
|
|
850
|
+
process.stderr.write(
|
|
851
|
+
`Confirmed ${done.toLocaleString('en-GB')} of ${total.toLocaleString('en-GB')} rows.\n`
|
|
852
|
+
)
|
|
826
853
|
});
|
|
827
|
-
|
|
854
|
+
if (applied.error && jsonOutput)
|
|
855
|
+
process.stdout.write(
|
|
856
|
+
`${JSON.stringify({ ...applied.result, completed: false, error: applied.error, confirmedRows: applied.confirmedRows, totalRows: applied.totalRows }, null, 2)}\n`
|
|
857
|
+
);
|
|
858
|
+
else if (applied.result) printImport(applied.result, jsonOutput, !applied.error);
|
|
859
|
+
if (applied.error) fail(applied.error);
|
|
828
860
|
return;
|
|
829
861
|
}
|
|
830
862
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
|
|
4
|
+
const summaryKeys = [
|
|
5
|
+
'total',
|
|
6
|
+
'added',
|
|
7
|
+
'updated',
|
|
8
|
+
'duplicates',
|
|
9
|
+
'invalid',
|
|
10
|
+
'dvlaErrors',
|
|
11
|
+
'dbErrors'
|
|
12
|
+
];
|
|
13
|
+
const countsByStatus = {
|
|
14
|
+
added: 'added',
|
|
15
|
+
updated: 'updated',
|
|
16
|
+
duplicate: 'duplicates',
|
|
17
|
+
invalid_reg: 'invalid',
|
|
18
|
+
invalid_data: 'invalid',
|
|
19
|
+
dvla_error: 'dvlaErrors',
|
|
20
|
+
db_error: 'dbErrors'
|
|
21
|
+
};
|
|
22
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
23
|
+
const same = (a, b) => Array.isArray(a) && JSON.stringify(a) === JSON.stringify(b);
|
|
24
|
+
|
|
25
|
+
export function parseSelectedRows(value) {
|
|
26
|
+
if (value === null || value === undefined) return undefined;
|
|
27
|
+
let rows;
|
|
28
|
+
try {
|
|
29
|
+
rows = JSON.parse(value);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error('--selected-rows requires a JSON array of original zero-based row indices.');
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
!Array.isArray(rows) ||
|
|
35
|
+
!rows.length ||
|
|
36
|
+
rows.length > 1000 ||
|
|
37
|
+
rows.some((row) => !Number.isInteger(row) || row < 0 || row >= 1000) ||
|
|
38
|
+
new Set(rows).size !== rows.length
|
|
39
|
+
) {
|
|
40
|
+
throw new Error('--selected-rows requires unique original row indices from 0 to 999.');
|
|
41
|
+
}
|
|
42
|
+
return rows.sort((a, b) => a - b);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function spreadsheetDigest(body) {
|
|
46
|
+
return hash(
|
|
47
|
+
typeof body.csv === 'string'
|
|
48
|
+
? Buffer.from(body.csv, 'utf8')
|
|
49
|
+
: Buffer.from(body.contentBase64, 'base64')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function retryMilliseconds(value, now) {
|
|
54
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
55
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(value.trim())
|
|
56
|
+
? Number(value)
|
|
57
|
+
: (Date.parse(value) - now) / 1000;
|
|
58
|
+
return Number.isFinite(seconds) && seconds >= 0 && seconds <= 300
|
|
59
|
+
? Math.ceil(seconds * 1000)
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function quotaRequest(request, path, options, wait, now) {
|
|
64
|
+
for (let retries = 0; ; retries++) {
|
|
65
|
+
try {
|
|
66
|
+
return await request(path, options);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const milliseconds = retryMilliseconds(error.retryAfter, now());
|
|
69
|
+
if (
|
|
70
|
+
error.status !== 429 ||
|
|
71
|
+
error.payload?.error !== 'rate_limited' ||
|
|
72
|
+
retries >= 2 ||
|
|
73
|
+
milliseconds === null
|
|
74
|
+
)
|
|
75
|
+
throw error;
|
|
76
|
+
await wait(milliseconds);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkPreview(preview, digest, selectedRows) {
|
|
82
|
+
if (preview?.digest !== digest)
|
|
83
|
+
throw new Error('Server preview does not match the confirmed file. No import was started.');
|
|
84
|
+
const batch = preview.batching;
|
|
85
|
+
if (!batch) {
|
|
86
|
+
const count = preview.preview?.detectedVehicleCount;
|
|
87
|
+
if (selectedRows || !Number.isInteger(count) || count < 1 || count > 50) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'This server does not support the required batched/selected import. Update the backend before applying.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const count = batch.totalRows;
|
|
95
|
+
const originalCount = preview.preview?.detectedVehicleCount;
|
|
96
|
+
if (
|
|
97
|
+
batch.batchSize !== 5 ||
|
|
98
|
+
!Number.isInteger(count) ||
|
|
99
|
+
count < 1 ||
|
|
100
|
+
count > 1000 ||
|
|
101
|
+
!Number.isInteger(originalCount) ||
|
|
102
|
+
originalCount < count ||
|
|
103
|
+
originalCount > 1000 ||
|
|
104
|
+
!Array.isArray(batch.sourceRows) ||
|
|
105
|
+
batch.sourceRows.length !== count ||
|
|
106
|
+
batch.sourceRows.some(
|
|
107
|
+
(row, i, rows) =>
|
|
108
|
+
!Number.isInteger(row) || row < 0 || row >= originalCount || (i > 0 && row <= rows[i - 1])
|
|
109
|
+
) ||
|
|
110
|
+
!Array.isArray(batch.sourceLines) ||
|
|
111
|
+
batch.sourceLines.length !== count ||
|
|
112
|
+
batch.sourceLines.some(
|
|
113
|
+
(line, i, lines) => !Number.isInteger(line) || line < 1 || (i > 0 && line <= lines[i - 1])
|
|
114
|
+
) ||
|
|
115
|
+
!/^[a-f0-9]{64}$/.test(preview.selectionDigest ?? '') ||
|
|
116
|
+
!same(batch.sourceRows, selectedRows ?? Array.from({ length: originalCount }, (_, i) => i))
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'Import preview has an invalid or incomplete selected-row envelope. No import was started.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return batch;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function checkBatch(result, preview, offset) {
|
|
126
|
+
const expected = preview.batching;
|
|
127
|
+
const rows = expected.sourceRows.slice(offset, offset + 5);
|
|
128
|
+
const lines = expected.sourceLines.slice(offset, offset + 5);
|
|
129
|
+
const next = offset + rows.length < expected.totalRows ? offset + rows.length : null;
|
|
130
|
+
const batch = result?.batch;
|
|
131
|
+
if (
|
|
132
|
+
!batch ||
|
|
133
|
+
batch.offset !== offset ||
|
|
134
|
+
batch.batchSize !== 5 ||
|
|
135
|
+
batch.totalRows !== expected.totalRows ||
|
|
136
|
+
batch.selectionDigest !== preview.selectionDigest ||
|
|
137
|
+
batch.nextOffset !== next ||
|
|
138
|
+
!same(batch.sourceRows, rows) ||
|
|
139
|
+
!same(batch.sourceLines, lines) ||
|
|
140
|
+
!Array.isArray(result.outcomes) ||
|
|
141
|
+
result.outcomes.length !== rows.length ||
|
|
142
|
+
result.outcomes.some((row) => !row || typeof row !== 'object' || !Number.isInteger(row.line)) ||
|
|
143
|
+
!same(
|
|
144
|
+
result.outcomes.map((row) => row.line).sort((a, b) => a - b),
|
|
145
|
+
lines
|
|
146
|
+
) ||
|
|
147
|
+
(result.replayed !== undefined && typeof result.replayed !== 'boolean')
|
|
148
|
+
) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'Import batch response is incomplete or belongs to another row selection. Its result is unconfirmed; stopped without advancing.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const counts = Object.fromEntries(summaryKeys.map((key) => [key, 0]));
|
|
154
|
+
counts.total = rows.length;
|
|
155
|
+
for (const row of result.outcomes) {
|
|
156
|
+
const key = Object.hasOwn(countsByStatus, row.status) ? countsByStatus[row.status] : null;
|
|
157
|
+
if (!key)
|
|
158
|
+
throw new Error(
|
|
159
|
+
'Import batch contains an unknown outcome. Its result is unconfirmed; stopped without advancing.'
|
|
160
|
+
);
|
|
161
|
+
counts[key]++;
|
|
162
|
+
}
|
|
163
|
+
if (summaryKeys.some((key) => result.summary?.[key] !== counts[key])) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
'Import batch summary does not account for its rows. Its result is unconfirmed; stopped without advancing.'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function applySpreadsheetBatches({
|
|
171
|
+
body,
|
|
172
|
+
confirmDigest,
|
|
173
|
+
idempotencyKey,
|
|
174
|
+
selectedRows,
|
|
175
|
+
request,
|
|
176
|
+
progress = () => {},
|
|
177
|
+
wait = delay,
|
|
178
|
+
now = Date.now
|
|
179
|
+
}) {
|
|
180
|
+
if (!/^[a-f0-9]{64}$/.test(confirmDigest ?? '') || spreadsheetDigest(body) !== confirmDigest) {
|
|
181
|
+
throw new Error('The file does not match --confirm. Preview this file again before applying.');
|
|
182
|
+
}
|
|
183
|
+
const source = { ...body, ...(selectedRows ? { selectedRows } : {}) };
|
|
184
|
+
let result = null;
|
|
185
|
+
let confirmedRows = 0;
|
|
186
|
+
let totalRows = null;
|
|
187
|
+
try {
|
|
188
|
+
const preview = await quotaRequest(
|
|
189
|
+
request,
|
|
190
|
+
'/api/v1/imports/vehicles/preview',
|
|
191
|
+
{ method: 'POST', body: source },
|
|
192
|
+
wait,
|
|
193
|
+
now
|
|
194
|
+
);
|
|
195
|
+
const batch = checkPreview(preview, confirmDigest, selectedRows);
|
|
196
|
+
if ((batch || preview.capacity) && preview.capacity?.canImport !== true) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
preview.capacity?.error ||
|
|
199
|
+
'This selected import exceeds the account vehicle allowance. Review the account before applying.'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!batch) {
|
|
203
|
+
result = await quotaRequest(
|
|
204
|
+
request,
|
|
205
|
+
'/api/v1/imports/vehicles/apply',
|
|
206
|
+
{
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: { ...source, confirmDigest, idempotencyKey }
|
|
209
|
+
},
|
|
210
|
+
wait,
|
|
211
|
+
now
|
|
212
|
+
);
|
|
213
|
+
return { result, error: null };
|
|
214
|
+
}
|
|
215
|
+
totalRows = batch.totalRows;
|
|
216
|
+
for (let offset = 0; offset < totalRows; offset += 5) {
|
|
217
|
+
const key = `cli:batch:${hash(JSON.stringify([idempotencyKey, confirmDigest, preview.selectionDigest, offset]))}`;
|
|
218
|
+
const applied = await quotaRequest(
|
|
219
|
+
request,
|
|
220
|
+
'/api/v1/imports/vehicles/apply',
|
|
221
|
+
{
|
|
222
|
+
method: 'POST',
|
|
223
|
+
body: {
|
|
224
|
+
...source,
|
|
225
|
+
confirmDigest,
|
|
226
|
+
confirmSelectionDigest: preview.selectionDigest,
|
|
227
|
+
offset,
|
|
228
|
+
idempotencyKey: key
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
wait,
|
|
232
|
+
now
|
|
233
|
+
);
|
|
234
|
+
checkBatch(applied, preview, offset);
|
|
235
|
+
if (!result) {
|
|
236
|
+
const { batch: _batch, ...rest } = applied;
|
|
237
|
+
result = {
|
|
238
|
+
...rest,
|
|
239
|
+
summary: { ...applied.summary },
|
|
240
|
+
outcomes: [...applied.outcomes],
|
|
241
|
+
replayed: Boolean(applied.replayed),
|
|
242
|
+
replayedBatches: applied.replayed ? 1 : 0
|
|
243
|
+
};
|
|
244
|
+
} else {
|
|
245
|
+
for (const key of summaryKeys) result.summary[key] += applied.summary[key];
|
|
246
|
+
result.outcomes.push(...applied.outcomes);
|
|
247
|
+
result.replayed &&= Boolean(applied.replayed);
|
|
248
|
+
result.replayedBatches += applied.replayed ? 1 : 0;
|
|
249
|
+
}
|
|
250
|
+
confirmedRows += applied.outcomes.length;
|
|
251
|
+
progress(confirmedRows, totalRows);
|
|
252
|
+
if (applied.outcomes.some((row) => row.code === 'rate_limited')) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
'DVLA is limiting requests. Review the confirmed outcomes. To retry confirmed failed rows later, use a NEW --idempotency-key; the same key replays the stored result.'
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { result, error: null, confirmedRows, totalRows };
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
result,
|
|
262
|
+
error: `${error instanceof Error ? error.message : 'Import interrupted.'} Confirmed earlier results are kept. No uncertain write was retried.`,
|
|
263
|
+
confirmedRows,
|
|
264
|
+
totalRows
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
package/operations.mjs
CHANGED
|
@@ -300,6 +300,8 @@ const definitions = [
|
|
|
300
300
|
'tolls:write',
|
|
301
301
|
{ idempotency: true }
|
|
302
302
|
],
|
|
303
|
+
['listTollPdfReviews', 'GET', '/api/v1/tolls/pdf-reviews', 'tolls:read'],
|
|
304
|
+
['confirmTollPdfReview', 'POST', '/api/v1/tolls/pdf-reviews', 'tolls:write'],
|
|
303
305
|
['getTollForwarding', 'GET', '/api/v1/tolls/forwarding', 'tolls:write'],
|
|
304
306
|
['enableTollForwarding', 'POST', '/api/v1/tolls/forwarding', 'tolls:write'],
|
|
305
307
|
['previewTollImport', 'POST', '/api/v1/tolls/imports/preview', 'tolls:read'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetkeep/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Operate every published Fleetkeep API workflow with guarded writes and machine-readable output",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"api-command.mjs",
|
|
11
11
|
"auth.mjs",
|
|
12
12
|
"fleetkeep.mjs",
|
|
13
|
+
"import-batches.mjs",
|
|
13
14
|
"operations.mjs",
|
|
14
15
|
"setup.mjs",
|
|
15
16
|
"README.md"
|