@shopickup/adapters-mpl 0.0.1
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 -0
- package/dist/capabilities/auth.d.ts +39 -0
- package/dist/capabilities/auth.d.ts.map +1 -0
- package/dist/capabilities/auth.js +130 -0
- package/dist/capabilities/close.d.ts +8 -0
- package/dist/capabilities/close.d.ts.map +1 -0
- package/dist/capabilities/close.js +70 -0
- package/dist/capabilities/get-shipment-details.d.ts +63 -0
- package/dist/capabilities/get-shipment-details.d.ts.map +1 -0
- package/dist/capabilities/get-shipment-details.js +97 -0
- package/dist/capabilities/index.d.ts +10 -0
- package/dist/capabilities/index.d.ts.map +1 -0
- package/dist/capabilities/index.js +9 -0
- package/dist/capabilities/label.d.ts +33 -0
- package/dist/capabilities/label.d.ts.map +1 -0
- package/dist/capabilities/label.js +328 -0
- package/dist/capabilities/parcels.d.ts +33 -0
- package/dist/capabilities/parcels.d.ts.map +1 -0
- package/dist/capabilities/parcels.js +284 -0
- package/dist/capabilities/pickup-points.d.ts +41 -0
- package/dist/capabilities/pickup-points.d.ts.map +1 -0
- package/dist/capabilities/pickup-points.js +294 -0
- package/dist/capabilities/track.d.ts +72 -0
- package/dist/capabilities/track.d.ts.map +1 -0
- package/dist/capabilities/track.js +331 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +142 -0
- package/dist/mappers/label.d.ts +67 -0
- package/dist/mappers/label.d.ts.map +1 -0
- package/dist/mappers/label.js +83 -0
- package/dist/mappers/shipment.d.ts +110 -0
- package/dist/mappers/shipment.d.ts.map +1 -0
- package/dist/mappers/shipment.js +258 -0
- package/dist/mappers/tracking.d.ts +60 -0
- package/dist/mappers/tracking.d.ts.map +1 -0
- package/dist/mappers/tracking.js +187 -0
- package/dist/utils/httpUtils.d.ts +36 -0
- package/dist/utils/httpUtils.d.ts.map +1 -0
- package/dist/utils/httpUtils.js +76 -0
- package/dist/utils/oauthFallback.d.ts +47 -0
- package/dist/utils/oauthFallback.d.ts.map +1 -0
- package/dist/utils/oauthFallback.js +250 -0
- package/dist/utils/resolveBaseUrl.d.ts +75 -0
- package/dist/utils/resolveBaseUrl.d.ts.map +1 -0
- package/dist/utils/resolveBaseUrl.js +65 -0
- package/dist/validation.d.ts +1890 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +726 -0
- package/package.json +69 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPL Adapter: Label Generation Capability
|
|
3
|
+
* Handles CREATE_LABEL and CREATE_LABELS operations
|
|
4
|
+
*
|
|
5
|
+
* Key differences from Foxpost:
|
|
6
|
+
* - Uses GET request instead of POST
|
|
7
|
+
* - Returns JSON array with base64-encoded label data
|
|
8
|
+
* - Multiple query parameters including labelType, labelFormat, orderBy, singleFile
|
|
9
|
+
* - Per-item error handling in the response array
|
|
10
|
+
*/
|
|
11
|
+
import { CarrierError, errorToLog, serializeForLog } from "@shopickup/core";
|
|
12
|
+
import { safeValidateCreateLabelRequest, safeValidateCreateLabelsRequest } from '../validation.js';
|
|
13
|
+
import { buildMPLHeaders } from '../utils/httpUtils.js';
|
|
14
|
+
import { buildLabelQueryParams, serializeQueryParams } from '../mappers/label.js';
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
const BLOB_FIELDS = new Set(['label', 'labelBase64']);
|
|
17
|
+
function isSerializedBuffer(value) {
|
|
18
|
+
return (!!value &&
|
|
19
|
+
typeof value === 'object' &&
|
|
20
|
+
value.type === 'Buffer' &&
|
|
21
|
+
Array.isArray(value.data));
|
|
22
|
+
}
|
|
23
|
+
function summarizeBufferLike(value) {
|
|
24
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
25
|
+
return {
|
|
26
|
+
omittedBinary: true,
|
|
27
|
+
byteLength: value.byteLength,
|
|
28
|
+
note: 'binary payload omitted from rawCarrierResponse',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
omittedBinary: true,
|
|
33
|
+
byteLength: value.data.length,
|
|
34
|
+
note: 'binary payload omitted from rawCarrierResponse',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function sanitizeRawValue(value, keyHint) {
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
if (keyHint && BLOB_FIELDS.has(keyHint)) {
|
|
40
|
+
return `[truncated ${keyHint}; length=${value.length}]`;
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
45
|
+
return summarizeBufferLike(value);
|
|
46
|
+
}
|
|
47
|
+
if (isSerializedBuffer(value)) {
|
|
48
|
+
return summarizeBufferLike(value);
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return value.map((item) => sanitizeRawValue(item));
|
|
52
|
+
}
|
|
53
|
+
if (value && typeof value === 'object') {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
56
|
+
out[key] = sanitizeRawValue(nested, key);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function sanitizeRawCarrierResponse(raw) {
|
|
63
|
+
return sanitizeRawValue(raw);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Create a label (generate PDF) for a single parcel
|
|
67
|
+
* Delegates to createLabels to reuse batching logic
|
|
68
|
+
*
|
|
69
|
+
* Returns Promise<LabelResult> with file mapping and metadata
|
|
70
|
+
*/
|
|
71
|
+
export async function createLabel(req, ctx, resolveBaseUrl) {
|
|
72
|
+
// Validate single-label request using carrier-specific schema
|
|
73
|
+
const validated = safeValidateCreateLabelRequest(req);
|
|
74
|
+
if (!validated.success) {
|
|
75
|
+
throw new CarrierError(`Invalid request: ${validated.error.message}`, "Validation", { raw: serializeForLog(validated.error) });
|
|
76
|
+
}
|
|
77
|
+
// Build batch request from validated single request
|
|
78
|
+
const batchReq = {
|
|
79
|
+
parcelCarrierIds: [validated.data.parcelCarrierId],
|
|
80
|
+
credentials: validated.data.credentials,
|
|
81
|
+
options: validated.data.options,
|
|
82
|
+
};
|
|
83
|
+
// Delegate to batch implementation
|
|
84
|
+
const response = await createLabels(batchReq, ctx, resolveBaseUrl);
|
|
85
|
+
// Extract first (only) result
|
|
86
|
+
if (!response || !Array.isArray(response.results)) {
|
|
87
|
+
throw new CarrierError("Unexpected response shape from createLabels", "Transient", { raw: serializeForLog(response) });
|
|
88
|
+
}
|
|
89
|
+
const results = response.results;
|
|
90
|
+
if (results.length === 0) {
|
|
91
|
+
throw new CarrierError("createLabels returned an empty results array", "Transient", { raw: serializeForLog(response) });
|
|
92
|
+
}
|
|
93
|
+
const result = results[0];
|
|
94
|
+
const file = result.fileId
|
|
95
|
+
? response.files?.find((candidate) => candidate.id === result.fileId)
|
|
96
|
+
: undefined;
|
|
97
|
+
return {
|
|
98
|
+
...result,
|
|
99
|
+
file,
|
|
100
|
+
rawCarrierResponse: response.rawCarrierResponse,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Create labels for multiple parcels in one call
|
|
105
|
+
*
|
|
106
|
+
* MPL GET /shipments/label endpoint:
|
|
107
|
+
* - Takes array of tracking numbers (query params)
|
|
108
|
+
* - Optional labelType, labelFormat, orderBy, singleFile params
|
|
109
|
+
* - Returns JSON array of LabelQueryResult objects with base64-encoded label data
|
|
110
|
+
* - Each result has trackingNumber, label (base64), errors/warnings arrays
|
|
111
|
+
*
|
|
112
|
+
* Returns structured response with files array and per-item results
|
|
113
|
+
*/
|
|
114
|
+
export async function createLabels(req, ctx, resolveBaseUrl) {
|
|
115
|
+
try {
|
|
116
|
+
// Validate the incoming request and use parsed data from Zod as the source of truth.
|
|
117
|
+
const validated = safeValidateCreateLabelsRequest({
|
|
118
|
+
parcelCarrierIds: req.parcelCarrierIds,
|
|
119
|
+
credentials: req.credentials,
|
|
120
|
+
options: req.options,
|
|
121
|
+
});
|
|
122
|
+
if (!validated.success) {
|
|
123
|
+
throw new CarrierError(`Invalid request: ${validated.error.message}`, "Validation", { raw: serializeForLog(validated.error) });
|
|
124
|
+
}
|
|
125
|
+
if (!ctx.http) {
|
|
126
|
+
throw new CarrierError("HTTP client not provided in context", "Permanent");
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(validated.data.parcelCarrierIds) || validated.data.parcelCarrierIds.length === 0) {
|
|
129
|
+
return {
|
|
130
|
+
results: [],
|
|
131
|
+
files: [],
|
|
132
|
+
successCount: 0,
|
|
133
|
+
failureCount: 0,
|
|
134
|
+
totalCount: 0,
|
|
135
|
+
allSucceeded: false,
|
|
136
|
+
allFailed: false,
|
|
137
|
+
someFailed: false,
|
|
138
|
+
summary: "No parcels to process",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Build query parameters and resolve base URL based on useTestApi
|
|
142
|
+
const queryParams = buildLabelQueryParams(validated.data);
|
|
143
|
+
const queryString = serializeQueryParams(queryParams);
|
|
144
|
+
const baseUrl = resolveBaseUrl({ useTestApi: validated.data.options.useTestApi });
|
|
145
|
+
const url = `${baseUrl}/shipments/label?${queryString}`;
|
|
146
|
+
ctx.logger?.debug("MPL: Creating labels batch", {
|
|
147
|
+
count: req.parcelCarrierIds.length,
|
|
148
|
+
labelType: queryParams.labelType,
|
|
149
|
+
labelFormat: queryParams.labelFormat,
|
|
150
|
+
orderBy: queryParams.orderBy,
|
|
151
|
+
singleFile: queryParams.singleFile,
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
// Make request to MPL label API
|
|
155
|
+
// Response is JSON array of LabelQueryResult
|
|
156
|
+
const accountingCodeFromValidated = validated.data.options?.mpl?.accountingCode;
|
|
157
|
+
const httpResponse = await ctx.http.get(url, {
|
|
158
|
+
headers: buildMPLHeaders(validated.data.credentials, accountingCodeFromValidated),
|
|
159
|
+
});
|
|
160
|
+
const labelResults = httpResponse.body;
|
|
161
|
+
if (!Array.isArray(labelResults)) {
|
|
162
|
+
throw new CarrierError(`Invalid response: expected array, got ${typeof labelResults}`, "Transient", { raw: serializeForLog(labelResults) });
|
|
163
|
+
}
|
|
164
|
+
// Process results and build file resources
|
|
165
|
+
const fileMap = new Map();
|
|
166
|
+
const files = [];
|
|
167
|
+
const results = [];
|
|
168
|
+
let successCount = 0;
|
|
169
|
+
let failureCount = 0;
|
|
170
|
+
labelResults.forEach((result, idx) => {
|
|
171
|
+
const trackingNumber = result.trackingNumber || req.parcelCarrierIds[idx];
|
|
172
|
+
// Check if this result has errors
|
|
173
|
+
if (result.errors && result.errors.length > 0) {
|
|
174
|
+
// Failed label
|
|
175
|
+
failureCount++;
|
|
176
|
+
results.push({
|
|
177
|
+
inputId: trackingNumber,
|
|
178
|
+
status: "failed",
|
|
179
|
+
errors: result.errors.map(err => ({
|
|
180
|
+
code: err.code,
|
|
181
|
+
message: err.text || err.text_eng || "Unknown error",
|
|
182
|
+
})),
|
|
183
|
+
raw: { ...result, attemptedIndex: idx },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else if (!result.label) {
|
|
187
|
+
// No label data returned
|
|
188
|
+
failureCount++;
|
|
189
|
+
results.push({
|
|
190
|
+
inputId: trackingNumber,
|
|
191
|
+
status: "failed",
|
|
192
|
+
errors: [{ code: "NO_LABEL_DATA", message: "No label data in response" }],
|
|
193
|
+
raw: { ...result, attemptedIndex: idx },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
// Successful label - decode base64
|
|
198
|
+
successCount++;
|
|
199
|
+
const labelData = result.label;
|
|
200
|
+
// Group labels by content (if singleFile=true, all will be same)
|
|
201
|
+
// Use labelData as key to detect identical responses
|
|
202
|
+
if (!fileMap.has(labelData)) {
|
|
203
|
+
// First occurrence of this label payload: create a stable file resource.
|
|
204
|
+
const buffer = Buffer.from(labelData, 'base64');
|
|
205
|
+
const file = {
|
|
206
|
+
id: randomUUID(),
|
|
207
|
+
contentType: queryParams.labelFormat === 'ZPL' ? 'text/plain' : 'application/pdf',
|
|
208
|
+
byteLength: buffer.byteLength,
|
|
209
|
+
pages: 0,
|
|
210
|
+
orientation: 'portrait',
|
|
211
|
+
metadata: {
|
|
212
|
+
labelType: queryParams.labelType,
|
|
213
|
+
labelFormat: queryParams.labelFormat,
|
|
214
|
+
isBase64Encoded: true,
|
|
215
|
+
},
|
|
216
|
+
// Attach raw bytes to the file so callers can access bytes directly
|
|
217
|
+
rawBytes: buffer,
|
|
218
|
+
};
|
|
219
|
+
fileMap.set(labelData, { file, count: 0 });
|
|
220
|
+
files.push(file);
|
|
221
|
+
}
|
|
222
|
+
const entry = fileMap.get(labelData);
|
|
223
|
+
entry.count++;
|
|
224
|
+
entry.file.pages = entry.count;
|
|
225
|
+
entry.file.metadata = {
|
|
226
|
+
...(entry.file.metadata ?? {}),
|
|
227
|
+
combinedLabels: entry.count > 1,
|
|
228
|
+
};
|
|
229
|
+
results.push({
|
|
230
|
+
inputId: trackingNumber,
|
|
231
|
+
status: "created",
|
|
232
|
+
fileId: entry.file.id,
|
|
233
|
+
pageRange: { start: entry.count, end: entry.count },
|
|
234
|
+
raw: { trackingNumber, index: idx },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
ctx.logger?.info("MPL: Labels created", {
|
|
239
|
+
count: req.parcelCarrierIds.length,
|
|
240
|
+
successCount,
|
|
241
|
+
failureCount,
|
|
242
|
+
labelType: queryParams.labelType,
|
|
243
|
+
labelFormat: queryParams.labelFormat,
|
|
244
|
+
testMode: validated.data.options?.useTestApi ?? false,
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
results,
|
|
248
|
+
files,
|
|
249
|
+
successCount,
|
|
250
|
+
failureCount,
|
|
251
|
+
totalCount: labelResults.length,
|
|
252
|
+
allSucceeded: failureCount === 0,
|
|
253
|
+
allFailed: successCount === 0,
|
|
254
|
+
someFailed: failureCount > 0 && successCount > 0,
|
|
255
|
+
summary: `${successCount} labels created, ${failureCount} failed`,
|
|
256
|
+
// Keep response metadata but truncate embedded label/blob payloads.
|
|
257
|
+
rawCarrierResponse: sanitizeRawCarrierResponse(serializeForLog(httpResponse)),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
catch (labelError) {
|
|
261
|
+
// Handle HTTP errors
|
|
262
|
+
let errorMessage = `Failed to generate label: ${labelError?.message || "Unknown error"}`;
|
|
263
|
+
let errorCategory = 'Transient';
|
|
264
|
+
// If labelError is already a CarrierError, propagate it
|
|
265
|
+
if (labelError instanceof CarrierError) {
|
|
266
|
+
throw labelError;
|
|
267
|
+
}
|
|
268
|
+
// Try to extract HTTP status from error
|
|
269
|
+
const httpStatus = labelError?.response?.status;
|
|
270
|
+
if (httpStatus === 400) {
|
|
271
|
+
errorCategory = /not[_ -]?found/i.test(String(labelError?.response?.data?.error || labelError?.response?.data?.message || errorMessage))
|
|
272
|
+
? 'NotFound'
|
|
273
|
+
: 'Validation';
|
|
274
|
+
errorMessage = errorCategory === 'NotFound' ? 'Requested tracking number not found' : "Invalid label request parameters";
|
|
275
|
+
}
|
|
276
|
+
else if (httpStatus === 401 || httpStatus === 403) {
|
|
277
|
+
errorCategory = 'Auth';
|
|
278
|
+
errorMessage = "Authentication failed - invalid credentials or authorization";
|
|
279
|
+
}
|
|
280
|
+
else if (httpStatus === 429) {
|
|
281
|
+
errorCategory = 'Transient';
|
|
282
|
+
errorMessage = "Rate limit exceeded";
|
|
283
|
+
}
|
|
284
|
+
else if (httpStatus && httpStatus >= 500) {
|
|
285
|
+
errorCategory = 'Transient';
|
|
286
|
+
errorMessage = "Server error - please retry";
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// Network or other error
|
|
290
|
+
errorCategory = 'Transient';
|
|
291
|
+
}
|
|
292
|
+
ctx.logger?.error("MPL: Label generation failed", {
|
|
293
|
+
count: req.parcelCarrierIds.length,
|
|
294
|
+
httpStatus,
|
|
295
|
+
error: errorToLog(labelError),
|
|
296
|
+
});
|
|
297
|
+
// Return failed results for all parcels
|
|
298
|
+
const results = req.parcelCarrierIds.map((trackingNumber) => ({
|
|
299
|
+
inputId: trackingNumber,
|
|
300
|
+
status: "failed",
|
|
301
|
+
errors: [{ code: "LABEL_GENERATION_FAILED", message: errorMessage }],
|
|
302
|
+
raw: { trackingNumber, error: serializeForLog(labelError) },
|
|
303
|
+
}));
|
|
304
|
+
return {
|
|
305
|
+
results,
|
|
306
|
+
files: [],
|
|
307
|
+
successCount: 0,
|
|
308
|
+
failureCount: results.length,
|
|
309
|
+
totalCount: results.length,
|
|
310
|
+
allSucceeded: false,
|
|
311
|
+
allFailed: true,
|
|
312
|
+
someFailed: false,
|
|
313
|
+
summary: `All ${results.length} labels failed`,
|
|
314
|
+
rawCarrierResponse: sanitizeRawCarrierResponse({ error: serializeForLog(labelError) }),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error instanceof CarrierError) {
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
ctx.logger?.error("MPL: Error creating labels", {
|
|
323
|
+
count: req.parcelCarrierIds.length,
|
|
324
|
+
error: errorToLog(error),
|
|
325
|
+
});
|
|
326
|
+
throw new CarrierError(`Label creation failed: ${error?.message || "Unknown error"}`, "Transient", { raw: serializeForLog(error) });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPL Adapter: Parcel Creation Capability
|
|
3
|
+
*
|
|
4
|
+
* Handles CREATE_PARCEL and CREATE_PARCELS operations.
|
|
5
|
+
* Maps canonical Parcel objects to MPL ShipmentCreateRequest format
|
|
6
|
+
* and submits them via POST /shipments endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Supports:
|
|
9
|
+
* - Single parcel creation (createParcel)
|
|
10
|
+
* - Batch creation up to 100 shipments (createParcels)
|
|
11
|
+
* - Partial failure handling (per-shipment results)
|
|
12
|
+
* - Label generation with configurable format
|
|
13
|
+
* - OAuth token exchange with fallback support
|
|
14
|
+
*/
|
|
15
|
+
import type { CreateParcelsResponse, CarrierResource, AdapterContext } from '@shopickup/core';
|
|
16
|
+
import type { ResolveBaseUrl } from '../utils/resolveBaseUrl.js';
|
|
17
|
+
import { type CreateParcelMPLRequest, type CreateParcelsMPLRequest } from '../validation.js';
|
|
18
|
+
/**
|
|
19
|
+
* Create a single parcel in MPL
|
|
20
|
+
* Delegates to createParcels to reuse batching logic
|
|
21
|
+
*/
|
|
22
|
+
export declare function createParcel(req: CreateParcelMPLRequest, ctx: AdapterContext, createParcelsImpl: (req: CreateParcelsMPLRequest, ctx: AdapterContext) => Promise<CreateParcelsResponse>): Promise<CarrierResource>;
|
|
23
|
+
/**
|
|
24
|
+
* Create multiple parcels in one call
|
|
25
|
+
*
|
|
26
|
+
* Maps canonical Parcel array to MPL ShipmentCreateRequest array.
|
|
27
|
+
* Supports up to 100 shipments per call (OpenAPI constraint).
|
|
28
|
+
* Returns per-item CarrierResource so callers can handle partial failures.
|
|
29
|
+
*
|
|
30
|
+
* @returns CreateParcelsResponse with summary and per-item results
|
|
31
|
+
*/
|
|
32
|
+
export declare function createParcels(req: CreateParcelsMPLRequest, ctx: AdapterContext, resolveBaseUrl: ResolveBaseUrl): Promise<CreateParcelsResponse>;
|
|
33
|
+
//# sourceMappingURL=parcels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parcels.d.ts","sourceRoot":"","sources":["../../src/capabilities/parcels.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAGV,qBAAqB,EACrB,eAAe,EAEf,cAAc,EAEf,MAAM,iBAAiB,CAAC;AAOzB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAKL,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAG7B,MAAM,kBAAkB,CAAC;AAoE1B;;;GAGG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,sBAAsB,EAC3B,GAAG,EAAE,cAAc,EACnB,iBAAiB,EAAE,CACjB,GAAG,EAAE,uBAAuB,EAC5B,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,qBAAqB,CAAC,GAClC,OAAO,CAAC,eAAe,CAAC,CA4D1B;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,uBAAuB,EAC5B,GAAG,EAAE,cAAc,EACnB,cAAc,EAAE,cAAc,GAC7B,OAAO,CAAC,qBAAqB,CAAC,CAiPhC"}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPL Adapter: Parcel Creation Capability
|
|
3
|
+
*
|
|
4
|
+
* Handles CREATE_PARCEL and CREATE_PARCELS operations.
|
|
5
|
+
* Maps canonical Parcel objects to MPL ShipmentCreateRequest format
|
|
6
|
+
* and submits them via POST /shipments endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Supports:
|
|
9
|
+
* - Single parcel creation (createParcel)
|
|
10
|
+
* - Batch creation up to 100 shipments (createParcels)
|
|
11
|
+
* - Partial failure handling (per-shipment results)
|
|
12
|
+
* - Label generation with configurable format
|
|
13
|
+
* - OAuth token exchange with fallback support
|
|
14
|
+
*/
|
|
15
|
+
import { CarrierError, serializeForLog, errorToLog, } from '@shopickup/core';
|
|
16
|
+
import { safeValidateCreateParcelRequest, safeValidateCreateParcelsRequest, safeValidateShipmentCreateRequest, safeValidateShipmentCreateResult, } from '../validation.js';
|
|
17
|
+
import { mapParcelsToMPLShipments } from '../mappers/shipment.js';
|
|
18
|
+
import { buildMPLHeaders } from '../utils/httpUtils.js';
|
|
19
|
+
/**
|
|
20
|
+
* Translates MPL error to CarrierError with appropriate category
|
|
21
|
+
*/
|
|
22
|
+
function translateMPLShipmentError(error) {
|
|
23
|
+
if (error instanceof CarrierError) {
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
if (error && typeof error === 'object') {
|
|
27
|
+
const err = error;
|
|
28
|
+
// Check for HTTP response status
|
|
29
|
+
if (err.status) {
|
|
30
|
+
if (err.status === 400 || err.status === 404) {
|
|
31
|
+
return new CarrierError(`Validation error: ${err.message || 'Invalid request'}`, 'Validation', { raw: serializeForLog(err) });
|
|
32
|
+
}
|
|
33
|
+
else if (err.status === 401 || err.status === 403) {
|
|
34
|
+
return new CarrierError(`Authentication error: ${err.message || 'Unauthorized'}`, 'Auth', { raw: serializeForLog(err) });
|
|
35
|
+
}
|
|
36
|
+
else if (err.status === 429) {
|
|
37
|
+
return new CarrierError(`Rate limit exceeded: ${err.message || 'Too many requests'}`, 'RateLimit', { raw: serializeForLog(err) });
|
|
38
|
+
}
|
|
39
|
+
else if (err.status >= 500) {
|
|
40
|
+
return new CarrierError(`Server error: ${err.message || 'Internal server error'}`, 'Transient', { raw: serializeForLog(err) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Check for MPL-specific error structure
|
|
44
|
+
if (err.fault?.faultstring) {
|
|
45
|
+
return new CarrierError(`MPL error: ${err.fault.faultstring}`, 'Transient', { raw: serializeForLog(err) });
|
|
46
|
+
}
|
|
47
|
+
return new CarrierError(`Shipment creation error: ${err.message || 'Unknown error'}`, 'Transient', { raw: serializeForLog(err) });
|
|
48
|
+
}
|
|
49
|
+
return new CarrierError(`Shipment creation error: ${String(error)}`, 'Transient');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a single parcel in MPL
|
|
53
|
+
* Delegates to createParcels to reuse batching logic
|
|
54
|
+
*/
|
|
55
|
+
export async function createParcel(req, ctx, createParcelsImpl) {
|
|
56
|
+
const validated = safeValidateCreateParcelRequest(req);
|
|
57
|
+
if (!validated.success) {
|
|
58
|
+
throw new CarrierError(`Invalid request: ${validated.error.message}`, 'Validation', { raw: serializeForLog(validated.error.issues) });
|
|
59
|
+
}
|
|
60
|
+
const parsedReq = validated.data;
|
|
61
|
+
const batchReq = {
|
|
62
|
+
parcels: [parsedReq.parcel],
|
|
63
|
+
credentials: parsedReq.credentials,
|
|
64
|
+
options: parsedReq.options,
|
|
65
|
+
};
|
|
66
|
+
const response = await createParcelsImpl(batchReq, ctx);
|
|
67
|
+
// Validate response shape
|
|
68
|
+
if (!response || !Array.isArray(response.results)) {
|
|
69
|
+
throw new CarrierError('Unexpected response shape from createParcels', 'Transient', { raw: serializeForLog(response) });
|
|
70
|
+
}
|
|
71
|
+
const results = response.results;
|
|
72
|
+
if (results.length === 0) {
|
|
73
|
+
throw new CarrierError('createParcels returned an empty results array', 'Transient', { raw: serializeForLog(response) });
|
|
74
|
+
}
|
|
75
|
+
// Return the first parcel result, attach full response for context
|
|
76
|
+
const result = results[0];
|
|
77
|
+
if (result.status === 'failed') {
|
|
78
|
+
const firstError = result.errors?.[0]?.message;
|
|
79
|
+
throw new CarrierError(firstError
|
|
80
|
+
? `Parcel creation failed: ${firstError}`
|
|
81
|
+
: 'Parcel creation failed', 'Validation', {
|
|
82
|
+
raw: serializeForLog({
|
|
83
|
+
result,
|
|
84
|
+
rawCarrierResponse: response.rawCarrierResponse,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...result,
|
|
90
|
+
rawCarrierResponse: response.rawCarrierResponse,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create multiple parcels in one call
|
|
95
|
+
*
|
|
96
|
+
* Maps canonical Parcel array to MPL ShipmentCreateRequest array.
|
|
97
|
+
* Supports up to 100 shipments per call (OpenAPI constraint).
|
|
98
|
+
* Returns per-item CarrierResource so callers can handle partial failures.
|
|
99
|
+
*
|
|
100
|
+
* @returns CreateParcelsResponse with summary and per-item results
|
|
101
|
+
*/
|
|
102
|
+
export async function createParcels(req, ctx, resolveBaseUrl) {
|
|
103
|
+
try {
|
|
104
|
+
const validated = safeValidateCreateParcelsRequest(req);
|
|
105
|
+
if (!validated.success) {
|
|
106
|
+
throw new CarrierError(`Invalid request: ${validated.error.message}`, 'Validation', { raw: serializeForLog(validated.error.issues) });
|
|
107
|
+
}
|
|
108
|
+
const parsedReq = validated.data;
|
|
109
|
+
const internalOptions = {
|
|
110
|
+
useTestApi: parsedReq.options.useTestApi ?? false,
|
|
111
|
+
labelType: parsedReq.options.mpl.labelType,
|
|
112
|
+
accountingCode: parsedReq.options.mpl.accountingCode,
|
|
113
|
+
agreementCode: parsedReq.options.mpl.agreementCode,
|
|
114
|
+
bankAccountNumber: parsedReq.options.mpl.bankAccountNumber,
|
|
115
|
+
};
|
|
116
|
+
// Validate we have required context
|
|
117
|
+
if (!ctx.http) {
|
|
118
|
+
throw new CarrierError('HTTP client not provided in context', 'Permanent');
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(parsedReq.parcels) || parsedReq.parcels.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
results: [],
|
|
123
|
+
successCount: 0,
|
|
124
|
+
failureCount: 0,
|
|
125
|
+
totalCount: 0,
|
|
126
|
+
allSucceeded: false,
|
|
127
|
+
allFailed: false,
|
|
128
|
+
someFailed: false,
|
|
129
|
+
summary: 'No parcels to process',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Enforce batch size limit (OpenAPI: max 100 shipments per call)
|
|
133
|
+
if (parsedReq.parcels.length > 100) {
|
|
134
|
+
throw new CarrierError(`Too many parcels: ${parsedReq.parcels.length} > 100 (MPL API limit)`, 'Validation', { raw: { maxAllowed: 100, requested: parsedReq.parcels.length } });
|
|
135
|
+
}
|
|
136
|
+
const baseUrl = resolveBaseUrl({ useTestApi: internalOptions.useTestApi });
|
|
137
|
+
const useTestApi = internalOptions.useTestApi;
|
|
138
|
+
ctx.logger?.debug('MPL: Creating parcels batch', {
|
|
139
|
+
count: parsedReq.parcels.length,
|
|
140
|
+
testMode: useTestApi,
|
|
141
|
+
});
|
|
142
|
+
// Extract sender information from first parcel (assumes uniform sender across batch)
|
|
143
|
+
const firstParcel = parsedReq.parcels[0];
|
|
144
|
+
if (!firstParcel.shipper) {
|
|
145
|
+
throw new CarrierError('Missing shipper information in parcel', 'Validation');
|
|
146
|
+
}
|
|
147
|
+
// Map canonical parcels to MPL shipments
|
|
148
|
+
const mplShipments = mapParcelsToMPLShipments(parsedReq.parcels, firstParcel.shipper, internalOptions.agreementCode, internalOptions.bankAccountNumber, internalOptions.labelType);
|
|
149
|
+
ctx.logger?.debug('MPL: Mapped parcels to MPL shipments', {
|
|
150
|
+
shipments: serializeForLog(mplShipments),
|
|
151
|
+
});
|
|
152
|
+
// Validate each mapped shipment
|
|
153
|
+
const mplShipmentsWithValidation = mplShipments.map((shipment, idx) => {
|
|
154
|
+
const validation = safeValidateShipmentCreateRequest(shipment);
|
|
155
|
+
if (!validation.success) {
|
|
156
|
+
ctx.logger?.warn('MPL: Shipment validation failed', {
|
|
157
|
+
shipmentIdx: idx,
|
|
158
|
+
errors: serializeForLog(validation.error.issues),
|
|
159
|
+
});
|
|
160
|
+
// Continue anyway - be lenient
|
|
161
|
+
}
|
|
162
|
+
return shipment;
|
|
163
|
+
});
|
|
164
|
+
// Call MPL API
|
|
165
|
+
const httpResponse = await ctx.http.post(`${baseUrl}/shipments`, mplShipmentsWithValidation, {
|
|
166
|
+
headers: buildMPLHeaders(parsedReq.credentials, internalOptions.accountingCode),
|
|
167
|
+
});
|
|
168
|
+
// Extract and validate response body
|
|
169
|
+
const carrierRespBody = httpResponse.body;
|
|
170
|
+
if (!Array.isArray(carrierRespBody)) {
|
|
171
|
+
throw new CarrierError('Invalid response from MPL: expected array', 'Transient', { raw: serializeForLog(carrierRespBody) });
|
|
172
|
+
}
|
|
173
|
+
// Process each result
|
|
174
|
+
const results = carrierRespBody.map((result, idx) => {
|
|
175
|
+
// Validate result shape
|
|
176
|
+
const resultValidation = safeValidateShipmentCreateResult(result);
|
|
177
|
+
if (!resultValidation.success) {
|
|
178
|
+
ctx.logger?.warn('MPL: Result validation failed', {
|
|
179
|
+
resultIdx: idx,
|
|
180
|
+
errors: serializeForLog(resultValidation.error.issues),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Check for errors in result
|
|
184
|
+
if (result.errors && Array.isArray(result.errors) && result.errors.length > 0) {
|
|
185
|
+
const errors = result.errors.map((err) => ({
|
|
186
|
+
field: err.parameter || 'unknown',
|
|
187
|
+
code: err.code || 'UNKNOWN_ERROR',
|
|
188
|
+
message: `${err.text || err.text_eng || 'Unknown error'}`,
|
|
189
|
+
}));
|
|
190
|
+
ctx.logger?.warn('MPL: Shipment errors', {
|
|
191
|
+
shipmentIdx: idx,
|
|
192
|
+
webshopId: result.webshopId,
|
|
193
|
+
errorCount: errors.length,
|
|
194
|
+
errorSummary: errors.map(e => `${e.field}: ${e.code}`),
|
|
195
|
+
errors: serializeForLog(errors),
|
|
196
|
+
});
|
|
197
|
+
const failedResource = {
|
|
198
|
+
carrierId: undefined,
|
|
199
|
+
status: 'failed',
|
|
200
|
+
raw: serializeForLog(result),
|
|
201
|
+
errors,
|
|
202
|
+
};
|
|
203
|
+
return failedResource;
|
|
204
|
+
}
|
|
205
|
+
// Check for tracking number (indicates success)
|
|
206
|
+
if (!result.trackingNumber) {
|
|
207
|
+
ctx.logger?.warn('MPL: No tracking number in result', {
|
|
208
|
+
shipmentIdx: idx,
|
|
209
|
+
webshopId: result.webshopId,
|
|
210
|
+
});
|
|
211
|
+
const failedResource = {
|
|
212
|
+
carrierId: undefined,
|
|
213
|
+
status: 'failed',
|
|
214
|
+
raw: serializeForLog(result),
|
|
215
|
+
errors: [
|
|
216
|
+
{
|
|
217
|
+
field: 'trackingNumber',
|
|
218
|
+
code: 'NO_TRACKING_NUMBER',
|
|
219
|
+
message: 'No tracking number assigned by carrier',
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
return failedResource;
|
|
224
|
+
}
|
|
225
|
+
// Success - shipment created with tracking number
|
|
226
|
+
const successResource = {
|
|
227
|
+
carrierId: result.trackingNumber,
|
|
228
|
+
status: 'created',
|
|
229
|
+
raw: serializeForLog(result),
|
|
230
|
+
};
|
|
231
|
+
ctx.logger?.debug('MPL: Shipment created', {
|
|
232
|
+
shipmentIdx: idx,
|
|
233
|
+
trackingNumber: result.trackingNumber,
|
|
234
|
+
webshopId: result.webshopId,
|
|
235
|
+
});
|
|
236
|
+
return successResource;
|
|
237
|
+
});
|
|
238
|
+
// Calculate summary statistics
|
|
239
|
+
const successCount = results.filter(r => r.status === 'created').length;
|
|
240
|
+
const failureCount = results.filter(r => r.status === 'failed').length;
|
|
241
|
+
const totalCount = results.length;
|
|
242
|
+
// Determine summary text
|
|
243
|
+
let summary;
|
|
244
|
+
if (failureCount === 0) {
|
|
245
|
+
summary = `All ${totalCount} parcels created successfully`;
|
|
246
|
+
}
|
|
247
|
+
else if (successCount === 0) {
|
|
248
|
+
summary = `All ${totalCount} parcels failed`;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
summary = `Mixed results: ${successCount} succeeded, ${failureCount} failed`;
|
|
252
|
+
}
|
|
253
|
+
ctx.logger?.info('MPL: Parcels creation finished', {
|
|
254
|
+
count: totalCount,
|
|
255
|
+
testMode: useTestApi,
|
|
256
|
+
summary,
|
|
257
|
+
successCount,
|
|
258
|
+
failureCount,
|
|
259
|
+
});
|
|
260
|
+
// Return strongly-typed response with a sanitized carrier response for debugging
|
|
261
|
+
// Avoid serializing the whole httpResponse (which may contain streams/buffers)
|
|
262
|
+
// — include only the most useful parts: status, headers, body
|
|
263
|
+
return {
|
|
264
|
+
results,
|
|
265
|
+
successCount,
|
|
266
|
+
failureCount,
|
|
267
|
+
totalCount,
|
|
268
|
+
allSucceeded: failureCount === 0 && totalCount > 0,
|
|
269
|
+
allFailed: successCount === 0 && totalCount > 0,
|
|
270
|
+
someFailed: successCount > 0 && failureCount > 0,
|
|
271
|
+
summary,
|
|
272
|
+
rawCarrierResponse: serializeForLog({ status: httpResponse.status, headers: httpResponse.headers, body: httpResponse.body }),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
ctx.logger?.error('MPL: Error creating parcels batch', {
|
|
277
|
+
error: errorToLog(error),
|
|
278
|
+
});
|
|
279
|
+
if (error instanceof CarrierError) {
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
throw translateMPLShipmentError(error);
|
|
283
|
+
}
|
|
284
|
+
}
|