@burdenoff/microfe-movethewheels 2026.1016.2 → 2026.1016.3
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/components/freight-cost/FreightCommercialEditor.js +217 -0
- package/dist/components/freight-cost/FreightCommercialFactEditor.js +217 -0
- package/dist/components/freight-cost/FreightCommercialHistory.js +115 -0
- package/dist/components/freight-cost/FreightDraftForm.js +106 -91
- package/dist/components/freight-cost/FreightHistory.js +42 -40
- package/dist/generated/freight-cost-operations.d.ts +959 -0
- package/dist/hooks/useFreightRecoveryAction.js +20 -20
- package/dist/movethewheels/components/freight-cost/FreightCommercialEditor.d.ts +8 -0
- package/dist/movethewheels/components/freight-cost/FreightCommercialFactEditor.d.ts +8 -0
- package/dist/movethewheels/components/freight-cost/FreightCommercialHistory.d.ts +4 -0
- package/dist/movethewheels/components/freight-cost/FreightDraftForm.d.ts +4 -2
- package/dist/movethewheels/components/freight-cost/FreightHistory.d.ts +2 -2
- package/dist/movethewheels/utils/freightCommercial.d.ts +44 -0
- package/dist/pages/FreightEstimatePage.js +96 -92
- package/dist/src/generated/freight-cost-operations.js +495 -6
- package/dist/utils/freightAcknowledgement.js +6 -5
- package/dist/utils/freightCommercial.js +194 -0
- package/dist/utils/freightRecovery.js +22 -22
- package/package.json +4 -3
- package/src/generated/freight-cost-operations.ts +831 -1
- package/src/movethewheels/components/freight-cost/FreightCommercialEditor.tsx +236 -0
- package/src/movethewheels/components/freight-cost/FreightCommercialFactEditor.tsx +258 -0
- package/src/movethewheels/components/freight-cost/FreightCommercialHistory.tsx +90 -0
- package/src/movethewheels/components/freight-cost/FreightDraftForm.tsx +33 -4
- package/src/movethewheels/components/freight-cost/FreightHistory.tsx +4 -2
- package/src/movethewheels/hooks/useFreightRecoveryAction.ts +2 -1
- package/src/movethewheels/pages/FreightEstimatePage.tsx +14 -7
- package/src/movethewheels/utils/freightAcknowledgement.ts +3 -1
- package/src/movethewheels/utils/freightCommercial.ts +388 -0
- package/src/movethewheels/utils/freightRecovery.ts +6 -2
- package/src/operations/freight-cost/commercial.graphql +483 -0
|
@@ -5,6 +5,7 @@ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
|
5
5
|
import { matchesFreightAcknowledgement } from '../utils/freightAcknowledgement';
|
|
6
6
|
import {
|
|
7
7
|
SaveFreightCostsDocument,
|
|
8
|
+
SaveFreightCommercialCostsDocument,
|
|
8
9
|
type SaveFreightCostsMutation,
|
|
9
10
|
} from '../../generated/freight-cost-operations';
|
|
10
11
|
import {
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
encodeFreightRecovery,
|
|
19
20
|
FREIGHT_RECOVERY_STORAGE,
|
|
20
21
|
} from '../utils/freightRecovery';
|
|
21
|
-
const documents = [SaveFreightCostsDocument];
|
|
22
|
+
const documents = [SaveFreightCostsDocument, SaveFreightCommercialCostsDocument];
|
|
22
23
|
function loadRecovery(): { attempt: ReuseRecovery | null; blocked: boolean } {
|
|
23
24
|
try {
|
|
24
25
|
const raw = sessionStorage.getItem(FREIGHT_RECOVERY_STORAGE);
|
|
@@ -13,16 +13,18 @@ import {
|
|
|
13
13
|
FreightShipmentsDocument,
|
|
14
14
|
FreightShipmentDocument,
|
|
15
15
|
FreightHistoryDocument,
|
|
16
|
-
|
|
16
|
+
FreightCommercialVersionDocument,
|
|
17
17
|
PreviewFreightCostsDocument,
|
|
18
18
|
SaveFreightCostsDocument,
|
|
19
|
+
SaveFreightCommercialCostsDocument,
|
|
19
20
|
type FreightShipmentsQuery,
|
|
20
21
|
type FreightShipmentQuery,
|
|
21
22
|
type FreightHistoryQuery,
|
|
22
|
-
type
|
|
23
|
+
type FreightCommercialVersionQuery,
|
|
23
24
|
type MtwFreightFactsInput,
|
|
24
25
|
type PreviewFreightCostsQuery,
|
|
25
26
|
} from '../../generated/freight-cost-operations';
|
|
27
|
+
import type { CommercialCapture } from '../utils/freightCommercial';
|
|
26
28
|
import type { SelectedEvidence } from '../components/ContainerEvidencePicker';
|
|
27
29
|
export default function FreightEstimatePage() {
|
|
28
30
|
const actions = useFreightRecoveryAction();
|
|
@@ -93,8 +95,8 @@ function FreightWorkbench({ actions }: { actions: ReturnType<typeof useFreightRe
|
|
|
93
95
|
: null,
|
|
94
96
|
actions.revision
|
|
95
97
|
);
|
|
96
|
-
const exact = useConditionQuery<
|
|
97
|
-
|
|
98
|
+
const exact = useConditionQuery<FreightCommercialVersionQuery>(
|
|
99
|
+
FreightCommercialVersionDocument,
|
|
98
100
|
historyTarget && selected ? { shipmentId: historyTarget.shipmentId, id: selected } : null,
|
|
99
101
|
actions.revision
|
|
100
102
|
);
|
|
@@ -112,7 +114,11 @@ function FreightWorkbench({ actions }: { actions: ReturnType<typeof useFreightRe
|
|
|
112
114
|
})) ?? [];
|
|
113
115
|
if (shipment && !options.some((row) => row.id === shipment.id))
|
|
114
116
|
options.unshift({ id: shipment.id, label: `${shipment.label} — selected outside this page` });
|
|
115
|
-
const save = async (
|
|
117
|
+
const save = async (
|
|
118
|
+
facts: MtwFreightFactsInput,
|
|
119
|
+
evidence: SelectedEvidence[],
|
|
120
|
+
commercialCapture?: CommercialCapture
|
|
121
|
+
) => {
|
|
116
122
|
if (preflight.current || paused) return;
|
|
117
123
|
if (!draft) throw new Error('Review the shipment and current cost history first.');
|
|
118
124
|
preflight.current = true;
|
|
@@ -139,8 +145,8 @@ function FreightWorkbench({ actions }: { actions: ReturnType<typeof useFreightRe
|
|
|
139
145
|
evidence.map(({ fileId, versionId }) => ({ fileId, versionId }))
|
|
140
146
|
);
|
|
141
147
|
const result = await actions.run(
|
|
142
|
-
SaveFreightCostsDocument,
|
|
143
|
-
input,
|
|
148
|
+
commercialCapture ? SaveFreightCommercialCostsDocument : SaveFreightCostsDocument,
|
|
149
|
+
commercialCapture ? { ...input, commercialCapture } : input,
|
|
144
150
|
'Sourced cost version recorded.'
|
|
145
151
|
);
|
|
146
152
|
if (result && active.current) {
|
|
@@ -250,6 +256,7 @@ function FreightWorkbench({ actions }: { actions: ReturnType<typeof useFreightRe
|
|
|
250
256
|
{draft && !changed && !mismatch && (
|
|
251
257
|
<FreightDraftForm
|
|
252
258
|
key={draft.generation}
|
|
259
|
+
shipmentId={draft.shipmentId}
|
|
253
260
|
disabled={
|
|
254
261
|
paused || current.loading || latest.loading || !!current.error || !!latest.error
|
|
255
262
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesCommercialCapture } from './freightCommercial';
|
|
1
2
|
/** Validate the minimum immutable receipt identity before resolving a retained request. */
|
|
2
3
|
export function matchesFreightAcknowledgement(
|
|
3
4
|
result: unknown,
|
|
@@ -26,6 +27,7 @@ export function matchesFreightAcknowledgement(
|
|
|
26
27
|
input.expectedVersion >= 0 &&
|
|
27
28
|
receipt.version === input.expectedVersion + 1 &&
|
|
28
29
|
'previousVersionId' in receipt &&
|
|
29
|
-
receipt.previousVersionId === input.expectedPreviousVersionId
|
|
30
|
+
receipt.previousVersionId === input.expectedPreviousVersionId &&
|
|
31
|
+
(input.commercialCapture == null || matchesCommercialCapture(receipt, input))
|
|
30
32
|
);
|
|
31
33
|
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MtwFreightCommercialCaptureInput,
|
|
3
|
+
MtwFreightCommercialLocationFactInput,
|
|
4
|
+
MtwFreightCommercialStatus,
|
|
5
|
+
MtwFreightCommercialProvenanceInput,
|
|
6
|
+
FreightCommercialLegsQuery,
|
|
7
|
+
} from '../../generated/freight-cost-operations';
|
|
8
|
+
|
|
9
|
+
export const commercialSlots = [
|
|
10
|
+
'origin',
|
|
11
|
+
'destination',
|
|
12
|
+
'carrier',
|
|
13
|
+
'service',
|
|
14
|
+
'cargo',
|
|
15
|
+
'weight',
|
|
16
|
+
'volume',
|
|
17
|
+
'equipment',
|
|
18
|
+
] as const;
|
|
19
|
+
export type CommercialSlot = (typeof commercialSlots)[number];
|
|
20
|
+
export type CommercialLeg = NonNullable<FreightCommercialLegsQuery['shipment']>['legs'][number];
|
|
21
|
+
export type CommercialCapture = MtwFreightCommercialCaptureInput;
|
|
22
|
+
export const unrecordedCommercialReason = 'Not recorded for this cost version';
|
|
23
|
+
export interface CommercialFactDraft {
|
|
24
|
+
status: MtwFreightCommercialStatus;
|
|
25
|
+
reason: string;
|
|
26
|
+
label: string;
|
|
27
|
+
reference: string;
|
|
28
|
+
description: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
jurisdiction: string;
|
|
31
|
+
amount: string;
|
|
32
|
+
unit: string;
|
|
33
|
+
zeroReason: string;
|
|
34
|
+
count: string;
|
|
35
|
+
sourceReference: string;
|
|
36
|
+
sourceRevision: string;
|
|
37
|
+
observedAt: string;
|
|
38
|
+
evidenceLinks: { fileId: string; versionId: string }[];
|
|
39
|
+
}
|
|
40
|
+
export type CommercialDraft = Record<CommercialSlot, CommercialFactDraft>;
|
|
41
|
+
export interface CommercialEditorState {
|
|
42
|
+
facts: CommercialDraft;
|
|
43
|
+
selectedLegs: CommercialCapture['selectedLegs'];
|
|
44
|
+
reviewed: string | null;
|
|
45
|
+
}
|
|
46
|
+
export function newCommercialEditor(): CommercialEditorState {
|
|
47
|
+
return { facts: newCommercialDraft(), selectedLegs: [], reviewed: null };
|
|
48
|
+
}
|
|
49
|
+
export function newCommercialFact(
|
|
50
|
+
status: MtwFreightCommercialStatus = 'UNKNOWN'
|
|
51
|
+
): CommercialFactDraft {
|
|
52
|
+
return {
|
|
53
|
+
status,
|
|
54
|
+
reason: status === 'UNKNOWN' ? unrecordedCommercialReason : '',
|
|
55
|
+
label: '',
|
|
56
|
+
reference: '',
|
|
57
|
+
description: '',
|
|
58
|
+
kind: '',
|
|
59
|
+
jurisdiction: '',
|
|
60
|
+
amount: '',
|
|
61
|
+
unit: '',
|
|
62
|
+
zeroReason: '',
|
|
63
|
+
count: '',
|
|
64
|
+
sourceReference: '',
|
|
65
|
+
sourceRevision: '',
|
|
66
|
+
observedAt: '',
|
|
67
|
+
evidenceLinks: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function newCommercialDraft(): CommercialDraft {
|
|
71
|
+
return {
|
|
72
|
+
origin: newCommercialFact(),
|
|
73
|
+
destination: newCommercialFact(),
|
|
74
|
+
carrier: newCommercialFact(),
|
|
75
|
+
service: newCommercialFact(),
|
|
76
|
+
cargo: newCommercialFact(),
|
|
77
|
+
weight: newCommercialFact(),
|
|
78
|
+
volume: newCommercialFact(),
|
|
79
|
+
equipment: newCommercialFact(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function text(value: string, limit: number, label: string): string {
|
|
83
|
+
const result = value.trim();
|
|
84
|
+
if (!result || result.length > limit) throw new Error(`${label}: enter 1–${limit} characters.`);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function instant(value: string): string {
|
|
88
|
+
if (
|
|
89
|
+
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
|
|
90
|
+
new Date(value).toISOString() !== value ||
|
|
91
|
+
Date.parse(value) > Date.now()
|
|
92
|
+
)
|
|
93
|
+
throw new Error('Use a valid observed time no later than now.');
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function decimal(value: string, zeroReason: string) {
|
|
97
|
+
if (!/^(0|[1-9][0-9]{0,17})(\.[0-9]{0,5}[1-9])?$/.test(value))
|
|
98
|
+
throw new Error(
|
|
99
|
+
'Use a canonical nonnegative decimal: up to 18 integer and 6 fractional digits, no exponent or trailing fractional zero.'
|
|
100
|
+
);
|
|
101
|
+
if (value === '0') return text(zeroReason, 255, 'Recorded zero reason');
|
|
102
|
+
if (zeroReason.trim()) throw new Error('A zero reason belongs only to an amount of zero.');
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function provenance(
|
|
106
|
+
row: CommercialFactDraft,
|
|
107
|
+
evidence: { fileId: string; versionId: string }[]
|
|
108
|
+
): MtwFreightCommercialProvenanceInput {
|
|
109
|
+
const links = row.evidenceLinks;
|
|
110
|
+
if (
|
|
111
|
+
links.length < 1 ||
|
|
112
|
+
links.length > 2 ||
|
|
113
|
+
new Set(links.map((link) => JSON.stringify([link.fileId, link.versionId]))).size !==
|
|
114
|
+
links.length ||
|
|
115
|
+
links.some(
|
|
116
|
+
(link) =>
|
|
117
|
+
!evidence.some((item) => item.fileId === link.fileId && item.versionId === link.versionId)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
throw new Error(
|
|
121
|
+
'Choose 1–2 distinct exact evidence versions already selected for this cost record.'
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
sourceReference: text(row.sourceReference, 255, 'Fact source'),
|
|
125
|
+
sourceRevision: text(row.sourceRevision, 255, 'Fact source revision'),
|
|
126
|
+
observedAt: row.observedAt ? instant(row.observedAt) : null,
|
|
127
|
+
evidenceLinks: [...links]
|
|
128
|
+
.sort((a, b) =>
|
|
129
|
+
JSON.stringify([a.fileId, a.versionId]) < JSON.stringify([b.fileId, b.versionId])
|
|
130
|
+
? -1
|
|
131
|
+
: JSON.stringify([a.fileId, a.versionId]) > JSON.stringify([b.fileId, b.versionId])
|
|
132
|
+
? 1
|
|
133
|
+
: 0
|
|
134
|
+
)
|
|
135
|
+
.map((link) => ({ ...link })),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** No source values are inferred from the owner snapshot or other fact slots. */
|
|
139
|
+
export function commercialCaptureInput(
|
|
140
|
+
draft: CommercialDraft,
|
|
141
|
+
pins: CommercialCapture['selectedLegs'],
|
|
142
|
+
evidence: { fileId: string; versionId: string }[]
|
|
143
|
+
): CommercialCapture {
|
|
144
|
+
const common = (key: CommercialSlot) => {
|
|
145
|
+
const row = draft[key];
|
|
146
|
+
if (row.status === 'UNKNOWN')
|
|
147
|
+
return {
|
|
148
|
+
status: row.status,
|
|
149
|
+
value: null,
|
|
150
|
+
reason: text(row.reason, 255, key),
|
|
151
|
+
provenance: null,
|
|
152
|
+
};
|
|
153
|
+
if (row.status !== 'SOURCED' && row.status !== 'NOT_APPLICABLE')
|
|
154
|
+
throw new Error('Choose a supported commercial fact status.');
|
|
155
|
+
return {
|
|
156
|
+
status: row.status,
|
|
157
|
+
value: null,
|
|
158
|
+
reason: row.status === 'NOT_APPLICABLE' ? text(row.reason, 255, key) : null,
|
|
159
|
+
provenance: provenance(row, evidence),
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const location = (key: 'origin' | 'destination'): MtwFreightCommercialLocationFactInput => {
|
|
163
|
+
const base = common(key),
|
|
164
|
+
row = draft[key];
|
|
165
|
+
if (base.status !== 'SOURCED') return base;
|
|
166
|
+
if (
|
|
167
|
+
row.kind !== 'ADDRESS_LABEL' &&
|
|
168
|
+
row.kind !== 'PORT_LABEL' &&
|
|
169
|
+
row.kind !== 'TERMINAL_LABEL' &&
|
|
170
|
+
row.kind !== 'POSTCODE_LABEL' &&
|
|
171
|
+
row.kind !== 'CUSTOM_LABEL'
|
|
172
|
+
)
|
|
173
|
+
throw new Error('Choose a location label kind.');
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
value: {
|
|
177
|
+
kind: row.kind,
|
|
178
|
+
label: text(row.label, 255, key),
|
|
179
|
+
jurisdictionLabel: row.jurisdiction.trim()
|
|
180
|
+
? text(row.jurisdiction, 120, 'Jurisdiction label')
|
|
181
|
+
: null,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const weight = common('weight'),
|
|
186
|
+
volume = common('volume'),
|
|
187
|
+
carrier = common('carrier'),
|
|
188
|
+
service = common('service'),
|
|
189
|
+
cargo = common('cargo'),
|
|
190
|
+
equipment = common('equipment');
|
|
191
|
+
const w = draft.weight,
|
|
192
|
+
v = draft.volume;
|
|
193
|
+
if (w.status === 'SOURCED' && w.unit !== 'KG' && w.unit !== 'TONNE' && w.unit !== 'LB')
|
|
194
|
+
throw new Error('Choose a recorded weight unit.');
|
|
195
|
+
if (v.status === 'SOURCED' && v.unit !== 'M3' && v.unit !== 'FT3')
|
|
196
|
+
throw new Error('Choose a recorded volume unit.');
|
|
197
|
+
if (
|
|
198
|
+
draft.equipment.status === 'SOURCED' &&
|
|
199
|
+
(!/^[1-9][0-9]{0,5}$/.test(draft.equipment.count) || Number(draft.equipment.count) > 100000)
|
|
200
|
+
)
|
|
201
|
+
throw new Error('Equipment count must be an integer from 1 to 100000.');
|
|
202
|
+
if (pins.length > 16 || new Set(pins.map((pin) => pin.id)).size !== pins.length)
|
|
203
|
+
throw new Error('Select at most 16 distinct reviewed legs.');
|
|
204
|
+
const selectedLegs = pins
|
|
205
|
+
.map((pin) => ({ id: text(pin.id, 200, 'Leg ID'), expectedUpdatedAt: pin.expectedUpdatedAt }))
|
|
206
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
207
|
+
const result: CommercialCapture = {
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
selectedLegs,
|
|
210
|
+
origin: location('origin'),
|
|
211
|
+
destination: location('destination'),
|
|
212
|
+
carrier:
|
|
213
|
+
carrier.status === 'SOURCED'
|
|
214
|
+
? {
|
|
215
|
+
...carrier,
|
|
216
|
+
value: {
|
|
217
|
+
reference: text(draft.carrier.reference, 120, 'Carrier reference'),
|
|
218
|
+
label: text(draft.carrier.label, 255, 'Carrier label'),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
: carrier,
|
|
222
|
+
service:
|
|
223
|
+
service.status === 'SOURCED'
|
|
224
|
+
? { ...service, value: text(draft.service.label, 120, 'Service') }
|
|
225
|
+
: service,
|
|
226
|
+
cargo:
|
|
227
|
+
cargo.status === 'SOURCED'
|
|
228
|
+
? {
|
|
229
|
+
...cargo,
|
|
230
|
+
value: {
|
|
231
|
+
classificationLabel: text(draft.cargo.label, 120, 'Cargo classification'),
|
|
232
|
+
description: text(draft.cargo.description, 500, 'Cargo description'),
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
: cargo,
|
|
236
|
+
weight:
|
|
237
|
+
weight.status === 'SOURCED'
|
|
238
|
+
? {
|
|
239
|
+
...weight,
|
|
240
|
+
value: {
|
|
241
|
+
amount: w.amount,
|
|
242
|
+
unit: w.unit === 'KG' ? 'KG' : w.unit === 'TONNE' ? 'TONNE' : 'LB',
|
|
243
|
+
zeroReason: decimal(w.amount, w.zeroReason),
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: weight,
|
|
247
|
+
volume:
|
|
248
|
+
volume.status === 'SOURCED'
|
|
249
|
+
? {
|
|
250
|
+
...volume,
|
|
251
|
+
value: {
|
|
252
|
+
amount: v.amount,
|
|
253
|
+
unit: v.unit === 'M3' ? 'M3' : 'FT3',
|
|
254
|
+
zeroReason: decimal(v.amount, v.zeroReason),
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
: volume,
|
|
258
|
+
equipment:
|
|
259
|
+
equipment.status === 'SOURCED'
|
|
260
|
+
? {
|
|
261
|
+
...equipment,
|
|
262
|
+
value: {
|
|
263
|
+
typeLabel: text(draft.equipment.label, 120, 'Equipment type'),
|
|
264
|
+
count: Number(draft.equipment.count),
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
: equipment,
|
|
268
|
+
};
|
|
269
|
+
if (new TextEncoder().encode(JSON.stringify(result)).byteLength > 32768)
|
|
270
|
+
throw new Error('Commercial capture exceeds 32768 bytes. Shorten facts before saving.');
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function commercialPinsMatch(
|
|
275
|
+
pins: CommercialCapture['selectedLegs'],
|
|
276
|
+
legs: CommercialLeg[],
|
|
277
|
+
shipmentId: string
|
|
278
|
+
): boolean {
|
|
279
|
+
return (
|
|
280
|
+
pins.length <= 16 &&
|
|
281
|
+
new Set(pins.map((pin) => pin.id)).size === pins.length &&
|
|
282
|
+
pins.every((pin) =>
|
|
283
|
+
legs.some(
|
|
284
|
+
(leg) =>
|
|
285
|
+
leg.id === pin.id &&
|
|
286
|
+
leg.shipmentId === shipmentId &&
|
|
287
|
+
leg.updatedAt === pin.expectedUpdatedAt
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Compare typed GraphQL output with the canonical input without trusting __typename or key order. */
|
|
294
|
+
export function commercialCanonical(value: unknown): string {
|
|
295
|
+
function ordered(v: unknown): unknown {
|
|
296
|
+
if (Array.isArray(v)) return v.map(ordered);
|
|
297
|
+
if (v && typeof v === 'object')
|
|
298
|
+
return Object.fromEntries(
|
|
299
|
+
Object.entries(v)
|
|
300
|
+
.filter(([key]) => key !== '__typename')
|
|
301
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
302
|
+
.map(([key, item]) => [key, ordered(item)])
|
|
303
|
+
);
|
|
304
|
+
return v;
|
|
305
|
+
}
|
|
306
|
+
return JSON.stringify(ordered(value));
|
|
307
|
+
}
|
|
308
|
+
export function matchesCommercialCapture(
|
|
309
|
+
receipt: unknown,
|
|
310
|
+
input: Record<string, unknown>
|
|
311
|
+
): boolean {
|
|
312
|
+
if (
|
|
313
|
+
!receipt ||
|
|
314
|
+
typeof receipt !== 'object' ||
|
|
315
|
+
!('commercialCapture' in receipt) ||
|
|
316
|
+
!receipt.commercialCapture ||
|
|
317
|
+
typeof receipt.commercialCapture !== 'object'
|
|
318
|
+
)
|
|
319
|
+
return false;
|
|
320
|
+
const capture = receipt.commercialCapture;
|
|
321
|
+
if (
|
|
322
|
+
!input.commercialCapture ||
|
|
323
|
+
typeof input.commercialCapture !== 'object' ||
|
|
324
|
+
!('selectedLegs' in input.commercialCapture) ||
|
|
325
|
+
!Array.isArray(input.commercialCapture.selectedLegs) ||
|
|
326
|
+
!('ownerShipment' in capture) ||
|
|
327
|
+
!capture.ownerShipment ||
|
|
328
|
+
typeof capture.ownerShipment !== 'object' ||
|
|
329
|
+
!('shipmentId' in capture.ownerShipment) ||
|
|
330
|
+
capture.ownerShipment.shipmentId !== input.shipmentId ||
|
|
331
|
+
!('updatedAt' in capture.ownerShipment) ||
|
|
332
|
+
capture.ownerShipment.updatedAt !== input.expectedShipmentUpdatedAt ||
|
|
333
|
+
!('primaryMode' in capture.ownerShipment) ||
|
|
334
|
+
typeof capture.ownerShipment.primaryMode !== 'string' ||
|
|
335
|
+
!['AIR', 'OCEAN', 'RAIL', 'ROAD'].includes(capture.ownerShipment.primaryMode) ||
|
|
336
|
+
!('ownerLegs' in capture) ||
|
|
337
|
+
!Array.isArray(capture.ownerLegs)
|
|
338
|
+
)
|
|
339
|
+
return false;
|
|
340
|
+
const declared = Object.fromEntries(
|
|
341
|
+
Object.entries(capture).filter(([key]) => key !== 'ownerShipment' && key !== 'ownerLegs')
|
|
342
|
+
);
|
|
343
|
+
if (commercialCanonical(declared) !== commercialCanonical(input.commercialCapture)) return false;
|
|
344
|
+
const pins = input.commercialCapture.selectedLegs;
|
|
345
|
+
const ownerLegs = capture.ownerLegs;
|
|
346
|
+
return (
|
|
347
|
+
ownerLegs.length === pins.length &&
|
|
348
|
+
pins.every(
|
|
349
|
+
(pin) =>
|
|
350
|
+
pin &&
|
|
351
|
+
typeof pin === 'object' &&
|
|
352
|
+
'id' in pin &&
|
|
353
|
+
'expectedUpdatedAt' in pin &&
|
|
354
|
+
ownerLegs.some(
|
|
355
|
+
(leg: unknown) =>
|
|
356
|
+
leg &&
|
|
357
|
+
typeof leg === 'object' &&
|
|
358
|
+
'id' in leg &&
|
|
359
|
+
leg.id === pin.id &&
|
|
360
|
+
'updatedAt' in leg &&
|
|
361
|
+
leg.updatedAt === pin.expectedUpdatedAt &&
|
|
362
|
+
'legNumber' in leg &&
|
|
363
|
+
typeof leg.legNumber === 'number' &&
|
|
364
|
+
Number.isInteger(leg.legNumber) &&
|
|
365
|
+
leg.legNumber > 0 &&
|
|
366
|
+
'mode' in leg &&
|
|
367
|
+
typeof leg.mode === 'string' &&
|
|
368
|
+
['AIR', 'OCEAN', 'RAIL', 'ROAD'].includes(leg.mode) &&
|
|
369
|
+
['carrierId', 'carrierName'].every(
|
|
370
|
+
(key) =>
|
|
371
|
+
key in leg &&
|
|
372
|
+
(Reflect.get(leg, key) === null || typeof Reflect.get(leg, key) === 'string')
|
|
373
|
+
) &&
|
|
374
|
+
['weightKg', 'volumeCbm', 'containerCount'].every(
|
|
375
|
+
(key) =>
|
|
376
|
+
key in leg &&
|
|
377
|
+
(Reflect.get(leg, key) === null ||
|
|
378
|
+
(typeof Reflect.get(leg, key) === 'number' &&
|
|
379
|
+
Number.isFinite(Reflect.get(leg, key))))
|
|
380
|
+
) &&
|
|
381
|
+
'containerType' in leg &&
|
|
382
|
+
(leg.containerType === null ||
|
|
383
|
+
(typeof leg.containerType === 'string' &&
|
|
384
|
+
['FCL_20FT', 'FCL_40FT', 'FCL_40FT_HC', 'LCL'].includes(leg.containerType)))
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { print } from 'graphql';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
SaveFreightCostsDocument,
|
|
4
|
+
SaveFreightCommercialCostsDocument,
|
|
5
|
+
} from '../../generated/freight-cost-operations';
|
|
3
6
|
import type { ReuseRecovery } from './reuseRecovery';
|
|
4
7
|
export const FREIGHT_RECOVERY_STORAGE = 'movethewheels.freight.unresolved.v1';
|
|
5
8
|
export function decodeFreightRecovery(raw: string | null): ReuseRecovery | null {
|
|
@@ -14,7 +17,8 @@ export function decodeFreightRecovery(raw: string | null): ReuseRecovery | null
|
|
|
14
17
|
!('scope' in value) ||
|
|
15
18
|
!('input' in value) ||
|
|
16
19
|
!('operation' in value) ||
|
|
17
|
-
value.operation !== print(SaveFreightCostsDocument)
|
|
20
|
+
(value.operation !== print(SaveFreightCostsDocument) &&
|
|
21
|
+
value.operation !== print(SaveFreightCommercialCostsDocument)) ||
|
|
18
22
|
!('key' in value) ||
|
|
19
23
|
typeof value.key !== 'string' ||
|
|
20
24
|
!value.key ||
|