diff --git a/docs/sdk/reference/types/models/interfaces/Container.mdx b/docs/sdk/reference/types/models/interfaces/Container.mdx index 45acdb3a..f9545142 100644 --- a/docs/sdk/reference/types/models/interfaces/Container.mdx +++ b/docs/sdk/reference/types/models/interfaces/Container.mdx @@ -13,27 +13,28 @@ Simplified container model returned by mapped SDK responses. ## Properties -| Property | Type | -| ------ | ------ | -| `demurrage?` | `object` | -| `demurrage.fees?` | `any`[] | -| `demurrage.holds?` | `any`[] | -| `demurrage.pickupAppointmentAt?` | `string` \| `null` | -| `demurrage.pickupLfd?` | `string` \| `null` | -| `equipment?` | `object` | -| `equipment.height?` | `number` | -| `equipment.length?` | `number` | -| `equipment.type?` | `string` | -| `equipment.weightLbs?` | `number` | -| `id` | `string` | -| `location?` | `object` | -| `location.availableForPickup?` | `boolean` | -| `location.currentLocation?` | `string` | -| `location.podArrivedAt?` | `string` \| `null` | -| `location.podDischargedAt?` | `string` \| `null` | -| `number?` | `string` | -| `shipment?` | [`Shipment`](/sdk/reference/types/models/interfaces/Shipment) \| `null` | -| `status?` | `string` | -| `terminals?` | `object` | -| `terminals.destinationTerminal?` | \{ `firmsCode?`: `string`; `id?`: `string`; `name?`: `string`; `nickname?`: `string`; \} \| `null` | -| `terminals.podTerminal?` | \{ `firmsCode?`: `string`; `id?`: `string`; `name?`: `string`; `nickname?`: `string`; \} \| `null` | +| Property | Type | Description | +| ------ | ------ | ------ | +| `currentStatus?` | `string` | Raw `current_status` from the API (also surfaced via `status`). | +| `demurrage?` | `object` | - | +| `demurrage.fees?` | `any`[] | - | +| `demurrage.holds?` | `any`[] | - | +| `demurrage.pickupAppointmentAt?` | `string` \| `null` | - | +| `demurrage.pickupLfd?` | `string` \| `null` | - | +| `equipment?` | `object` | - | +| `equipment.height?` | `number` | - | +| `equipment.length?` | `number` | - | +| `equipment.type?` | `string` | - | +| `equipment.weightLbs?` | `number` | - | +| `id` | `string` | - | +| `location?` | `object` | - | +| `location.availableForPickup?` | `boolean` | - | +| `location.currentLocation?` | `string` | - | +| `location.podArrivedAt?` | `string` \| `null` | - | +| `location.podDischargedAt?` | `string` \| `null` | - | +| `number?` | `string` | - | +| `shipment?` | [`Shipment`](/sdk/reference/types/models/interfaces/Shipment) \| `null` | - | +| `status?` | `string` | - | +| `terminals?` | `object` | - | +| `terminals.destinationTerminal?` | \{ `firmsCode?`: `string`; `id?`: `string`; `name?`: `string`; `nickname?`: `string`; \} \| `null` | - | +| `terminals.podTerminal?` | \{ `firmsCode?`: `string`; `id?`: `string`; `name?`: `string`; `nickname?`: `string`; \} \| `null` | - | diff --git a/docs/sdk/reference/types/models/interfaces/ShippingLine.mdx b/docs/sdk/reference/types/models/interfaces/ShippingLine.mdx index 3f852cc8..3b4fdcb8 100644 --- a/docs/sdk/reference/types/models/interfaces/ShippingLine.mdx +++ b/docs/sdk/reference/types/models/interfaces/ShippingLine.mdx @@ -9,10 +9,14 @@ Simplified shipping line returned by mapped SDK responses. ## Properties -| Property | Type | -| ------ | ------ | -| `bolPrefix?` | `string` | -| `name` | `string` | -| `notes?` | `string` | -| `scac` | `string` | -| `shortName?` | `string` | +| Property | Type | Description | +| ------ | ------ | ------ | +| `alternativeScacs?` | `string`[] | Additional SCACs the carrier tracks under. | +| `billOfLadingTrackingSupport?` | `boolean` | Whether the carrier supports tracking by bill of lading number. | +| `bolPrefix?` | `string` | - | +| `bookingNumberTrackingSupport?` | `boolean` | Whether the carrier supports tracking by booking number. | +| `containerNumberTrackingSupport?` | `boolean` | Whether the carrier supports tracking by container number. | +| `name` | `string` | - | +| `notes?` | `string` | - | +| `scac` | `string` | - | +| `shortName?` | `string` | - | diff --git a/sdks/typescript-sdk/src/client.errors.test.ts b/sdks/typescript-sdk/src/client.errors.test.ts index f6dfc656..be867203 100644 --- a/sdks/typescript-sdk/src/client.errors.test.ts +++ b/sdks/typescript-sdk/src/client.errors.test.ts @@ -53,7 +53,7 @@ describe('Terminal49Client error handling', () => { it('maps 401 to AuthenticationError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'invalid token' }] }, 401), }); @@ -69,7 +69,7 @@ describe('Terminal49Client error handling', () => { it('maps 403 with feature message to FeatureNotEnabledError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'Feature not enabled' }] }, 403), }); @@ -85,7 +85,7 @@ describe('Terminal49Client error handling', () => { it('maps 403 without feature message to AuthorizationError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'Access forbidden' }] }, 403), }); @@ -101,7 +101,7 @@ describe('Terminal49Client error handling', () => { it('maps 404 to NotFoundError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'missing' }] }, 404), }); @@ -117,7 +117,7 @@ describe('Terminal49Client error handling', () => { it('maps 429 to RateLimitError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'too many requests' }] }, 429), }); @@ -133,7 +133,7 @@ describe('Terminal49Client error handling', () => { it('maps 5xx to UpstreamError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'server down' }] }, 503), }); @@ -149,7 +149,7 @@ describe('Terminal49Client error handling', () => { it('maps unexpected status to Terminal49Error with status in message', async () => { const { fetchImpl } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'teapot' }] }, 418), }); diff --git a/sdks/typescript-sdk/src/client.mapping.test.ts b/sdks/typescript-sdk/src/client.mapping.test.ts index 0f9d5ebb..9c58bd16 100644 --- a/sdks/typescript-sdk/src/client.mapping.test.ts +++ b/sdks/typescript-sdk/src/client.mapping.test.ts @@ -141,11 +141,18 @@ describe('Terminal49Client mapping helpers', () => { : null; if (!routeLocation) return; - const portId = routeLocation?.relationships?.port?.data?.id; - const port = portId ? findIncluded(fixture, 'port', portId) : null; + // The port for a route leg lives under the `location` relationship of a + // route_location (type port|terminal), NOT a `port` relationship. + const locationId = routeLocation?.relationships?.location?.data?.id; + const port = locationId + ? findIncluded(fixture, 'port', locationId) + : null; if (port) { + // Regression guard: the leg must resolve its port, not be null. + expect(result.locations[0]?.port).toBeTruthy(); expectIfDefined(result.locations[0]?.port?.code, port.attributes?.code); expectIfDefined(result.locations[0]?.port?.name, port.attributes?.name); + expectIfDefined(result.locations[0]?.port?.city, port.attributes?.city); expectIfDefined( result.locations[0]?.port?.countryCode, port.attributes?.country_code, @@ -491,7 +498,8 @@ describe('Terminal49Client mapping helpers', () => { it('maps container list items from base fixture', async () => { const fixture = loadFixture('containers.list'); const { fetchImpl } = createMockFetch({ - '/containers?include=shipment,pod_terminal': () => jsonResponse(fixture), + '/containers?include=shipment,pod_terminal,pickup_facility': () => + jsonResponse(fixture), }); const client = new Terminal49Client({ @@ -590,7 +598,7 @@ describe('Terminal49Client mapping helpers', () => { it('returns empty lists when container or shipment list data is not an array', async () => { const { fetchImpl } = createMockFetch({ - '/containers?include=shipment,pod_terminal': () => + '/containers?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ data: {} }), '/shipments?include=containers,pod_terminal,port_of_lading,port_of_discharge,destination,destination_terminal': () => jsonResponse({ data: {} }), @@ -614,4 +622,215 @@ describe('Terminal49Client mapping helpers', () => { expect(containers.items).toEqual([]); expect(shipments.items).toEqual([]); }); + + it('resolves every route leg port from the `location` relationship', async () => { + const fixture = loadFixture('containers.route'); + const { fetchImpl } = createMockFetch({ + '/containers/cont-1/route?include=port,vessel,route_location': () => + jsonResponse(fixture), + }); + + const client = new Terminal49Client({ + apiToken: 'token-123', + apiBaseUrl: baseUrl, + fetchImpl, + }); + + const result = (await client.getContainerRoute('cont-1', { + format: 'mapped', + })) as any; + + expect(result.totalLegs).toBe(2); + expect(result.locations.length).toBe(2); + + // Each leg must carry a non-null port resolved via `location`. + expect(result.locations[0]?.port?.code).toBe('CNSHA'); + expect(result.locations[0]?.port?.name).toBe('Shanghai'); + expect(result.locations[0]?.port?.city).toBe('Shanghai'); + expect(result.locations[0]?.port?.countryCode).toBe('CN'); + + expect(result.locations[1]?.port?.code).toBe('USLAX'); + expect(result.locations[1]?.port?.name).toBe('Los Angeles'); + expect(result.locations[1]?.port?.countryCode).toBe('US'); + + // Outbound leg + vessel resolution sanity. + expect(result.locations[0]?.outbound?.carrierScac).toBe('MAEU'); + expect(result.locations[0]?.outbound?.vessel?.name).toBe( + 'MAERSK EDINBURGH', + ); + expect(result.locations[1]?.inbound?.vessel?.imo).toBe('9456769'); + }); + + it('maps port `code` into the shipment locode fields', async () => { + const fixture = loadFixture('shipments.get.include'); + const shipmentId = fixture?.data?.id || 'ship-1'; + const { fetchImpl } = createMockFetch({ + [`/shipments/${shipmentId}?include=containers,pod_terminal,port_of_lading,port_of_discharge,destination,destination_terminal`]: + () => jsonResponse(fixture), + }); + + const client = new Terminal49Client({ + apiToken: 'token-123', + apiBaseUrl: baseUrl, + fetchImpl, + }); + + const result = (await client.getShipment(shipmentId, true, { + format: 'mapped', + })) as any; + + const relationships = fixture?.data?.relationships || {}; + const polRef = relationships.port_of_lading?.data; + const pol = polRef ? findIncluded(fixture, polRef.type, polRef.id) : null; + + if (pol?.attributes?.code) { + // Port resources expose `code` (e.g. KRPUS), never `locode`. + expect(pol.attributes.locode).toBeUndefined(); + expect(result.ports?.portOfLading?.locode).toBe(pol.attributes.code); + expect(result.ports?.portOfLading?.code).toBe(pol.attributes.code); + } + + const podRef = relationships.port_of_discharge?.data; + const pod = podRef ? findIncluded(fixture, podRef.type, podRef.id) : null; + if (pod?.attributes?.code) { + expect(result.ports?.portOfDischarge?.locode).toBe(pod.attributes.code); + expect(result.ports?.portOfDischarge?.code).toBe(pod.attributes.code); + } + }); + + it('maps container current_status and pickup_facility from real paths', async () => { + const fixture = loadFixture('containers.get.pickup'); + const { fetchImpl } = createMockFetch({ + '/containers?include=shipment,pod_terminal,pickup_facility': () => + jsonResponse(fixture), + }); + + const client = new Terminal49Client({ + apiToken: 'token-123', + apiBaseUrl: baseUrl, + fetchImpl, + }); + + const list = (await client.listContainers( + { include: 'shipment,pod_terminal,pickup_facility' }, + { format: 'mapped' }, + )) as any; + const result = list.items[0]; + + const item = fixture.data[0]; + const attrs = item.attributes; + + // status must come from current_status (there is no `status` attribute). + expect(attrs.status).toBeUndefined(); + expect(result.status).toBe(attrs.current_status); + expect(result.currentStatus).toBe(attrs.current_status); + + // pod terminal resolves as before. + expect(result.terminals?.podTerminal?.name).toBe('APM Terminals Pier 400'); + expect(result.terminals?.podTerminal?.firmsCode).toBe('Y258'); + + // pickup_facility (the real inland/destination facility relationship) must + // be surfaced; the legacy non-existent `destination_terminal` relationship + // never resolves anything. + const pickupRef = item.relationships.pickup_facility?.data; + const pickup = findIncluded(fixture, 'terminal', pickupRef?.id); + expect(pickup).toBeTruthy(); + expect(result.terminals?.destinationTerminal?.name).toBe( + pickup.attributes.name, + ); + expect(result.terminals?.destinationTerminal?.firmsCode).toBe( + pickup.attributes.firms_code, + ); + + expect(result.equipment?.type).toBe(attrs.equipment_type); + expect(result.location?.availableForPickup).toBe( + attrs.available_for_pickup, + ); + expect(result.demurrage?.pickupLfd).toBe(attrs.pickup_lfd); + }); + + it('does not let the raw-attr spread clobber curated nested fields', async () => { + const fixture = loadFixture('containers.get.pickup'); + const { fetchImpl } = createMockFetch({ + '/containers?include=shipment,pod_terminal,pickup_facility': () => + jsonResponse(fixture), + }); + + const client = new Terminal49Client({ + apiToken: 'token-123', + apiBaseUrl: baseUrl, + fetchImpl, + }); + + const list = (await client.listContainers( + { include: 'shipment,pod_terminal,pickup_facility' }, + { format: 'mapped' }, + )) as any; + const result = list.items[0]; + + // Curated nests must remain structured objects, never overwritten by the + // flat camelCased raw attributes (e.g. an `equipment*` scalar must not + // replace the curated `equipment` object). + expect(typeof result.equipment).toBe('object'); + expect(result.equipment).not.toBeNull(); + expect(typeof result.location).toBe('object'); + expect(typeof result.demurrage).toBe('object'); + expect(typeof result.rail).toBe('object'); + expect(typeof result.terminals).toBe('object'); + + // The flattened raw scalars that feed curated nests should not also appear + // as top-level duplicate keys. + expect(result).not.toHaveProperty('equipmentType'); + expect(result).not.toHaveProperty('equipmentLength'); + expect(result).not.toHaveProperty('availableForPickup'); + expect(result).not.toHaveProperty('podArrivedAt'); + expect(result).not.toHaveProperty('pickupLfd'); + expect(result).not.toHaveProperty('podRailCarrierScac'); + }); + + it('restores shipping-line alternative_scacs and tracking-support flags', async () => { + const fixture = loadFixture('shipping-lines.list'); + const { fetchImpl } = createMockFetch({ + '/shipping_lines': () => jsonResponse(fixture), + }); + + const client = new Terminal49Client({ + apiToken: 'token-123', + apiBaseUrl: baseUrl, + fetchImpl, + }); + + const result = (await client.listShippingLines(undefined, { + format: 'mapped', + })) as any[]; + + const sourceWithAlt = (fixture?.data || []).find( + (item: any) => + Array.isArray(item?.attributes?.alternative_scacs) && + item.attributes.alternative_scacs.length > 0, + ); + expect(sourceWithAlt).toBeTruthy(); + const mappedAlt = result.find( + (line) => line.scac === sourceWithAlt.attributes.scac, + ); + expect(mappedAlt?.alternativeScacs).toEqual( + sourceWithAlt.attributes.alternative_scacs, + ); + + const sourceWithFlag = (fixture?.data || []).find( + (item: any) => + item?.attributes?.container_number_tracking_support === false, + ); + expect(sourceWithFlag).toBeTruthy(); + const mappedFlag = result.find( + (line) => line.scac === sourceWithFlag.attributes.scac, + ); + expect(mappedFlag?.containerNumberTrackingSupport).toBe(false); + expect(mappedFlag?.billOfLadingTrackingSupport).toBe( + sourceWithFlag.attributes.bill_of_lading_tracking_support, + ); + expect(mappedFlag?.bookingNumberTrackingSupport).toBe( + sourceWithFlag.attributes.booking_number_tracking_support, + ); + }); }); diff --git a/sdks/typescript-sdk/src/client.test.ts b/sdks/typescript-sdk/src/client.test.ts index e7dfa500..c9e83aee 100644 --- a/sdks/typescript-sdk/src/client.test.ts +++ b/sdks/typescript-sdk/src/client.test.ts @@ -103,7 +103,7 @@ describe('Terminal49Client', () => { it('maps 404 responses to NotFoundError', async () => { const { fetchImpl } = createMockFetch({ - '/containers/missing?include=shipment,pod_terminal': () => + '/containers/missing?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ errors: [{ detail: 'not found' }] }, 404), }); @@ -120,7 +120,7 @@ describe('Terminal49Client', () => { it('adds auth header and include params when fetching container', async () => { const { fetchImpl, calls } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ data: { id: 'abc', attributes: {} } }), }); @@ -138,13 +138,13 @@ describe('Terminal49Client', () => { const headers = new Headers(calls[0].init?.headers); expect(headers.get('Authorization')).toBe('Token token-123'); expect(calls[0].url.searchParams.get('include')).toBe( - 'shipment,pod_terminal', + 'shipment,pod_terminal,pickup_facility', ); }); it('preserves bearer auth and sends account header when configured', async () => { const { fetchImpl, calls } = createMockFetch({ - '/containers/abc?include=shipment,pod_terminal': () => + '/containers/abc?include=shipment,pod_terminal,pickup_facility': () => jsonResponse({ data: { id: 'abc', attributes: {} } }), }); @@ -351,7 +351,8 @@ describe('Terminal49Client', () => { }; const { fetchImpl } = createMockFetch({ - '/containers?include=shipment,pod_terminal': () => jsonResponse(doc), + '/containers?include=shipment,pod_terminal,pickup_facility': () => + jsonResponse(doc), }); const client = new Terminal49Client({ diff --git a/sdks/typescript-sdk/src/client.ts b/sdks/typescript-sdk/src/client.ts index fabbcb57..a9035df3 100644 --- a/sdks/typescript-sdk/src/client.ts +++ b/sdks/typescript-sdk/src/client.ts @@ -146,7 +146,11 @@ export class Terminal49Client { /** Fetch a container by ID with optional included relationships. */ async getContainer( id: string, - include: IncludeParam = ['shipment', 'pod_terminal'], + include: IncludeParam = [ + 'shipment', + 'pod_terminal', + 'pickup_facility', + ], options?: CallOptions, ): Promise { return this.containers.get(id, include, options); diff --git a/sdks/typescript-sdk/src/client/jsonapi.ts b/sdks/typescript-sdk/src/client/jsonapi.ts index 8f32da4f..614e6b78 100644 --- a/sdks/typescript-sdk/src/client/jsonapi.ts +++ b/sdks/typescript-sdk/src/client/jsonapi.ts @@ -47,3 +47,21 @@ export class JsonApiDocument { return camelCase ? JsonApiDocument.toCamelCase(attrs) : attrs; } } + +/** + * Returns a shallow copy of `obj` with the given keys removed. Used by the + * mappers to keep the raw camelCased attribute spread from clobbering curated + * nested fields (and from emitting duplicate top-level scalars for the same + * underlying value). + */ +export function omitKeys>( + obj: T, + keys: readonly string[], +): Record { + const omit = new Set(keys); + const result: Record = {}; + for (const [key, value] of Object.entries(obj || {})) { + if (!omit.has(key)) result[key] = value; + } + return result; +} diff --git a/sdks/typescript-sdk/src/client/managers/containers.ts b/sdks/typescript-sdk/src/client/managers/containers.ts index b64a5d48..552298f9 100644 --- a/sdks/typescript-sdk/src/client/managers/containers.ts +++ b/sdks/typescript-sdk/src/client/managers/containers.ts @@ -16,12 +16,17 @@ import { BaseManager } from './base.js'; const DEFAULT_CONTAINER_INCLUDES = [ 'shipment', 'pod_terminal', + 'pickup_facility', ] as const satisfies readonly ContainerInclude[]; export class ContainerManager extends BaseManager { async get( id: string, - include: IncludeParam = ['shipment', 'pod_terminal'], + include: IncludeParam = [ + 'shipment', + 'pod_terminal', + 'pickup_facility', + ], options?: CallOptions, ): Promise { const includeParam = normalizeInclude(include); diff --git a/sdks/typescript-sdk/src/client/mappers.ts b/sdks/typescript-sdk/src/client/mappers.ts index d4a38369..0fe27011 100644 --- a/sdks/typescript-sdk/src/client/mappers.ts +++ b/sdks/typescript-sdk/src/client/mappers.ts @@ -5,7 +5,41 @@ import type { ShippingLine, TrackingRequest, } from '../types/models.js'; -import { JsonApiDocument } from './jsonapi.js'; +import { JsonApiDocument, omitKeys } from './jsonapi.js'; + +/** + * CamelCased raw container attribute keys that already feed a curated nested + * field (equipment/location/demurrage/terminals/rail) or a curated top-level + * field. They are dropped from the raw-attribute spread so the spread cannot + * clobber the curated nests or emit duplicate top-level scalars for the same + * value. + */ +const CONTAINER_CURATED_ATTR_KEYS = [ + 'number', + 'containerNumber', + 'status', + 'currentStatus', + 'equipmentType', + 'equipmentLength', + 'equipmentHeight', + 'weightInLbs', + 'locationAtPodTerminal', + 'availableForPickup', + 'podArrivedAt', + 'podDischargedAt', + 'pickupLfd', + 'pickupAppointmentAt', + 'feesAtPodTerminal', + 'holdsAtPodTerminal', + 'podRailCarrierScac', + 'indRailCarrierScac', + 'podRailLoadedAt', + 'podRailDepartedAt', + 'indRailArrivedAt', + 'indRailUnloadedAt', + 'indEtaAt', + 'indAtaAt', +] as const; export function mapTransportEvents(doc: any) { const apiDoc = new JsonApiDocument(doc); @@ -23,7 +57,8 @@ export function mapTransportEvents(doc: any) { ? { id: location.id, name: location.attributes?.name, - locode: location.attributes?.locode, + // Port/terminal resources expose `code`, not `locode`. + locode: location.attributes?.code ?? location.attributes?.locode, } : undefined, terminal: terminal @@ -51,7 +86,9 @@ export function mapRoute(doc: any): Route { if (!location) return null; const attrs = location.attributes || {}; - const port = apiDoc.getRelationship(location, 'port'); + // A route leg's port lives under the `location` relationship + // (type port|terminal); there is no `port` relationship. + const port = apiDoc.getRelationship(location, 'location'); const inboundVessel = apiDoc.getRelationship(location, 'inbound_vessel'); const outboundVessel = apiDoc.getRelationship( location, @@ -107,7 +144,7 @@ export function mapRoute(doc: any): Route { export function mapShippingLines(doc: any): ShippingLine[] { const data = Array.isArray(doc?.data) ? doc.data : []; return data - .map((item: any) => { + .map((item: any): ShippingLine | null => { const attrs = item?.attributes || {}; const scac = attrs.scac || item?.scac; if (!scac) return null; @@ -117,9 +154,15 @@ export function mapShippingLines(doc: any): ShippingLine[] { shortName: attrs.short_name || attrs.nickname || undefined, bolPrefix: attrs.bol_prefix || undefined, notes: attrs.notes || undefined, - } as ShippingLine; + alternativeScacs: Array.isArray(attrs.alternative_scacs) + ? attrs.alternative_scacs + : undefined, + billOfLadingTrackingSupport: attrs.bill_of_lading_tracking_support, + bookingNumberTrackingSupport: attrs.booking_number_tracking_support, + containerNumberTrackingSupport: attrs.container_number_tracking_support, + }; }) - .filter(Boolean) as ShippingLine[]; + .filter((line: ShippingLine | null): line is ShippingLine => line !== null); } export function mapContainer(doc: any): Container { @@ -130,7 +173,10 @@ export function mapContainer(doc: any): Container { const shipment = apiDoc.getRelationship(data, 'shipment'); const podTerminal = apiDoc.getRelationship(data, 'pod_terminal'); - const destTerminal = apiDoc.getRelationship(data, 'destination_terminal'); + // The inland/destination facility is exposed via the `pickup_facility` + // relationship; there is no `destination_terminal` relationship on a + // container resource. + const pickupFacility = apiDoc.getRelationship(data, 'pickup_facility'); const transportEvents = apiDoc.included .filter((item: any) => item.type === 'transport_event') @@ -145,7 +191,8 @@ export function mapContainer(doc: any): Container { ? { id: location.id, name: location.attributes?.name, - locode: location.attributes?.locode, + // Port/terminal resources expose `code`, not `locode`. + locode: location.attributes?.code ?? location.attributes?.locode, } : undefined, terminal: terminal @@ -161,9 +208,10 @@ export function mapContainer(doc: any): Container { return { id: data?.id, - ...attrCamel, + ...omitKeys(attrCamel, CONTAINER_CURATED_ATTR_KEYS), number: attrs.number || attrs.container_number, - status: attrs.status, + status: attrs.current_status ?? attrs.status, + currentStatus: attrs.current_status, equipment: { type: attrs.equipment_type, length: attrs.equipment_length, @@ -191,12 +239,14 @@ export function mapContainer(doc: any): Container { firmsCode: podTerminal.attributes?.firms_code, } : null, - destinationTerminal: destTerminal + // The container's inland/final pickup facility (real `pickup_facility` + // relationship) is surfaced here as the destination terminal. + destinationTerminal: pickupFacility ? { - id: destTerminal.id, - name: destTerminal.attributes?.name, - nickname: destTerminal.attributes?.nickname, - firmsCode: destTerminal.attributes?.firms_code, + id: pickupFacility.id, + name: pickupFacility.attributes?.name, + nickname: pickupFacility.attributes?.nickname, + firmsCode: pickupFacility.attributes?.firms_code, } : null, }, @@ -280,7 +330,8 @@ export function mapShipment(doc: any): Shipment { shipment.ports = { portOfLading: pol ? { - locode: pol.attributes?.locode, + // Port resources expose `code` (e.g. KRPUS), not `locode`. + locode: pol.attributes?.code ?? pol.attributes?.locode, name: pol.attributes?.name, code: pol.attributes?.code, countryCode: pol.attributes?.country_code, @@ -291,7 +342,7 @@ export function mapShipment(doc: any): Shipment { : null, portOfDischarge: pod ? { - locode: pod.attributes?.locode, + locode: pod.attributes?.code ?? pod.attributes?.locode, name: pod.attributes?.name, code: pod.attributes?.code, countryCode: pod.attributes?.country_code, diff --git a/sdks/typescript-sdk/src/fixtures/containers.get.pickup.json b/sdks/typescript-sdk/src/fixtures/containers.get.pickup.json new file mode 100644 index 00000000..c79aebc6 --- /dev/null +++ b/sdks/typescript-sdk/src/fixtures/containers.get.pickup.json @@ -0,0 +1,65 @@ +{ + "data": [ + { + "id": "cont-pickup-1", + "type": "container", + "attributes": { + "number": "MSCU1234567", + "seal_number": "SEAL987", + "current_status": "available", + "equipment_type": "dry", + "equipment_length": 40, + "equipment_height": "high_cube", + "weight_in_lbs": 38000, + "available_for_pickup": true, + "location_at_pod_terminal": "Yard A-12", + "pod_arrived_at": "2026-06-15T03:00:00Z", + "pod_discharged_at": "2026-06-15T09:30:00Z", + "pickup_lfd": "2026-06-20T23:59:00Z", + "pickup_appointment_at": "2026-06-19T15:00:00Z", + "fees_at_pod_terminal": [], + "holds_at_pod_terminal": [], + "pod_rail_carrier_scac": "BNSF", + "ind_rail_carrier_scac": "UP", + "ind_eta_at": "2026-06-25T12:00:00Z" + }, + "relationships": { + "shipment": { "data": { "id": "ship-pickup-1", "type": "shipment" } }, + "pod_terminal": { "data": { "id": "term-pod-1", "type": "terminal" } }, + "pickup_facility": { + "data": { "id": "term-pickup-1", "type": "terminal" } + }, + "transport_events": { "data": [] }, + "raw_events": { "data": [] } + } + } + ], + "included": [ + { + "id": "ship-pickup-1", + "type": "shipment", + "attributes": { + "bill_of_lading_number": "MAEU123456789", + "shipping_line_scac": "MAEU" + } + }, + { + "id": "term-pod-1", + "type": "terminal", + "attributes": { + "name": "APM Terminals Pier 400", + "nickname": "Pier 400", + "firms_code": "Y258" + } + }, + { + "id": "term-pickup-1", + "type": "terminal", + "attributes": { + "name": "BNSF Logistics Park Chicago", + "nickname": "LPC", + "firms_code": "Z901" + } + } + ] +} diff --git a/sdks/typescript-sdk/src/fixtures/containers.route.json b/sdks/typescript-sdk/src/fixtures/containers.route.json new file mode 100644 index 00000000..0c18fcf2 --- /dev/null +++ b/sdks/typescript-sdk/src/fixtures/containers.route.json @@ -0,0 +1,108 @@ +{ + "data": { + "id": "route-1", + "type": "route", + "attributes": { + "created_at": "2026-06-01T10:00:00Z", + "updated_at": "2026-06-10T12:30:00Z" + }, + "relationships": { + "route_locations": { + "data": [ + { "id": "rl-1", "type": "route_location" }, + { "id": "rl-2", "type": "route_location" } + ] + } + } + }, + "included": [ + { + "id": "rl-1", + "type": "route_location", + "attributes": { + "id": "rl-1", + "inbound_scac": null, + "inbound_mode": null, + "inbound_eta_at": null, + "inbound_ata_at": null, + "inbound_voyage_number": null, + "outbound_scac": "MAEU", + "outbound_mode": "vessel", + "outbound_etd_at": "2026-06-02T08:00:00Z", + "outbound_atd_at": "2026-06-02T09:15:00Z", + "outbound_voyage_number": "123W", + "created_at": "2026-06-01T10:00:00Z", + "updated_at": "2026-06-10T12:30:00Z" + }, + "relationships": { + "route": { "data": { "id": "route-1", "type": "route" } }, + "location": { "data": { "id": "port-cnsha", "type": "port" } }, + "inbound_vessel": { "data": null }, + "outbound_vessel": { "data": { "id": "vessel-1", "type": "vessel" } } + } + }, + { + "id": "rl-2", + "type": "route_location", + "attributes": { + "id": "rl-2", + "inbound_scac": "MAEU", + "inbound_mode": "vessel", + "inbound_eta_at": "2026-06-20T14:00:00Z", + "inbound_ata_at": null, + "inbound_voyage_number": "123W", + "outbound_scac": null, + "outbound_mode": null, + "outbound_etd_at": null, + "outbound_atd_at": null, + "outbound_voyage_number": null, + "created_at": "2026-06-01T10:00:00Z", + "updated_at": "2026-06-10T12:30:00Z" + }, + "relationships": { + "route": { "data": { "id": "route-1", "type": "route" } }, + "location": { "data": { "id": "port-uslax", "type": "port" } }, + "inbound_vessel": { "data": { "id": "vessel-1", "type": "vessel" } }, + "outbound_vessel": { "data": null } + } + }, + { + "id": "port-cnsha", + "type": "port", + "attributes": { + "id": "port-cnsha", + "name": "Shanghai", + "code": "CNSHA", + "state_abbr": "SH", + "city": "Shanghai", + "country_code": "CN", + "latitude": "31.22222", + "longitude": "121.45806", + "time_zone": "Asia/Shanghai" + } + }, + { + "id": "port-uslax", + "type": "port", + "attributes": { + "id": "port-uslax", + "name": "Los Angeles", + "code": "USLAX", + "state_abbr": "CA", + "city": "Los Angeles", + "country_code": "US", + "latitude": "33.74537", + "longitude": "-118.27370", + "time_zone": "America/Los_Angeles" + } + }, + { + "id": "vessel-1", + "type": "vessel", + "attributes": { + "name": "MAERSK EDINBURGH", + "imo": "9456769" + } + } + ] +} diff --git a/sdks/typescript-sdk/src/types/models.ts b/sdks/typescript-sdk/src/types/models.ts index 309386ab..ce1a6cc9 100644 --- a/sdks/typescript-sdk/src/types/models.ts +++ b/sdks/typescript-sdk/src/types/models.ts @@ -5,6 +5,14 @@ export interface ShippingLine { shortName?: string; bolPrefix?: string; notes?: string; + /** Additional SCACs the carrier tracks under. */ + alternativeScacs?: string[]; + /** Whether the carrier supports tracking by bill of lading number. */ + billOfLadingTrackingSupport?: boolean; + /** Whether the carrier supports tracking by booking number. */ + bookingNumberTrackingSupport?: boolean; + /** Whether the carrier supports tracking by container number. */ + containerNumberTrackingSupport?: boolean; } /** Pagination links returned by Terminal49 list endpoints. */ @@ -29,6 +37,8 @@ export interface Container { id: string; number?: string; status?: string; + /** Raw `current_status` from the API (also surfaced via `status`). */ + currentStatus?: string; equipment?: { type?: string; length?: number; diff --git a/sdks/typescript-sdk/src/types/options.ts b/sdks/typescript-sdk/src/types/options.ts index e112c4d7..79d71d28 100644 --- a/sdks/typescript-sdk/src/types/options.ts +++ b/sdks/typescript-sdk/src/types/options.ts @@ -30,6 +30,9 @@ export type ShipmentInclude = export type ContainerInclude = | 'shipment' | 'pod_terminal' + // The inland/final-destination facility is exposed via `pickup_facility`; + // containers have no `destination_terminal` relationship (that one is on + // shipments). The container mapper resolves `destinationTerminal` from this. | 'pickup_facility' | 'transport_events';