@burdenoff/microfe-movethewheels 2026.1010.2 → 2026.1011.2
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/dist/MoveTheWheelsRoot.js +12 -1
- package/dist/MoveTheWheelsRoutes.js +57 -49
- package/dist/generated/shipment-status-authority-operations.d.ts +4473 -0
- package/dist/generated/tracking-quality-operations.d.ts +4302 -0
- package/dist/hooks/useEpod.js +85 -77
- package/dist/hooks/useShipmentStatusAuthority.js +127 -0
- package/dist/hooks/useTracking.js +15 -14
- package/dist/hooks/useTrackingEventQuality.js +60 -0
- package/dist/movethewheels/hooks/useEpod.d.ts +23 -0
- package/dist/movethewheels/hooks/useShipmentStatusAuthority.d.ts +23 -0
- package/dist/movethewheels/hooks/useTrackingEventQuality.d.ts +18 -0
- package/dist/movethewheels/pages/ShipmentStatusAuthorityPage.d.ts +1 -0
- package/dist/movethewheels/pages/TrackingEventQualityPage.d.ts +1 -0
- package/dist/movethewheels/utils/shipmentStatusDraft.d.ts +45 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/radio-tower.js +36 -0
- package/dist/pages/DeliveryCapturePage.js +181 -116
- package/dist/pages/ShipmentStatusAuthorityPage.js +563 -0
- package/dist/pages/TrackingEventQualityPage.js +224 -0
- package/dist/src/generated/shipment-status-authority-operations.js +86 -0
- package/dist/src/generated/tracking-quality-operations.js +44 -0
- package/dist/utils/shipmentStatusDraft.js +41 -0
- package/package.json +16 -7
- package/patches/README.md +25 -0
- package/patches/braces@3.0.3.patch +87 -0
- package/src/generated/shipment-status-authority-operations.ts +5503 -0
- package/src/generated/tracking-quality-operations.ts +5323 -0
- package/src/movethewheels/MoveTheWheelsRoot.tsx +14 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/hooks/useEpod.ts +47 -12
- package/src/movethewheels/hooks/useShipmentStatusAuthority.ts +179 -0
- package/src/movethewheels/hooks/useTracking.ts +27 -12
- package/src/movethewheels/hooks/useTrackingEventQuality.ts +110 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +113 -6
- package/src/movethewheels/pages/ShipmentStatusAuthorityPage.tsx +639 -0
- package/src/movethewheels/pages/TrackingEventQualityPage.tsx +250 -0
- package/src/movethewheels/utils/shipmentStatusDraft.ts +118 -0
- package/src/operations/shipment-status-authority/status.graphql +80 -0
- package/src/operations/tracking-quality/quality.graphql +15 -0
|
@@ -92,6 +92,20 @@ const NavRegistrar: FC<Pick<MoveTheWheelsRootProps, 'registerNavItems' | 'basePa
|
|
|
92
92
|
path: joinPath(basePath, 'tracking'),
|
|
93
93
|
icon: <Map size={16} />,
|
|
94
94
|
order: 3,
|
|
95
|
+
children: [
|
|
96
|
+
{
|
|
97
|
+
id: 'mtw-tracking-quality',
|
|
98
|
+
label: tr('movethewheels.root.nav.trackingQuality', 'Tracking event quality'),
|
|
99
|
+
path: joinPath(basePath, 'tracking/event-quality'),
|
|
100
|
+
icon: <Map size={16} />,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'mtw-status-authority',
|
|
104
|
+
label: tr('movethewheels.root.nav.statusAuthority', 'Shipment status authority'),
|
|
105
|
+
path: joinPath(basePath, 'shipments/status-authority'),
|
|
106
|
+
icon: <ClipboardList size={16} />,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
95
109
|
},
|
|
96
110
|
{
|
|
97
111
|
id: 'mtw-consolidation',
|
|
@@ -55,6 +55,8 @@ const OrdersListPage = lazy(() => import('./pages/OrdersListPage'));
|
|
|
55
55
|
const OrderDetailsPage = lazy(() => import('./pages/OrderDetailsPage'));
|
|
56
56
|
const CreateOrderPage = lazy(() => import('./pages/CreateOrderPage'));
|
|
57
57
|
const TrackingPage = lazy(() => import('./pages/TrackingPage'));
|
|
58
|
+
const TrackingEventQualityPage = lazy(() => import('./pages/TrackingEventQualityPage'));
|
|
59
|
+
const ShipmentStatusAuthorityPage = lazy(() => import('./pages/ShipmentStatusAuthorityPage'));
|
|
58
60
|
const LiveTrackingPage = lazy(() => import('./pages/LiveTrackingPage'));
|
|
59
61
|
const FleetPage = lazy(() => import('./pages/FleetPage'));
|
|
60
62
|
const VehiclesPage = lazy(() => import('./pages/VehiclesPage'));
|
|
@@ -108,6 +110,8 @@ export function MoveTheWheelsRoutes() {
|
|
|
108
110
|
{/* Tracking */}
|
|
109
111
|
<Route path="tracking" element={<TrackingPage />} />
|
|
110
112
|
<Route path="tracking/live" element={<LiveTrackingPage />} />
|
|
113
|
+
<Route path="tracking/event-quality" element={<TrackingEventQualityPage />} />
|
|
114
|
+
<Route path="shipments/status-authority" element={<ShipmentStatusAuthorityPage />} />
|
|
111
115
|
|
|
112
116
|
{/* Driver ePOD capture (BOFF-4399) */}
|
|
113
117
|
<Route path="deliveries/mine" element={<DeliveryCapturePage />} />
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* multi-language delivery instructions.
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import { parse } from 'graphql';
|
|
16
|
+
import { useShipmentStatusCommand } from './useShipmentStatusAuthority';
|
|
15
17
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
16
18
|
import type { Order, ProofOfDelivery, SignatureType } from '../types';
|
|
17
19
|
import { executeGraphQL } from '../utils/graphqlClient';
|
|
18
20
|
|
|
19
|
-
interface ApiProofOfDelivery {
|
|
21
|
+
export interface ApiProofOfDelivery {
|
|
20
22
|
id: string;
|
|
21
23
|
deliveryJobId: string;
|
|
22
24
|
shipmentId?: string | null;
|
|
@@ -81,6 +83,7 @@ const CAPTURE_EPOD_MUTATION = `
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
`;
|
|
86
|
+
const CAPTURE_EPOD_DOCUMENT = parse(CAPTURE_EPOD_MUTATION);
|
|
84
87
|
|
|
85
88
|
const INITIATE_UPLOAD_MUTATION = `
|
|
86
89
|
mutation EpodInitiateUpload($input: InitiateUploadInput!) {
|
|
@@ -257,6 +260,9 @@ export function useMyPendingEpodDeliveryJobs(options: { limit?: number; offset?:
|
|
|
257
260
|
}
|
|
258
261
|
|
|
259
262
|
export interface CaptureEpodArgs {
|
|
263
|
+
expectedShipmentId: string | null;
|
|
264
|
+
expectedShipmentStatusRevision?: number;
|
|
265
|
+
completionAttestation: boolean;
|
|
260
266
|
deliveryJobId: string;
|
|
261
267
|
recipientName: string;
|
|
262
268
|
signatureType: SignatureType;
|
|
@@ -268,22 +274,48 @@ export interface CaptureEpodArgs {
|
|
|
268
274
|
notes?: string;
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
export function epodAcknowledged(
|
|
278
|
+
actual: ApiProofOfDelivery | null | undefined,
|
|
279
|
+
expected: CaptureEpodArgs
|
|
280
|
+
): boolean {
|
|
281
|
+
return (
|
|
282
|
+
!!actual &&
|
|
283
|
+
typeof actual.id === 'string' &&
|
|
284
|
+
actual.id.trim().length > 0 &&
|
|
285
|
+
actual.deliveryJobId === expected.deliveryJobId &&
|
|
286
|
+
actual.shipmentId === expected.expectedShipmentId &&
|
|
287
|
+
actual.recipientName === expected.recipientName &&
|
|
288
|
+
actual.signatureType === toApiSignatureType(expected.signatureType) &&
|
|
289
|
+
actual.signatureData === expected.signatureData &&
|
|
290
|
+
typeof actual.capturedByDriverId === 'string' &&
|
|
291
|
+
actual.capturedByDriverId.trim().length > 0 &&
|
|
292
|
+
typeof actual.deliveredAt === 'string' &&
|
|
293
|
+
Number.isFinite(Date.parse(actual.deliveredAt)) &&
|
|
294
|
+
Array.isArray(actual.photoFileIds) &&
|
|
295
|
+
actual.photoFileIds.length === (expected.photoFileIds ?? []).length &&
|
|
296
|
+
actual.photoFileIds.every((id, index) => id === (expected.photoFileIds ?? [])[index]) &&
|
|
297
|
+
actual.latitude === (expected.latitude ?? null) &&
|
|
298
|
+
actual.longitude === (expected.longitude ?? null) &&
|
|
299
|
+
actual.locationAccuracy === (expected.locationAccuracy ?? null) &&
|
|
300
|
+
actual.notes === (expected.notes ?? null)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
271
304
|
/** Driver-facing capture mutation. Identity/ownership is enforced server-side — this never sends a driver id. */
|
|
272
305
|
export function useCaptureEpod() {
|
|
273
|
-
const
|
|
274
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
306
|
+
const command = useShipmentStatusCommand();
|
|
275
307
|
const [error, setError] = useState<Error | null>(null);
|
|
276
308
|
|
|
277
309
|
const captureEpod = useCallback(
|
|
278
310
|
async (args: CaptureEpodArgs): Promise<ProofOfDelivery> => {
|
|
279
|
-
setIsSubmitting(true);
|
|
280
311
|
setError(null);
|
|
281
312
|
try {
|
|
282
|
-
const data = await
|
|
283
|
-
|
|
284
|
-
CAPTURE_EPOD_MUTATION,
|
|
313
|
+
const data = await command.run<{ captureEpod: ApiProofOfDelivery }>(
|
|
314
|
+
CAPTURE_EPOD_DOCUMENT,
|
|
285
315
|
{
|
|
286
316
|
input: {
|
|
317
|
+
expectedShipmentStatusRevision: args.expectedShipmentStatusRevision,
|
|
318
|
+
completionAttestation: args.completionAttestation,
|
|
287
319
|
deliveryJobId: args.deliveryJobId,
|
|
288
320
|
recipientName: args.recipientName,
|
|
289
321
|
signatureType: toApiSignatureType(args.signatureType),
|
|
@@ -294,8 +326,13 @@ export function useCaptureEpod() {
|
|
|
294
326
|
locationAccuracy: args.locationAccuracy,
|
|
295
327
|
notes: args.notes,
|
|
296
328
|
},
|
|
297
|
-
}
|
|
329
|
+
},
|
|
330
|
+
(data) => epodAcknowledged(data.captureEpod, args)
|
|
298
331
|
);
|
|
332
|
+
if (!data)
|
|
333
|
+
throw new Error(
|
|
334
|
+
'Capture was not acknowledged. Review the recorded status and the capture message before taking further action.'
|
|
335
|
+
);
|
|
299
336
|
return mapEpod(data.captureEpod);
|
|
300
337
|
} catch (captureError) {
|
|
301
338
|
const err =
|
|
@@ -304,14 +341,12 @@ export function useCaptureEpod() {
|
|
|
304
341
|
: new Error('Failed to capture proof of delivery');
|
|
305
342
|
setError(err);
|
|
306
343
|
throw err;
|
|
307
|
-
} finally {
|
|
308
|
-
setIsSubmitting(false);
|
|
309
344
|
}
|
|
310
345
|
},
|
|
311
|
-
[
|
|
346
|
+
[command]
|
|
312
347
|
);
|
|
313
348
|
|
|
314
|
-
return { captureEpod, isSubmitting, error };
|
|
349
|
+
return { captureEpod, isSubmitting: command.busy, error, captureFeedback: command.feedback };
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
export interface UploadedEpodPhoto {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { print, type DocumentNode } from 'graphql';
|
|
3
|
+
import { graphqlFetch, clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql';
|
|
4
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
5
|
+
|
|
6
|
+
const KNOWN_REJECTIONS = new Set([
|
|
7
|
+
'STATUS_REVISION_CONFLICT',
|
|
8
|
+
'BAD_USER_INPUT',
|
|
9
|
+
'FORBIDDEN',
|
|
10
|
+
'UNAUTHENTICATED',
|
|
11
|
+
'NOT_FOUND',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export class ShipmentStatusRequestError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
readonly code: string | null
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ShipmentStatusRequestError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normal shared gateway transport; mutation acknowledgements are never automatically retried. */
|
|
25
|
+
export function useShipmentStatusApi() {
|
|
26
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId } = useMoveTheWheels();
|
|
27
|
+
const context = useMemo(
|
|
28
|
+
() => ({ apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId }),
|
|
29
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId]
|
|
30
|
+
);
|
|
31
|
+
return useCallback(
|
|
32
|
+
async <T>(
|
|
33
|
+
document: DocumentNode,
|
|
34
|
+
variables: Record<string, unknown>,
|
|
35
|
+
mutation = false,
|
|
36
|
+
signal?: AbortSignal
|
|
37
|
+
): Promise<T> => {
|
|
38
|
+
const response = await graphqlFetch<T>({
|
|
39
|
+
gateway: 'workspace',
|
|
40
|
+
query: print(document),
|
|
41
|
+
variables,
|
|
42
|
+
baseUrl: context.apiGatewayUrl,
|
|
43
|
+
authToken: context.authToken,
|
|
44
|
+
workspaceToken: context.workspaceToken || true,
|
|
45
|
+
workspaceId: context.workspaceId || true,
|
|
46
|
+
tenantId: context.tenantId || true,
|
|
47
|
+
organizationId: true,
|
|
48
|
+
signal,
|
|
49
|
+
skipAuthRetry: mutation,
|
|
50
|
+
});
|
|
51
|
+
if (response.errors?.length) {
|
|
52
|
+
// A single verified rejection is distinct from a transport failure or partial response.
|
|
53
|
+
const first = response.errors[0];
|
|
54
|
+
const code =
|
|
55
|
+
response.data === null &&
|
|
56
|
+
response.errors.length === 1 &&
|
|
57
|
+
typeof first?.extensions?.code === 'string'
|
|
58
|
+
? first.extensions.code
|
|
59
|
+
: null;
|
|
60
|
+
throw new ShipmentStatusRequestError(
|
|
61
|
+
first?.message ?? 'Unable to load the recorded status.',
|
|
62
|
+
code
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (response.data == null)
|
|
66
|
+
throw new ShipmentStatusRequestError('No acknowledgement was received.', null);
|
|
67
|
+
return response.data;
|
|
68
|
+
},
|
|
69
|
+
[context]
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useShipmentStatusQuery<T>(
|
|
74
|
+
document: DocumentNode,
|
|
75
|
+
variables: Record<string, unknown> | null,
|
|
76
|
+
revision = 0
|
|
77
|
+
) {
|
|
78
|
+
const request = useShipmentStatusApi();
|
|
79
|
+
const key = JSON.stringify(variables);
|
|
80
|
+
const [state, setState] = useState<{
|
|
81
|
+
request: typeof request;
|
|
82
|
+
key: string;
|
|
83
|
+
revision: number;
|
|
84
|
+
data: T | null;
|
|
85
|
+
error: string | null;
|
|
86
|
+
loading: boolean;
|
|
87
|
+
} | null>(null);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const scope = { request, key, revision };
|
|
91
|
+
setState({ ...scope, data: null, error: null, loading: key !== 'null' });
|
|
92
|
+
if (key === 'null') return;
|
|
93
|
+
// An explicit review refresh must read the gateway rather than its short-lived response cache.
|
|
94
|
+
if (revision > 0) clearGraphqlFetchResponseCache();
|
|
95
|
+
const input: Record<string, unknown> = JSON.parse(key);
|
|
96
|
+
void request<T>(document, input, false, controller.signal)
|
|
97
|
+
.then((data) => {
|
|
98
|
+
if (!controller.signal.aborted) setState({ ...scope, data, error: null, loading: false });
|
|
99
|
+
})
|
|
100
|
+
.catch((failure: unknown) => {
|
|
101
|
+
if (!controller.signal.aborted)
|
|
102
|
+
setState({
|
|
103
|
+
...scope,
|
|
104
|
+
data: null,
|
|
105
|
+
error: failure instanceof Error ? failure.message : 'Unable to load recorded status.',
|
|
106
|
+
loading: false,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return () => controller.abort();
|
|
110
|
+
}, [document, request, key, revision]);
|
|
111
|
+
const current = state?.request === request && state.key === key && state.revision === revision;
|
|
112
|
+
return {
|
|
113
|
+
data: current ? state.data : null,
|
|
114
|
+
error: current ? state.error : null,
|
|
115
|
+
loading: current ? state.loading : key !== 'null',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useShipmentStatusCommand() {
|
|
120
|
+
const request = useShipmentStatusApi();
|
|
121
|
+
const lock = useRef(false);
|
|
122
|
+
const uncertain = useRef(false);
|
|
123
|
+
const [busy, setBusy] = useState(false);
|
|
124
|
+
const [feedback, setFeedback] = useState<{
|
|
125
|
+
kind: 'APPLIED' | 'REJECTED' | 'UNKNOWN';
|
|
126
|
+
message: string;
|
|
127
|
+
} | null>(null);
|
|
128
|
+
const [revision, setRevision] = useState(0);
|
|
129
|
+
const run = async <T>(
|
|
130
|
+
document: DocumentNode,
|
|
131
|
+
variables: Record<string, unknown>,
|
|
132
|
+
acknowledged: (result: T) => boolean
|
|
133
|
+
): Promise<T | null> => {
|
|
134
|
+
if (lock.current || uncertain.current) return null;
|
|
135
|
+
lock.current = true;
|
|
136
|
+
setBusy(true);
|
|
137
|
+
setFeedback(null);
|
|
138
|
+
try {
|
|
139
|
+
const result = await request<T>(document, variables, true);
|
|
140
|
+
if (!acknowledged(result))
|
|
141
|
+
throw new ShipmentStatusRequestError(
|
|
142
|
+
'The returned acknowledgement does not identify this decision.',
|
|
143
|
+
null
|
|
144
|
+
);
|
|
145
|
+
setFeedback({
|
|
146
|
+
kind: 'APPLIED',
|
|
147
|
+
message: 'Decision recorded. Review the refreshed status and history.',
|
|
148
|
+
});
|
|
149
|
+
setRevision((value) => value + 1);
|
|
150
|
+
return result;
|
|
151
|
+
} catch (failure) {
|
|
152
|
+
const known =
|
|
153
|
+
failure instanceof ShipmentStatusRequestError &&
|
|
154
|
+
failure.code !== null &&
|
|
155
|
+
KNOWN_REJECTIONS.has(failure.code);
|
|
156
|
+
uncertain.current = !known;
|
|
157
|
+
setFeedback({
|
|
158
|
+
kind: known ? 'REJECTED' : 'UNKNOWN',
|
|
159
|
+
message: known
|
|
160
|
+
? `${failure.message} Your input is preserved; refresh and review the recorded status before another action.`
|
|
161
|
+
: 'Acknowledgement is uncertain. Do not resend this action. Refresh the recorded status and review its decisions before beginning a separate new action.',
|
|
162
|
+
});
|
|
163
|
+
return null;
|
|
164
|
+
} finally {
|
|
165
|
+
lock.current = false;
|
|
166
|
+
setBusy(false);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
run,
|
|
171
|
+
busy,
|
|
172
|
+
feedback,
|
|
173
|
+
revision,
|
|
174
|
+
beginSeparateAction: () => {
|
|
175
|
+
uncertain.current = false;
|
|
176
|
+
setFeedback(null);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -108,6 +108,10 @@ const LOOKUP_DELIVERY_JOBS_QUERY = `
|
|
|
108
108
|
|
|
109
109
|
const DELIVERY_JOB_TRACKING_QUERY = `
|
|
110
110
|
query GetDeliveryJobTracking($deliveryJobId: ID!, $pagination: PaginationInput) {
|
|
111
|
+
shipments(filter: { deliveryJobId: $deliveryJobId }, pagination: { limit: 2, offset: 0 }) {
|
|
112
|
+
id
|
|
113
|
+
status
|
|
114
|
+
}
|
|
111
115
|
deliveryJobTracking(deliveryJobId: $deliveryJobId, pagination: $pagination) {
|
|
112
116
|
id
|
|
113
117
|
shipmentId
|
|
@@ -232,7 +236,8 @@ function mapTrackingEventToLocation(event: ApiTrackingEvent): Location | null {
|
|
|
232
236
|
|
|
233
237
|
function mapTrackingEventsToTracking(
|
|
234
238
|
events: ApiTrackingEvent[],
|
|
235
|
-
entityId: string
|
|
239
|
+
entityId: string,
|
|
240
|
+
recordedShipmentStatus?: string
|
|
236
241
|
): Tracking | null {
|
|
237
242
|
const latest = events[0];
|
|
238
243
|
if (!latest) return null;
|
|
@@ -248,7 +253,8 @@ function mapTrackingEventsToTracking(
|
|
|
248
253
|
entityType: 'order',
|
|
249
254
|
currentLocation,
|
|
250
255
|
locationHistory,
|
|
251
|
-
status
|
|
256
|
+
// Observation history is not shipment-status authority. Ambiguous/unlinked shipments stay unknown.
|
|
257
|
+
status: recordedShipmentStatus?.toLowerCase() ?? 'unknown',
|
|
252
258
|
estimatedArrival: undefined,
|
|
253
259
|
nextCheckpoint: latest.details ?? undefined,
|
|
254
260
|
alerts: [],
|
|
@@ -472,18 +478,23 @@ export function useOrderTracking(orderLookup: string | undefined) {
|
|
|
472
478
|
setTrackingLoading(true);
|
|
473
479
|
setTrackingError(null);
|
|
474
480
|
try {
|
|
475
|
-
const data = await executeGraphQL<{
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
);
|
|
481
|
+
const data = await executeGraphQL<{
|
|
482
|
+
deliveryJobTracking: ApiTrackingEvent[];
|
|
483
|
+
shipments: Array<{ id: string; status: string }>;
|
|
484
|
+
}>(graphQLOptions, DELIVERY_JOB_TRACKING_QUERY, {
|
|
485
|
+
deliveryJobId: resolvedOrderId,
|
|
486
|
+
pagination: { limit: 50, offset: 0 },
|
|
487
|
+
});
|
|
483
488
|
const events = [...data.deliveryJobTracking].sort(
|
|
484
489
|
(first, second) => Date.parse(second.timestamp) - Date.parse(first.timestamp)
|
|
485
490
|
);
|
|
486
|
-
setTracking(
|
|
491
|
+
setTracking(
|
|
492
|
+
mapTrackingEventsToTracking(
|
|
493
|
+
events,
|
|
494
|
+
resolvedOrderId,
|
|
495
|
+
data.shipments.length === 1 ? data.shipments[0]?.status : undefined
|
|
496
|
+
)
|
|
497
|
+
);
|
|
487
498
|
} catch (fetchError) {
|
|
488
499
|
setTrackingError(
|
|
489
500
|
fetchError instanceof Error ? fetchError : new Error('Failed to load order tracking')
|
|
@@ -580,7 +591,11 @@ export function useLiveTracking() {
|
|
|
580
591
|
order: mapOrder(deliveryJob),
|
|
581
592
|
driver: deliveryJob.driver ? mapDriver(deliveryJob.driver) : null,
|
|
582
593
|
vehicle: deliveryJob.vehicle ? mapVehicle(deliveryJob.vehicle) : null,
|
|
583
|
-
tracking: mapTrackingEventsToTracking(
|
|
594
|
+
tracking: mapTrackingEventsToTracking(
|
|
595
|
+
shipment.trackingEvents ?? [],
|
|
596
|
+
deliveryJob.id,
|
|
597
|
+
shipment.status
|
|
598
|
+
),
|
|
584
599
|
estimatedArrival: shipment.estimatedDelivery
|
|
585
600
|
? new Date(shipment.estimatedDelivery)
|
|
586
601
|
: undefined,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TrackingQualityIntegrationsDocument,
|
|
3
|
+
TrackingQualityDeliveriesDocument,
|
|
4
|
+
TrackingQualityShipmentDocument,
|
|
5
|
+
} from '../../generated/tracking-quality-operations';
|
|
6
|
+
export {
|
|
7
|
+
TrackingQualityIntegrationsDocument as QualityIntegrationsDocument,
|
|
8
|
+
TrackingQualityDeliveriesDocument as QualityDeliveriesDocument,
|
|
9
|
+
TrackingQualityShipmentDocument as QualityShipmentDocument,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface TrackingUpdateOutcome {
|
|
13
|
+
index: number;
|
|
14
|
+
externalEventId: string | null;
|
|
15
|
+
shipmentReference: string;
|
|
16
|
+
shipmentId: string | null;
|
|
17
|
+
status: 'ACCEPTED' | 'DUPLICATE' | 'CONFLICT' | 'REJECTED';
|
|
18
|
+
errorCode: string | null;
|
|
19
|
+
projection: {
|
|
20
|
+
status: 'APPLIED' | 'HELD' | 'LEGACY_NO_DECISION';
|
|
21
|
+
decisionId: string | null;
|
|
22
|
+
heldReason: string | null;
|
|
23
|
+
} | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const statuses = new Set(['ACCEPTED', 'DUPLICATE', 'CONFLICT', 'REJECTED']);
|
|
27
|
+
const heldReasons = new Set([
|
|
28
|
+
'LEGACY_AUTHORITY',
|
|
29
|
+
'MANUAL_AUTHORITY',
|
|
30
|
+
'NON_DESIGNATED_SOURCE',
|
|
31
|
+
'UNKNOWN_SOURCE_TIME',
|
|
32
|
+
'OLDER_SOURCE_TIME',
|
|
33
|
+
'EQUAL_SOURCE_TIME',
|
|
34
|
+
'SOURCE_TIME_TOO_OLD',
|
|
35
|
+
'SOURCE_TIME_IN_FUTURE',
|
|
36
|
+
'UNMAPPED_EVENT_TYPE',
|
|
37
|
+
'UNRECEIPTED_EVIDENCE',
|
|
38
|
+
]);
|
|
39
|
+
const codes = new Set([
|
|
40
|
+
'KEY_CONFLICT',
|
|
41
|
+
'BUDGET_EXHAUSTED',
|
|
42
|
+
'INTEGRATION_UNAVAILABLE',
|
|
43
|
+
'SHIPMENT_UNAVAILABLE',
|
|
44
|
+
'UPDATE_REJECTED',
|
|
45
|
+
]);
|
|
46
|
+
const bounded = (value: unknown, max: number): value is string =>
|
|
47
|
+
typeof value === 'string' && value.length <= max;
|
|
48
|
+
const nullable = (value: unknown, max: number): value is string | null =>
|
|
49
|
+
value === null || bounded(value, max);
|
|
50
|
+
|
|
51
|
+
/** JSON scalar decoding fails closed; legacy delivery history has no outcomes. */
|
|
52
|
+
export function trackingOutcomes(value: unknown): TrackingUpdateOutcome[] | null {
|
|
53
|
+
if (!Array.isArray(value) || value.length > 5) return null;
|
|
54
|
+
const result: TrackingUpdateOutcome[] = [];
|
|
55
|
+
for (const item of value) {
|
|
56
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) return null;
|
|
57
|
+
const row: Record<string, unknown> = item;
|
|
58
|
+
if (
|
|
59
|
+
!Number.isInteger(row.index) ||
|
|
60
|
+
typeof row.index !== 'number' ||
|
|
61
|
+
row.index !== result.length ||
|
|
62
|
+
row.index < 0 ||
|
|
63
|
+
row.index > 4 ||
|
|
64
|
+
!nullable(row.externalEventId, 255) ||
|
|
65
|
+
!bounded(row.shipmentReference, 255) ||
|
|
66
|
+
!nullable(row.shipmentId, 255) ||
|
|
67
|
+
typeof row.status !== 'string' ||
|
|
68
|
+
!statuses.has(row.status) ||
|
|
69
|
+
(row.status === 'CONFLICT' && row.shipmentId !== null) ||
|
|
70
|
+
!(row.errorCode === null || (typeof row.errorCode === 'string' && codes.has(row.errorCode)))
|
|
71
|
+
) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let projection: TrackingUpdateOutcome['projection'] = null;
|
|
75
|
+
const projectionStatus = row.projectionStatus;
|
|
76
|
+
const decisionId = row.projectionDecisionId ?? null;
|
|
77
|
+
const heldReason = row.projectionHeldReason ?? null;
|
|
78
|
+
if (projectionStatus !== undefined && projectionStatus !== null) {
|
|
79
|
+
if (row.status !== 'ACCEPTED' && row.status !== 'DUPLICATE') return null;
|
|
80
|
+
if (projectionStatus === 'LEGACY_NO_DECISION') {
|
|
81
|
+
if (row.status !== 'DUPLICATE' || decisionId !== null || heldReason !== null) return null;
|
|
82
|
+
} else if (projectionStatus === 'APPLIED' || projectionStatus === 'HELD') {
|
|
83
|
+
if (!bounded(decisionId, 255) || !decisionId) return null;
|
|
84
|
+
if (
|
|
85
|
+
projectionStatus === 'APPLIED'
|
|
86
|
+
? heldReason !== null
|
|
87
|
+
: typeof heldReason !== 'string' || !heldReasons.has(heldReason)
|
|
88
|
+
)
|
|
89
|
+
return null;
|
|
90
|
+
} else return null;
|
|
91
|
+
// The guarded finite status and bounded identifiers justify the JSON narrowing.
|
|
92
|
+
projection = {
|
|
93
|
+
status: projectionStatus,
|
|
94
|
+
decisionId: decisionId as string | null,
|
|
95
|
+
heldReason: heldReason as string | null,
|
|
96
|
+
};
|
|
97
|
+
} else if (decisionId !== null || heldReason !== null) return null;
|
|
98
|
+
// The finite status/code guards justify narrowing the untyped JSON scalar.
|
|
99
|
+
result.push({
|
|
100
|
+
index: row.index,
|
|
101
|
+
externalEventId: row.externalEventId,
|
|
102
|
+
shipmentReference: row.shipmentReference,
|
|
103
|
+
shipmentId: row.shipmentId,
|
|
104
|
+
status: row.status as TrackingUpdateOutcome['status'],
|
|
105
|
+
errorCode: row.errorCode,
|
|
106
|
+
projection,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|