@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.
Files changed (50) hide show
  1. package/README.md +43 -0
  2. package/dist/capabilities/auth.d.ts +39 -0
  3. package/dist/capabilities/auth.d.ts.map +1 -0
  4. package/dist/capabilities/auth.js +130 -0
  5. package/dist/capabilities/close.d.ts +8 -0
  6. package/dist/capabilities/close.d.ts.map +1 -0
  7. package/dist/capabilities/close.js +70 -0
  8. package/dist/capabilities/get-shipment-details.d.ts +63 -0
  9. package/dist/capabilities/get-shipment-details.d.ts.map +1 -0
  10. package/dist/capabilities/get-shipment-details.js +97 -0
  11. package/dist/capabilities/index.d.ts +10 -0
  12. package/dist/capabilities/index.d.ts.map +1 -0
  13. package/dist/capabilities/index.js +9 -0
  14. package/dist/capabilities/label.d.ts +33 -0
  15. package/dist/capabilities/label.d.ts.map +1 -0
  16. package/dist/capabilities/label.js +328 -0
  17. package/dist/capabilities/parcels.d.ts +33 -0
  18. package/dist/capabilities/parcels.d.ts.map +1 -0
  19. package/dist/capabilities/parcels.js +284 -0
  20. package/dist/capabilities/pickup-points.d.ts +41 -0
  21. package/dist/capabilities/pickup-points.d.ts.map +1 -0
  22. package/dist/capabilities/pickup-points.js +294 -0
  23. package/dist/capabilities/track.d.ts +72 -0
  24. package/dist/capabilities/track.d.ts.map +1 -0
  25. package/dist/capabilities/track.js +331 -0
  26. package/dist/index.d.ts +83 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +142 -0
  29. package/dist/mappers/label.d.ts +67 -0
  30. package/dist/mappers/label.d.ts.map +1 -0
  31. package/dist/mappers/label.js +83 -0
  32. package/dist/mappers/shipment.d.ts +110 -0
  33. package/dist/mappers/shipment.d.ts.map +1 -0
  34. package/dist/mappers/shipment.js +258 -0
  35. package/dist/mappers/tracking.d.ts +60 -0
  36. package/dist/mappers/tracking.d.ts.map +1 -0
  37. package/dist/mappers/tracking.js +187 -0
  38. package/dist/utils/httpUtils.d.ts +36 -0
  39. package/dist/utils/httpUtils.d.ts.map +1 -0
  40. package/dist/utils/httpUtils.js +76 -0
  41. package/dist/utils/oauthFallback.d.ts +47 -0
  42. package/dist/utils/oauthFallback.d.ts.map +1 -0
  43. package/dist/utils/oauthFallback.js +250 -0
  44. package/dist/utils/resolveBaseUrl.d.ts +75 -0
  45. package/dist/utils/resolveBaseUrl.d.ts.map +1 -0
  46. package/dist/utils/resolveBaseUrl.js +65 -0
  47. package/dist/validation.d.ts +1890 -0
  48. package/dist/validation.d.ts.map +1 -0
  49. package/dist/validation.js +726 -0
  50. 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
+ }