@freighttech/shipment-tracking 0.6.0

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 (456) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +25 -0
  3. package/dist/generated/entities/bic_config/index.js +21 -0
  4. package/dist/generated/entities/bic_config/index.js.map +7 -0
  5. package/dist/generated/entities/carrier_config/index.js +27 -0
  6. package/dist/generated/entities/carrier_config/index.js.map +7 -0
  7. package/dist/generated/entities/location_override/index.js +33 -0
  8. package/dist/generated/entities/location_override/index.js.map +7 -0
  9. package/dist/generated/entities/shipment/index.js +73 -0
  10. package/dist/generated/entities/shipment/index.js.map +7 -0
  11. package/dist/generated/entities/ships_go_config/index.js +31 -0
  12. package/dist/generated/entities/ships_go_config/index.js.map +7 -0
  13. package/dist/generated/entities/tracking_event/index.js +101 -0
  14. package/dist/generated/entities/tracking_event/index.js.map +7 -0
  15. package/dist/generated/entities/tracking_job/index.js +47 -0
  16. package/dist/generated/entities/tracking_job/index.js.map +7 -0
  17. package/dist/generated/entities/webhook/index.js +25 -0
  18. package/dist/generated/entities/webhook/index.js.map +7 -0
  19. package/dist/generated/entities/webhook_delivery/index.js +25 -0
  20. package/dist/generated/entities/webhook_delivery/index.js.map +7 -0
  21. package/dist/generated/entities.ids.generated.js +21 -0
  22. package/dist/generated/entities.ids.generated.js.map +7 -0
  23. package/dist/index.js +41 -0
  24. package/dist/index.js.map +7 -0
  25. package/dist/modules/shipment_tracking/acl.js +22 -0
  26. package/dist/modules/shipment_tracking/acl.js.map +7 -0
  27. package/dist/modules/shipment_tracking/api/bic-configs/route.js +176 -0
  28. package/dist/modules/shipment_tracking/api/bic-configs/route.js.map +7 -0
  29. package/dist/modules/shipment_tracking/api/bic-configs/test/route.js +95 -0
  30. package/dist/modules/shipment_tracking/api/bic-configs/test/route.js.map +7 -0
  31. package/dist/modules/shipment_tracking/api/cargo-events/route.js +93 -0
  32. package/dist/modules/shipment_tracking/api/cargo-events/route.js.map +7 -0
  33. package/dist/modules/shipment_tracking/api/carrier-configs/route.js +139 -0
  34. package/dist/modules/shipment_tracking/api/carrier-configs/route.js.map +7 -0
  35. package/dist/modules/shipment_tracking/api/dashboard/ships-map/route.js +177 -0
  36. package/dist/modules/shipment_tracking/api/dashboard/ships-map/route.js.map +7 -0
  37. package/dist/modules/shipment_tracking/api/location-overrides/route.js +179 -0
  38. package/dist/modules/shipment_tracking/api/location-overrides/route.js.map +7 -0
  39. package/dist/modules/shipment_tracking/api/openapi.js +27 -0
  40. package/dist/modules/shipment_tracking/api/openapi.js.map +7 -0
  41. package/dist/modules/shipment_tracking/api/shipments/route.js +192 -0
  42. package/dist/modules/shipment_tracking/api/shipments/route.js.map +7 -0
  43. package/dist/modules/shipment_tracking/api/shipsgo-configs/route.js +185 -0
  44. package/dist/modules/shipment_tracking/api/shipsgo-configs/route.js.map +7 -0
  45. package/dist/modules/shipment_tracking/api/tracking-jobs/route.js +144 -0
  46. package/dist/modules/shipment_tracking/api/tracking-jobs/route.js.map +7 -0
  47. package/dist/modules/shipment_tracking/api/tracking-jobs/sync/route.js +107 -0
  48. package/dist/modules/shipment_tracking/api/tracking-jobs/sync/route.js.map +7 -0
  49. package/dist/modules/shipment_tracking/api/utils.js +12 -0
  50. package/dist/modules/shipment_tracking/api/utils.js.map +7 -0
  51. package/dist/modules/shipment_tracking/api/webhooks/deliveries/route.js +76 -0
  52. package/dist/modules/shipment_tracking/api/webhooks/deliveries/route.js.map +7 -0
  53. package/dist/modules/shipment_tracking/api/webhooks/route.js +135 -0
  54. package/dist/modules/shipment_tracking/api/webhooks/route.js.map +7 -0
  55. package/dist/modules/shipment_tracking/api/webhooks/test/route.js +71 -0
  56. package/dist/modules/shipment_tracking/api/webhooks/test/route.js.map +7 -0
  57. package/dist/modules/shipment_tracking/assets/carriers/cma_cgm.png +0 -0
  58. package/dist/modules/shipment_tracking/assets/carriers/cosco.png +0 -0
  59. package/dist/modules/shipment_tracking/assets/carriers/evergreen.png +0 -0
  60. package/dist/modules/shipment_tracking/assets/carriers/hapag_lloyd.png +0 -0
  61. package/dist/modules/shipment_tracking/assets/carriers/maersk.png +0 -0
  62. package/dist/modules/shipment_tracking/assets/carriers/msc.png +0 -0
  63. package/dist/modules/shipment_tracking/assets/carriers/one.png +0 -0
  64. package/dist/modules/shipment_tracking/assets/carriers/oocl.png +0 -0
  65. package/dist/modules/shipment_tracking/assets/carriers/yang_ming.png +0 -0
  66. package/dist/modules/shipment_tracking/assets/carriers/zim.png +0 -0
  67. package/dist/modules/shipment_tracking/assets/index.js +52 -0
  68. package/dist/modules/shipment_tracking/assets/index.js.map +7 -0
  69. package/dist/modules/shipment_tracking/backend/carrier-configs/page.js +621 -0
  70. package/dist/modules/shipment_tracking/backend/carrier-configs/page.js.map +7 -0
  71. package/dist/modules/shipment_tracking/backend/carrier-configs/page.meta.js +19 -0
  72. package/dist/modules/shipment_tracking/backend/carrier-configs/page.meta.js.map +7 -0
  73. package/dist/modules/shipment_tracking/backend/location-overrides/page.js +198 -0
  74. package/dist/modules/shipment_tracking/backend/location-overrides/page.js.map +7 -0
  75. package/dist/modules/shipment_tracking/backend/location-overrides/page.meta.js +19 -0
  76. package/dist/modules/shipment_tracking/backend/location-overrides/page.meta.js.map +7 -0
  77. package/dist/modules/shipment_tracking/backend/shipment-tracking/page.js +279 -0
  78. package/dist/modules/shipment_tracking/backend/shipment-tracking/page.js.map +7 -0
  79. package/dist/modules/shipment_tracking/backend/shipment-tracking/page.meta.js +19 -0
  80. package/dist/modules/shipment_tracking/backend/shipment-tracking/page.meta.js.map +7 -0
  81. package/dist/modules/shipment_tracking/backend/tracking-jobs/page.js +10 -0
  82. package/dist/modules/shipment_tracking/backend/tracking-jobs/page.js.map +7 -0
  83. package/dist/modules/shipment_tracking/backend/tracking-jobs/page.meta.js +19 -0
  84. package/dist/modules/shipment_tracking/backend/tracking-jobs/page.meta.js.map +7 -0
  85. package/dist/modules/shipment_tracking/backend/webhooks/page.js +198 -0
  86. package/dist/modules/shipment_tracking/backend/webhooks/page.js.map +7 -0
  87. package/dist/modules/shipment_tracking/backend/webhooks/page.meta.js +19 -0
  88. package/dist/modules/shipment_tracking/backend/webhooks/page.meta.js.map +7 -0
  89. package/dist/modules/shipment_tracking/ce.js +52 -0
  90. package/dist/modules/shipment_tracking/ce.js.map +7 -0
  91. package/dist/modules/shipment_tracking/cli.js +229 -0
  92. package/dist/modules/shipment_tracking/cli.js.map +7 -0
  93. package/dist/modules/shipment_tracking/commands/bic-configs.js +66 -0
  94. package/dist/modules/shipment_tracking/commands/bic-configs.js.map +7 -0
  95. package/dist/modules/shipment_tracking/commands/carrier-configs.js +65 -0
  96. package/dist/modules/shipment_tracking/commands/carrier-configs.js.map +7 -0
  97. package/dist/modules/shipment_tracking/commands/index.js +9 -0
  98. package/dist/modules/shipment_tracking/commands/index.js.map +7 -0
  99. package/dist/modules/shipment_tracking/commands/location-overrides.js +93 -0
  100. package/dist/modules/shipment_tracking/commands/location-overrides.js.map +7 -0
  101. package/dist/modules/shipment_tracking/commands/shipments.js +200 -0
  102. package/dist/modules/shipment_tracking/commands/shipments.js.map +7 -0
  103. package/dist/modules/shipment_tracking/commands/shipsgo-configs.js +75 -0
  104. package/dist/modules/shipment_tracking/commands/shipsgo-configs.js.map +7 -0
  105. package/dist/modules/shipment_tracking/commands/tracking-jobs.js +120 -0
  106. package/dist/modules/shipment_tracking/commands/tracking-jobs.js.map +7 -0
  107. package/dist/modules/shipment_tracking/commands/tracking.js +140 -0
  108. package/dist/modules/shipment_tracking/commands/tracking.js.map +7 -0
  109. package/dist/modules/shipment_tracking/commands/webhooks.js +115 -0
  110. package/dist/modules/shipment_tracking/commands/webhooks.js.map +7 -0
  111. package/dist/modules/shipment_tracking/components/CombinedTimestampCell.js +168 -0
  112. package/dist/modules/shipment_tracking/components/CombinedTimestampCell.js.map +7 -0
  113. package/dist/modules/shipment_tracking/components/FacilityLocationInput.js +202 -0
  114. package/dist/modules/shipment_tracking/components/FacilityLocationInput.js.map +7 -0
  115. package/dist/modules/shipment_tracking/components/LocationOverrideDrawer.js +501 -0
  116. package/dist/modules/shipment_tracking/components/LocationOverrideDrawer.js.map +7 -0
  117. package/dist/modules/shipment_tracking/components/ShipmentDetailsDrawer.js +818 -0
  118. package/dist/modules/shipment_tracking/components/ShipmentDetailsDrawer.js.map +7 -0
  119. package/dist/modules/shipment_tracking/components/ShipmentDrawer.js +404 -0
  120. package/dist/modules/shipment_tracking/components/ShipmentDrawer.js.map +7 -0
  121. package/dist/modules/shipment_tracking/components/ShipsGoConfigSection.js +200 -0
  122. package/dist/modules/shipment_tracking/components/ShipsGoConfigSection.js.map +7 -0
  123. package/dist/modules/shipment_tracking/components/TimestampCell.js +108 -0
  124. package/dist/modules/shipment_tracking/components/TimestampCell.js.map +7 -0
  125. package/dist/modules/shipment_tracking/components/TrackingJobDrawer.js +343 -0
  126. package/dist/modules/shipment_tracking/components/TrackingJobDrawer.js.map +7 -0
  127. package/dist/modules/shipment_tracking/components/VesselTrackingMap.js +496 -0
  128. package/dist/modules/shipment_tracking/components/VesselTrackingMap.js.map +7 -0
  129. package/dist/modules/shipment_tracking/components/WebhookDrawer.js +332 -0
  130. package/dist/modules/shipment_tracking/components/WebhookDrawer.js.map +7 -0
  131. package/dist/modules/shipment_tracking/data/entities.js +686 -0
  132. package/dist/modules/shipment_tracking/data/entities.js.map +7 -0
  133. package/dist/modules/shipment_tracking/data/validators.js +434 -0
  134. package/dist/modules/shipment_tracking/data/validators.js.map +7 -0
  135. package/dist/modules/shipment_tracking/di.js +60 -0
  136. package/dist/modules/shipment_tracking/di.js.map +7 -0
  137. package/dist/modules/shipment_tracking/encryption.js +12 -0
  138. package/dist/modules/shipment_tracking/encryption.js.map +7 -0
  139. package/dist/modules/shipment_tracking/events.js +87 -0
  140. package/dist/modules/shipment_tracking/events.js.map +7 -0
  141. package/dist/modules/shipment_tracking/hooks/useVesselTracking.js +137 -0
  142. package/dist/modules/shipment_tracking/hooks/useVesselTracking.js.map +7 -0
  143. package/dist/modules/shipment_tracking/i18n/de.json +327 -0
  144. package/dist/modules/shipment_tracking/i18n/en.json +327 -0
  145. package/dist/modules/shipment_tracking/i18n/es.json +327 -0
  146. package/dist/modules/shipment_tracking/i18n/pl.json +327 -0
  147. package/dist/modules/shipment_tracking/index.js +15 -0
  148. package/dist/modules/shipment_tracking/index.js.map +7 -0
  149. package/dist/modules/shipment_tracking/lib/adapters/cma-cgm.js +66 -0
  150. package/dist/modules/shipment_tracking/lib/adapters/cma-cgm.js.map +7 -0
  151. package/dist/modules/shipment_tracking/lib/adapters/cosco-types.js +1 -0
  152. package/dist/modules/shipment_tracking/lib/adapters/cosco-types.js.map +7 -0
  153. package/dist/modules/shipment_tracking/lib/adapters/cosco.js +236 -0
  154. package/dist/modules/shipment_tracking/lib/adapters/cosco.js.map +7 -0
  155. package/dist/modules/shipment_tracking/lib/adapters/evergreen.js +87 -0
  156. package/dist/modules/shipment_tracking/lib/adapters/evergreen.js.map +7 -0
  157. package/dist/modules/shipment_tracking/lib/adapters/hapag-lloyd.js +68 -0
  158. package/dist/modules/shipment_tracking/lib/adapters/hapag-lloyd.js.map +7 -0
  159. package/dist/modules/shipment_tracking/lib/adapters/index.js +20 -0
  160. package/dist/modules/shipment_tracking/lib/adapters/index.js.map +7 -0
  161. package/dist/modules/shipment_tracking/lib/adapters/maersk.js +84 -0
  162. package/dist/modules/shipment_tracking/lib/adapters/maersk.js.map +7 -0
  163. package/dist/modules/shipment_tracking/lib/adapters/msc.js +170 -0
  164. package/dist/modules/shipment_tracking/lib/adapters/msc.js.map +7 -0
  165. package/dist/modules/shipment_tracking/lib/adapters/zim.js +85 -0
  166. package/dist/modules/shipment_tracking/lib/adapters/zim.js.map +7 -0
  167. package/dist/modules/shipment_tracking/lib/audit-helpers.js +20 -0
  168. package/dist/modules/shipment_tracking/lib/audit-helpers.js.map +7 -0
  169. package/dist/modules/shipment_tracking/lib/auth/base64url.js +8 -0
  170. package/dist/modules/shipment_tracking/lib/auth/base64url.js.map +7 -0
  171. package/dist/modules/shipment_tracking/lib/auth/oauth-client.js +38 -0
  172. package/dist/modules/shipment_tracking/lib/auth/oauth-client.js.map +7 -0
  173. package/dist/modules/shipment_tracking/lib/background.js +15 -0
  174. package/dist/modules/shipment_tracking/lib/background.js.map +7 -0
  175. package/dist/modules/shipment_tracking/lib/bic-api-client.js +106 -0
  176. package/dist/modules/shipment_tracking/lib/bic-api-client.js.map +7 -0
  177. package/dist/modules/shipment_tracking/lib/carrier-adapter.js +17 -0
  178. package/dist/modules/shipment_tracking/lib/carrier-adapter.js.map +7 -0
  179. package/dist/modules/shipment_tracking/lib/current-vessel.js +149 -0
  180. package/dist/modules/shipment_tracking/lib/current-vessel.js.map +7 -0
  181. package/dist/modules/shipment_tracking/lib/dcsa-event-mapping.js +124 -0
  182. package/dist/modules/shipment_tracking/lib/dcsa-event-mapping.js.map +7 -0
  183. package/dist/modules/shipment_tracking/lib/dcsa-event-parser.js +112 -0
  184. package/dist/modules/shipment_tracking/lib/dcsa-event-parser.js.map +7 -0
  185. package/dist/modules/shipment_tracking/lib/dcsa-params.js +16 -0
  186. package/dist/modules/shipment_tracking/lib/dcsa-params.js.map +7 -0
  187. package/dist/modules/shipment_tracking/lib/empty-return.js +48 -0
  188. package/dist/modules/shipment_tracking/lib/empty-return.js.map +7 -0
  189. package/dist/modules/shipment_tracking/lib/location-overrides.js +85 -0
  190. package/dist/modules/shipment_tracking/lib/location-overrides.js.map +7 -0
  191. package/dist/modules/shipment_tracking/lib/location-types.js +59 -0
  192. package/dist/modules/shipment_tracking/lib/location-types.js.map +7 -0
  193. package/dist/modules/shipment_tracking/lib/logger.js +113 -0
  194. package/dist/modules/shipment_tracking/lib/logger.js.map +7 -0
  195. package/dist/modules/shipment_tracking/lib/poi-event-processor.js +183 -0
  196. package/dist/modules/shipment_tracking/lib/poi-event-processor.js.map +7 -0
  197. package/dist/modules/shipment_tracking/lib/poi-nats-config.js +52 -0
  198. package/dist/modules/shipment_tracking/lib/poi-nats-config.js.map +7 -0
  199. package/dist/modules/shipment_tracking/lib/poi-types.js +43 -0
  200. package/dist/modules/shipment_tracking/lib/poi-types.js.map +7 -0
  201. package/dist/modules/shipment_tracking/lib/rate-limiter.js +31 -0
  202. package/dist/modules/shipment_tracking/lib/rate-limiter.js.map +7 -0
  203. package/dist/modules/shipment_tracking/lib/route-extraction.js +187 -0
  204. package/dist/modules/shipment_tracking/lib/route-extraction.js.map +7 -0
  205. package/dist/modules/shipment_tracking/lib/route-inference.js +106 -0
  206. package/dist/modules/shipment_tracking/lib/route-inference.js.map +7 -0
  207. package/dist/modules/shipment_tracking/lib/schedule-generator.js +63 -0
  208. package/dist/modules/shipment_tracking/lib/schedule-generator.js.map +7 -0
  209. package/dist/modules/shipment_tracking/lib/shipsgo-client.js +143 -0
  210. package/dist/modules/shipment_tracking/lib/shipsgo-client.js.map +7 -0
  211. package/dist/modules/shipment_tracking/lib/shipsgo-mapper.js +204 -0
  212. package/dist/modules/shipment_tracking/lib/shipsgo-mapper.js.map +7 -0
  213. package/dist/modules/shipment_tracking/lib/status-machine.js +131 -0
  214. package/dist/modules/shipment_tracking/lib/status-machine.js.map +7 -0
  215. package/dist/modules/shipment_tracking/lib/time-extraction.js +41 -0
  216. package/dist/modules/shipment_tracking/lib/time-extraction.js.map +7 -0
  217. package/dist/modules/shipment_tracking/lib/timestamp-utils.js +100 -0
  218. package/dist/modules/shipment_tracking/lib/timestamp-utils.js.map +7 -0
  219. package/dist/modules/shipment_tracking/lib/vessel-api.js +111 -0
  220. package/dist/modules/shipment_tracking/lib/vessel-api.js.map +7 -0
  221. package/dist/modules/shipment_tracking/lib/vessel-cache.js +50 -0
  222. package/dist/modules/shipment_tracking/lib/vessel-cache.js.map +7 -0
  223. package/dist/modules/shipment_tracking/lib/vessel-extraction.js +13 -0
  224. package/dist/modules/shipment_tracking/lib/vessel-extraction.js.map +7 -0
  225. package/dist/modules/shipment_tracking/lib/webhook-dispatcher.js +62 -0
  226. package/dist/modules/shipment_tracking/lib/webhook-dispatcher.js.map +7 -0
  227. package/dist/modules/shipment_tracking/migrations/Migration20260422161208_shipment_tracking.js +48 -0
  228. package/dist/modules/shipment_tracking/migrations/Migration20260422161208_shipment_tracking.js.map +7 -0
  229. package/dist/modules/shipment_tracking/migrations/Migration20260529120000_shipment_tracking_poi_region_names.js +15 -0
  230. package/dist/modules/shipment_tracking/migrations/Migration20260529120000_shipment_tracking_poi_region_names.js.map +7 -0
  231. package/dist/modules/shipment_tracking/migrations/Migration20260821120000_shipment_tracking_shipsgo.js +47 -0
  232. package/dist/modules/shipment_tracking/migrations/Migration20260821120000_shipment_tracking_shipsgo.js.map +7 -0
  233. package/dist/modules/shipment_tracking/notifications.js +145 -0
  234. package/dist/modules/shipment_tracking/notifications.js.map +7 -0
  235. package/dist/modules/shipment_tracking/services/carrierRegistry.js +24 -0
  236. package/dist/modules/shipment_tracking/services/carrierRegistry.js.map +7 -0
  237. package/dist/modules/shipment_tracking/services/poiNatsService.js +397 -0
  238. package/dist/modules/shipment_tracking/services/poiNatsService.js.map +7 -0
  239. package/dist/modules/shipment_tracking/services/shipsGoProvider.js +163 -0
  240. package/dist/modules/shipment_tracking/services/shipsGoProvider.js.map +7 -0
  241. package/dist/modules/shipment_tracking/services/trackingService.js +1283 -0
  242. package/dist/modules/shipment_tracking/services/trackingService.js.map +7 -0
  243. package/dist/modules/shipment_tracking/services/webhookService.js +263 -0
  244. package/dist/modules/shipment_tracking/services/webhookService.js.map +7 -0
  245. package/dist/modules/shipment_tracking/setup.js +72 -0
  246. package/dist/modules/shipment_tracking/setup.js.map +7 -0
  247. package/dist/modules/shipment_tracking/subscribers/cargo-event-created.js +60 -0
  248. package/dist/modules/shipment_tracking/subscribers/cargo-event-created.js.map +7 -0
  249. package/dist/modules/shipment_tracking/subscribers/dcsa-event-webhook-dispatch.js +58 -0
  250. package/dist/modules/shipment_tracking/subscribers/dcsa-event-webhook-dispatch.js.map +7 -0
  251. package/dist/modules/shipment_tracking/subscribers/poi-event-notification.js +82 -0
  252. package/dist/modules/shipment_tracking/subscribers/poi-event-notification.js.map +7 -0
  253. package/dist/modules/shipment_tracking/subscribers/poi-event-webhook-dispatch.js +53 -0
  254. package/dist/modules/shipment_tracking/subscribers/poi-event-webhook-dispatch.js.map +7 -0
  255. package/dist/modules/shipment_tracking/subscribers/shipment-status-changed.js +28 -0
  256. package/dist/modules/shipment_tracking/subscribers/shipment-status-changed.js.map +7 -0
  257. package/dist/modules/shipment_tracking/subscribers/terminal-event-ingest.js +67 -0
  258. package/dist/modules/shipment_tracking/subscribers/terminal-event-ingest.js.map +7 -0
  259. package/dist/modules/shipment_tracking/subscribers/tracking-job-initial-poll.js +30 -0
  260. package/dist/modules/shipment_tracking/subscribers/tracking-job-initial-poll.js.map +7 -0
  261. package/dist/modules/shipment_tracking/subscribers/tracking-job-webhook-dispatch.js +19 -0
  262. package/dist/modules/shipment_tracking/subscribers/tracking-job-webhook-dispatch.js.map +7 -0
  263. package/dist/modules/shipment_tracking/tables/index.js +6 -0
  264. package/dist/modules/shipment_tracking/tables/index.js.map +7 -0
  265. package/dist/modules/shipment_tracking/tables/tracking_job/table.client.js +284 -0
  266. package/dist/modules/shipment_tracking/tables/tracking_job/table.client.js.map +7 -0
  267. package/dist/modules/shipment_tracking/tables/tracking_job/table.js +36 -0
  268. package/dist/modules/shipment_tracking/tables/tracking_job/table.js.map +7 -0
  269. package/dist/modules/shipment_tracking/widgets/dashboard/ships-map/config.js +25 -0
  270. package/dist/modules/shipment_tracking/widgets/dashboard/ships-map/config.js.map +7 -0
  271. package/dist/modules/shipment_tracking/widgets/dashboard/ships-map/widget.client.js +355 -0
  272. package/dist/modules/shipment_tracking/widgets/dashboard/ships-map/widget.client.js.map +7 -0
  273. package/dist/modules/shipment_tracking/widgets/dashboard/ships-map/widget.js +26 -0
  274. package/dist/modules/shipment_tracking/widgets/dashboard/ships-map/widget.js.map +7 -0
  275. package/dist/types/assets.d.js +1 -0
  276. package/dist/types/assets.d.js.map +7 -0
  277. package/generated/entities/bic_config/index.ts +20 -0
  278. package/generated/entities/carrier_config/index.ts +23 -0
  279. package/generated/entities/location_override/index.ts +26 -0
  280. package/generated/entities/shipment/index.ts +46 -0
  281. package/generated/entities/ships_go_config/index.ts +25 -0
  282. package/generated/entities/tracking_event/index.ts +60 -0
  283. package/generated/entities/tracking_job/index.ts +33 -0
  284. package/generated/entities/webhook/index.ts +22 -0
  285. package/generated/entities/webhook_delivery/index.ts +22 -0
  286. package/generated/entities.ids.generated.ts +47 -0
  287. package/package.json +81 -0
  288. package/src/index.ts +39 -0
  289. package/src/modules/shipment_tracking/__integration__/TC-TRACK-410-shipsgo-config-api.spec.ts +133 -0
  290. package/src/modules/shipment_tracking/__integration__/meta.ts +4 -0
  291. package/src/modules/shipment_tracking/__tests__/empty-return-stop.test.ts +231 -0
  292. package/src/modules/shipment_tracking/__tests__/fixtures/hapag-lloyd-transship-container.json +702 -0
  293. package/src/modules/shipment_tracking/__tests__/fixtures/index.ts +434 -0
  294. package/src/modules/shipment_tracking/__tests__/fixtures/maersk-multi-transship-completed.json +1518 -0
  295. package/src/modules/shipment_tracking/__tests__/fixtures/maersk-transship-intransit.json +558 -0
  296. package/src/modules/shipment_tracking/__tests__/fixtures/msc-direct.json +222 -0
  297. package/src/modules/shipment_tracking/__tests__/fixtures/msc-multi-transship-completed.json +974 -0
  298. package/src/modules/shipment_tracking/__tests__/fixtures/msc-multi.json +4846 -0
  299. package/src/modules/shipment_tracking/__tests__/fixtures/msc-transship.json +409 -0
  300. package/src/modules/shipment_tracking/__tests__/fixtures/poi-events.json +240 -0
  301. package/src/modules/shipment_tracking/__tests__/fixtures/synthetic/index.ts +138 -0
  302. package/src/modules/shipment_tracking/__tests__/fixtures/synthetic-eta-updates.json +288 -0
  303. package/src/modules/shipment_tracking/__tests__/fixtures/synthetic-route-inference.json +201 -0
  304. package/src/modules/shipment_tracking/__tests__/fixtures/synthetic-voyage-progression.json +410 -0
  305. package/src/modules/shipment_tracking/__tests__/shipsgo-air-mapper.test.ts +157 -0
  306. package/src/modules/shipment_tracking/__tests__/shipsgo-client.test.ts +155 -0
  307. package/src/modules/shipment_tracking/__tests__/shipsgo-mapper.test.ts +201 -0
  308. package/src/modules/shipment_tracking/__tests__/shipsgo-poll.test.ts +357 -0
  309. package/src/modules/shipment_tracking/__tests__/shipsgo-provider.test.ts +364 -0
  310. package/src/modules/shipment_tracking/__tests__/trackingService.integration.test.ts +2248 -0
  311. package/src/modules/shipment_tracking/__tests__/webhookPayload.test.ts +151 -0
  312. package/src/modules/shipment_tracking/acl.ts +18 -0
  313. package/src/modules/shipment_tracking/api/bic-configs/route.ts +206 -0
  314. package/src/modules/shipment_tracking/api/bic-configs/test/route.ts +108 -0
  315. package/src/modules/shipment_tracking/api/cargo-events/route.ts +101 -0
  316. package/src/modules/shipment_tracking/api/carrier-configs/route.ts +142 -0
  317. package/src/modules/shipment_tracking/api/dashboard/ships-map/route.ts +246 -0
  318. package/src/modules/shipment_tracking/api/location-overrides/route.ts +185 -0
  319. package/src/modules/shipment_tracking/api/openapi.ts +28 -0
  320. package/src/modules/shipment_tracking/api/shipments/route.ts +197 -0
  321. package/src/modules/shipment_tracking/api/shipsgo-configs/route.ts +215 -0
  322. package/src/modules/shipment_tracking/api/tracking-jobs/route.ts +149 -0
  323. package/src/modules/shipment_tracking/api/tracking-jobs/sync/route.ts +116 -0
  324. package/src/modules/shipment_tracking/api/utils.ts +10 -0
  325. package/src/modules/shipment_tracking/api/webhooks/deliveries/route.ts +82 -0
  326. package/src/modules/shipment_tracking/api/webhooks/route.ts +138 -0
  327. package/src/modules/shipment_tracking/api/webhooks/test/route.ts +84 -0
  328. package/src/modules/shipment_tracking/assets/carriers/cma_cgm.png +0 -0
  329. package/src/modules/shipment_tracking/assets/carriers/cosco.png +0 -0
  330. package/src/modules/shipment_tracking/assets/carriers/evergreen.png +0 -0
  331. package/src/modules/shipment_tracking/assets/carriers/hapag_lloyd.png +0 -0
  332. package/src/modules/shipment_tracking/assets/carriers/maersk.png +0 -0
  333. package/src/modules/shipment_tracking/assets/carriers/msc.png +0 -0
  334. package/src/modules/shipment_tracking/assets/carriers/one.png +0 -0
  335. package/src/modules/shipment_tracking/assets/carriers/oocl.png +0 -0
  336. package/src/modules/shipment_tracking/assets/carriers/yang_ming.png +0 -0
  337. package/src/modules/shipment_tracking/assets/carriers/zim.png +0 -0
  338. package/src/modules/shipment_tracking/assets/index.ts +66 -0
  339. package/src/modules/shipment_tracking/backend/carrier-configs/page.meta.ts +16 -0
  340. package/src/modules/shipment_tracking/backend/carrier-configs/page.tsx +782 -0
  341. package/src/modules/shipment_tracking/backend/location-overrides/page.meta.ts +16 -0
  342. package/src/modules/shipment_tracking/backend/location-overrides/page.tsx +241 -0
  343. package/src/modules/shipment_tracking/backend/shipment-tracking/page.meta.ts +16 -0
  344. package/src/modules/shipment_tracking/backend/shipment-tracking/page.tsx +340 -0
  345. package/src/modules/shipment_tracking/backend/tracking-jobs/page.meta.ts +16 -0
  346. package/src/modules/shipment_tracking/backend/tracking-jobs/page.tsx +16 -0
  347. package/src/modules/shipment_tracking/backend/webhooks/page.meta.ts +16 -0
  348. package/src/modules/shipment_tracking/backend/webhooks/page.tsx +228 -0
  349. package/src/modules/shipment_tracking/ce.ts +48 -0
  350. package/src/modules/shipment_tracking/cli.ts +321 -0
  351. package/src/modules/shipment_tracking/commands/bic-configs.ts +94 -0
  352. package/src/modules/shipment_tracking/commands/carrier-configs.ts +89 -0
  353. package/src/modules/shipment_tracking/commands/index.ts +8 -0
  354. package/src/modules/shipment_tracking/commands/location-overrides.ts +119 -0
  355. package/src/modules/shipment_tracking/commands/shipments.ts +247 -0
  356. package/src/modules/shipment_tracking/commands/shipsgo-configs.ts +101 -0
  357. package/src/modules/shipment_tracking/commands/tracking-jobs.ts +158 -0
  358. package/src/modules/shipment_tracking/commands/tracking.ts +230 -0
  359. package/src/modules/shipment_tracking/commands/webhooks.ts +159 -0
  360. package/src/modules/shipment_tracking/components/CombinedTimestampCell.tsx +236 -0
  361. package/src/modules/shipment_tracking/components/FacilityLocationInput.tsx +235 -0
  362. package/src/modules/shipment_tracking/components/LocationOverrideDrawer.tsx +586 -0
  363. package/src/modules/shipment_tracking/components/ShipmentDetailsDrawer.tsx +1364 -0
  364. package/src/modules/shipment_tracking/components/ShipmentDrawer.tsx +498 -0
  365. package/src/modules/shipment_tracking/components/ShipsGoConfigSection.tsx +266 -0
  366. package/src/modules/shipment_tracking/components/TimestampCell.tsx +146 -0
  367. package/src/modules/shipment_tracking/components/TrackingJobDrawer.tsx +467 -0
  368. package/src/modules/shipment_tracking/components/VesselTrackingMap.tsx +761 -0
  369. package/src/modules/shipment_tracking/components/WebhookDrawer.tsx +434 -0
  370. package/src/modules/shipment_tracking/data/entities.ts +748 -0
  371. package/src/modules/shipment_tracking/data/validators.ts +523 -0
  372. package/src/modules/shipment_tracking/di.ts +73 -0
  373. package/src/modules/shipment_tracking/encryption.ts +17 -0
  374. package/src/modules/shipment_tracking/events.ts +122 -0
  375. package/src/modules/shipment_tracking/hooks/useVesselTracking.ts +297 -0
  376. package/src/modules/shipment_tracking/i18n/de.json +327 -0
  377. package/src/modules/shipment_tracking/i18n/en.json +327 -0
  378. package/src/modules/shipment_tracking/i18n/es.json +327 -0
  379. package/src/modules/shipment_tracking/i18n/pl.json +327 -0
  380. package/src/modules/shipment_tracking/index.ts +13 -0
  381. package/src/modules/shipment_tracking/lib/__tests__/dcsa-event-mapping.test.ts +385 -0
  382. package/src/modules/shipment_tracking/lib/__tests__/dcsa-event-parser.test.ts +398 -0
  383. package/src/modules/shipment_tracking/lib/__tests__/empty-return.test.ts +174 -0
  384. package/src/modules/shipment_tracking/lib/__tests__/poi-event-processor.test.ts +699 -0
  385. package/src/modules/shipment_tracking/lib/__tests__/route-inference.test.ts +402 -0
  386. package/src/modules/shipment_tracking/lib/__tests__/status-machine.test.ts +447 -0
  387. package/src/modules/shipment_tracking/lib/__tests__/time-extraction.test.ts +331 -0
  388. package/src/modules/shipment_tracking/lib/__tests__/timestamp-utils.test.ts +260 -0
  389. package/src/modules/shipment_tracking/lib/__tests__/vessel-api.test.ts +125 -0
  390. package/src/modules/shipment_tracking/lib/__tests__/vessel-extraction.test.ts +108 -0
  391. package/src/modules/shipment_tracking/lib/adapters/cma-cgm.ts +84 -0
  392. package/src/modules/shipment_tracking/lib/adapters/cosco-types.ts +145 -0
  393. package/src/modules/shipment_tracking/lib/adapters/cosco.ts +314 -0
  394. package/src/modules/shipment_tracking/lib/adapters/evergreen.ts +107 -0
  395. package/src/modules/shipment_tracking/lib/adapters/hapag-lloyd.ts +88 -0
  396. package/src/modules/shipment_tracking/lib/adapters/index.ts +18 -0
  397. package/src/modules/shipment_tracking/lib/adapters/maersk.ts +104 -0
  398. package/src/modules/shipment_tracking/lib/adapters/msc.ts +223 -0
  399. package/src/modules/shipment_tracking/lib/adapters/zim.ts +106 -0
  400. package/src/modules/shipment_tracking/lib/audit-helpers.ts +29 -0
  401. package/src/modules/shipment_tracking/lib/auth/base64url.ts +4 -0
  402. package/src/modules/shipment_tracking/lib/auth/oauth-client.ts +53 -0
  403. package/src/modules/shipment_tracking/lib/background.ts +24 -0
  404. package/src/modules/shipment_tracking/lib/bic-api-client.ts +199 -0
  405. package/src/modules/shipment_tracking/lib/carrier-adapter.ts +136 -0
  406. package/src/modules/shipment_tracking/lib/current-vessel.ts +274 -0
  407. package/src/modules/shipment_tracking/lib/dcsa-event-mapping.ts +202 -0
  408. package/src/modules/shipment_tracking/lib/dcsa-event-parser.ts +163 -0
  409. package/src/modules/shipment_tracking/lib/dcsa-params.ts +14 -0
  410. package/src/modules/shipment_tracking/lib/empty-return.ts +122 -0
  411. package/src/modules/shipment_tracking/lib/location-overrides.ts +150 -0
  412. package/src/modules/shipment_tracking/lib/location-types.ts +154 -0
  413. package/src/modules/shipment_tracking/lib/logger.ts +195 -0
  414. package/src/modules/shipment_tracking/lib/poi-event-processor.ts +302 -0
  415. package/src/modules/shipment_tracking/lib/poi-nats-config.ts +134 -0
  416. package/src/modules/shipment_tracking/lib/poi-types.ts +198 -0
  417. package/src/modules/shipment_tracking/lib/rate-limiter.ts +58 -0
  418. package/src/modules/shipment_tracking/lib/route-extraction.ts +439 -0
  419. package/src/modules/shipment_tracking/lib/route-inference.ts +238 -0
  420. package/src/modules/shipment_tracking/lib/schedule-generator.ts +92 -0
  421. package/src/modules/shipment_tracking/lib/shipsgo-client.ts +350 -0
  422. package/src/modules/shipment_tracking/lib/shipsgo-mapper.ts +296 -0
  423. package/src/modules/shipment_tracking/lib/status-machine.ts +230 -0
  424. package/src/modules/shipment_tracking/lib/time-extraction.ts +85 -0
  425. package/src/modules/shipment_tracking/lib/timestamp-utils.ts +244 -0
  426. package/src/modules/shipment_tracking/lib/vessel-api.ts +299 -0
  427. package/src/modules/shipment_tracking/lib/vessel-cache.ts +102 -0
  428. package/src/modules/shipment_tracking/lib/vessel-extraction.ts +39 -0
  429. package/src/modules/shipment_tracking/lib/webhook-dispatcher.ts +93 -0
  430. package/src/modules/shipment_tracking/migrations/.snapshot-open-mercato.json +2914 -0
  431. package/src/modules/shipment_tracking/migrations/Migration20260422161208_shipment_tracking.ts +60 -0
  432. package/src/modules/shipment_tracking/migrations/Migration20260529120000_shipment_tracking_poi_region_names.ts +22 -0
  433. package/src/modules/shipment_tracking/migrations/Migration20260821120000_shipment_tracking_shipsgo.ts +65 -0
  434. package/src/modules/shipment_tracking/notifications.ts +151 -0
  435. package/src/modules/shipment_tracking/services/carrierRegistry.ts +29 -0
  436. package/src/modules/shipment_tracking/services/poiNatsService.ts +526 -0
  437. package/src/modules/shipment_tracking/services/shipsGoProvider.ts +231 -0
  438. package/src/modules/shipment_tracking/services/trackingService.ts +1777 -0
  439. package/src/modules/shipment_tracking/services/webhookService.ts +347 -0
  440. package/src/modules/shipment_tracking/setup.ts +99 -0
  441. package/src/modules/shipment_tracking/subscribers/__tests__/terminal-event-ingest.test.ts +141 -0
  442. package/src/modules/shipment_tracking/subscribers/cargo-event-created.ts +115 -0
  443. package/src/modules/shipment_tracking/subscribers/dcsa-event-webhook-dispatch.ts +115 -0
  444. package/src/modules/shipment_tracking/subscribers/poi-event-notification.ts +144 -0
  445. package/src/modules/shipment_tracking/subscribers/poi-event-webhook-dispatch.ts +94 -0
  446. package/src/modules/shipment_tracking/subscribers/shipment-status-changed.ts +41 -0
  447. package/src/modules/shipment_tracking/subscribers/terminal-event-ingest.ts +109 -0
  448. package/src/modules/shipment_tracking/subscribers/tracking-job-initial-poll.ts +53 -0
  449. package/src/modules/shipment_tracking/subscribers/tracking-job-webhook-dispatch.ts +50 -0
  450. package/src/modules/shipment_tracking/tables/index.ts +4 -0
  451. package/src/modules/shipment_tracking/tables/tracking_job/table.client.tsx +337 -0
  452. package/src/modules/shipment_tracking/tables/tracking_job/table.ts +34 -0
  453. package/src/modules/shipment_tracking/widgets/dashboard/ships-map/config.ts +32 -0
  454. package/src/modules/shipment_tracking/widgets/dashboard/ships-map/widget.client.tsx +448 -0
  455. package/src/modules/shipment_tracking/widgets/dashboard/ships-map/widget.ts +25 -0
  456. package/src/types/assets.d.ts +5 -0
@@ -0,0 +1,1777 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { EventBus } from '@open-mercato/events'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { Shipment, TrackingJob, TrackingEvent, CarrierConfig, BicConfig } from '../data/entities'
5
+ import type { TrackingReferenceType, TrackingJobStatusEnum, ShipmentStatusEnum, FacilityCodeListProvider, TrackingProvider, TrackingMode } from '../data/entities'
6
+ import type { CarrierRegistryService } from './carrierRegistry'
7
+ import type { ShipsGoProvider } from './shipsGoProvider'
8
+ import type { WebhookService } from './webhookService'
9
+ import { ShipsGoCreditsExhaustedError, ShipsGoAuthError, ShipsGoRateLimitedError } from '../lib/shipsgo-client'
10
+ import type { CacheService } from '../lib/rate-limiter'
11
+ import { checkRateLimit } from '../lib/rate-limiter'
12
+ import { trackingLogger, withCarrierContext } from '../lib/logger'
13
+ import { deriveShipmentStatus, deriveAirShipmentStatus } from '../lib/status-machine'
14
+ import { extractShipmentTimes } from '../lib/time-extraction'
15
+ import { generatePollSchedule, getNextPollDate } from '../lib/schedule-generator'
16
+ import type { CarrierFetchedEvent } from '../lib/carrier-adapter'
17
+ import { mapDcsaEventToWebhookType, isSignificantMilestone } from '../lib/dcsa-event-mapping'
18
+ import { inferRouteFromEvents } from '../lib/route-inference'
19
+ import { allContainersComplete, destinationArrivedBefore, staleArrivalCutoff } from '../lib/empty-return'
20
+ import { mergeExtractedTimestamps, getPrimaryTimestampValue } from '../lib/timestamp-utils'
21
+ import { findLatestVesselInfo } from '../lib/vessel-extraction'
22
+ import { extractRouteFromEvents, mapTrackingEventToEntry } from '../lib/route-extraction'
23
+ import { buildLocationFromEvent, createBasicLocation, mergeLocationWithBicData, isLocationComplete } from '../lib/location-types'
24
+ import type { FacilityLocation } from '../lib/location-types'
25
+ import { BicApiClient, type BicFacility } from '../lib/bic-api-client'
26
+ import { applyLocationOverrideIfExists } from '../lib/location-overrides'
27
+
28
+ /**
29
+ * Provider-agnostic summary of a poll's top-level references, threaded from the
30
+ * fetch into shipment derivation. Ocean carries vessel/booking/BOL; air (ShipsGo
31
+ * mode='air') carries the AWB, flight, airline and ShipsGo's top-level status.
32
+ */
33
+ type CarrierResultSummary = {
34
+ vesselName?: string | null
35
+ vesselImo?: string | null
36
+ bookingNumber?: string | null
37
+ bolNumber?: string | null
38
+ awbNumber?: string | null
39
+ flightNumber?: string | null
40
+ airlineCode?: string | null
41
+ airlineName?: string | null
42
+ airStatus?: string | null
43
+ /** Provider shipment-level metadata merged into Shipment.extra (ShipsGo route / air cargo). */
44
+ extra?: Record<string, unknown> | null
45
+ }
46
+
47
+ type TrackingServiceDeps = {
48
+ em: () => EntityManager
49
+ eventBus: EventBus
50
+ carrierRegistry: CarrierRegistryService
51
+ shipsGoProvider: ShipsGoProvider
52
+ cacheService: CacheService
53
+ webhookService: WebhookService
54
+ }
55
+
56
+ export class TrackingService {
57
+ private deps: TrackingServiceDeps
58
+
59
+ constructor(deps: TrackingServiceDeps) {
60
+ this.deps = deps
61
+ }
62
+
63
+ /**
64
+ * Creates a tracking job and immediately polls the carrier.
65
+ * This is the main entry point for new tracking requests.
66
+ *
67
+ * The system will:
68
+ * 1. Create a TrackingJob for the given carrier and reference
69
+ * 2. Poll the carrier API for events
70
+ * 3. Auto-discover containers from EQUIPMENT events and create Shipments
71
+ * 4. Auto-infer origin/destination ports from events if not provided
72
+ * 5. Emit events for new cargo events and shipment status changes
73
+ */
74
+ async createTrackingJob(input: {
75
+ organizationId: string
76
+ tenantId: string
77
+ carrierCode: string
78
+ referenceType: TrackingReferenceType
79
+ referenceValue: string
80
+ // Provider defaults to 'carrier' (direct DCSA adapter). 'shipsgo' routes
81
+ // through the aggregator fallback and does not require a local adapter.
82
+ provider?: TrackingProvider
83
+ // Transport mode; defaults to 'ocean'.
84
+ mode?: TrackingMode
85
+ // Origin/destination are optional - will be auto-inferred from events if not provided
86
+ originUnlocode?: string
87
+ destinationUnlocode?: string
88
+ schedule?: string[]
89
+ }): Promise<{
90
+ trackingJobId: string
91
+ shipmentsCreated: number
92
+ newEvents: number
93
+ }> {
94
+ const em = this.deps.em()
95
+ const { organizationId, tenantId, carrierCode, referenceType, referenceValue, schedule } = input
96
+ const provider: TrackingProvider = input.provider ?? 'carrier'
97
+ const mode: TrackingMode = input.mode ?? 'ocean'
98
+ // Origin/destination may be provided or will be auto-inferred from events
99
+ let originUnlocode = input.originUnlocode?.toUpperCase()
100
+ let destinationUnlocode = input.destinationUnlocode?.toUpperCase()
101
+
102
+ const carrierCodeLower = carrierCode.toLowerCase()
103
+
104
+ // Direct-carrier jobs require a registered adapter; ShipsGo (aggregator)
105
+ // jobs do not — they are register-then-poll and carrier-agnostic for ocean.
106
+ if (provider === 'carrier' && !this.deps.carrierRegistry.has(carrierCodeLower)) {
107
+ throw new Error(`No adapter registered for carrier: ${carrierCode}`)
108
+ }
109
+
110
+ // Check for existing active job with same reference
111
+ const existingJob = await em.findOne(TrackingJob, {
112
+ organizationId,
113
+ tenantId,
114
+ carrierCode: carrierCodeLower,
115
+ referenceType,
116
+ referenceValue,
117
+ status: { $in: ['active', 'paused'] },
118
+ deletedAt: null,
119
+ })
120
+
121
+ if (existingJob) {
122
+ trackingLogger.debug('TrackingJob already exists', {
123
+ trackingJobId: existingJob.id,
124
+ carrierCode: carrierCodeLower,
125
+ referenceType,
126
+ referenceValue,
127
+ })
128
+ // Poll the existing job
129
+ const pollResult = await this.pollTrackingJob(existingJob.id)
130
+ return {
131
+ trackingJobId: existingJob.id,
132
+ shipmentsCreated: pollResult.shipmentsCreated,
133
+ newEvents: pollResult.newEvents,
134
+ }
135
+ }
136
+
137
+ // Generate poll schedule
138
+ const pollSchedule = schedule ?? generatePollSchedule({})
139
+ const nextPollAt = getNextPollDate(pollSchedule)
140
+
141
+ // Create new tracking job (origin/destination may be null, will be inferred from first poll)
142
+ const trackingJob = em.create(TrackingJob, {
143
+ organizationId,
144
+ tenantId,
145
+ provider,
146
+ mode,
147
+ carrierCode: carrierCodeLower,
148
+ referenceType,
149
+ referenceValue,
150
+ originUnlocode: originUnlocode ?? null,
151
+ destinationUnlocode: destinationUnlocode ?? null,
152
+ status: 'active',
153
+ schedule: pollSchedule,
154
+ nextPollAt,
155
+ })
156
+
157
+ em.persist(trackingJob)
158
+ await em.flush()
159
+
160
+ trackingLogger.info('Created TrackingJob', {
161
+ trackingJobId: trackingJob.id,
162
+ carrierCode: carrierCodeLower,
163
+ referenceType,
164
+ referenceValue,
165
+ tenantId,
166
+ organizationId,
167
+ })
168
+
169
+ // Emit tracking job created event
170
+ await this.deps.eventBus.emit('shipment_tracking.tracking_job.created', {
171
+ id: trackingJob.id,
172
+ carrierCode: carrierCodeLower,
173
+ referenceType,
174
+ referenceValue,
175
+ tenantId,
176
+ organizationId,
177
+ })
178
+
179
+ // Poll immediately
180
+ let pollResult = { shipmentsCreated: 0, newEvents: 0 }
181
+ try {
182
+ pollResult = await this.pollTrackingJob(trackingJob.id)
183
+ } catch (error) {
184
+ trackingLogger.error('Initial poll failed', {
185
+ trackingJobId: trackingJob.id,
186
+ carrierCode: carrierCodeLower,
187
+ error: error instanceof Error ? error.message : 'Unknown error',
188
+ })
189
+
190
+ await this.deps.eventBus.emit('shipment_tracking.tracking_job.poll_failed', {
191
+ id: trackingJob.id,
192
+ carrierCode: carrierCodeLower,
193
+ error: error instanceof Error ? error.message : 'Unknown error',
194
+ tenantId,
195
+ organizationId,
196
+ })
197
+ }
198
+
199
+ return {
200
+ trackingJobId: trackingJob.id,
201
+ shipmentsCreated: pollResult.shipmentsCreated,
202
+ newEvents: pollResult.newEvents,
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Polls all active tracking jobs for a tenant/organization.
208
+ * Called by the scheduler for periodic re-polling.
209
+ */
210
+ async pollAllActiveJobs(
211
+ tenantId: string,
212
+ organizationId?: string,
213
+ ): Promise<{ polled: number; newEvents: number; failed: number }> {
214
+ const em = this.deps.em()
215
+
216
+ const filter: Record<string, unknown> = {
217
+ tenantId,
218
+ status: 'active',
219
+ deletedAt: null,
220
+ }
221
+
222
+ if (organizationId) {
223
+ filter.organizationId = organizationId
224
+ }
225
+
226
+ const jobs = await em.find(TrackingJob, filter)
227
+
228
+ let polled = 0
229
+ let totalNewEvents = 0
230
+ let failed = 0
231
+
232
+ for (const job of jobs) {
233
+ try {
234
+ const result = await this.pollTrackingJob(job.id)
235
+ polled++
236
+ totalNewEvents += result.newEvents
237
+ } catch (error) {
238
+ failed++
239
+ trackingLogger.error('Poll failed for job', {
240
+ trackingJobId: job.id,
241
+ carrierCode: job.carrierCode,
242
+ error: error instanceof Error ? error.message : 'Unknown error',
243
+ })
244
+
245
+ await this.deps.eventBus.emit('shipment_tracking.tracking_job.poll_failed', {
246
+ id: job.id,
247
+ carrierCode: job.carrierCode,
248
+ error: error instanceof Error ? error.message : 'Unknown error',
249
+ tenantId: job.tenantId,
250
+ organizationId: job.organizationId,
251
+ })
252
+ }
253
+ }
254
+
255
+ trackingLogger.info('Poll all completed', {
256
+ polled,
257
+ totalNewEvents,
258
+ failed,
259
+ tenantId,
260
+ organizationId,
261
+ })
262
+
263
+ return { polled, newEvents: totalNewEvents, failed }
264
+ }
265
+
266
+ /**
267
+ * Polls a single tracking job:
268
+ * 1. Fetches events from the carrier
269
+ * 2. Persists new TrackingEvents
270
+ * 3. Auto-discovers containers and creates/updates Shipments
271
+ * 4. Emits events for cargo events and shipment changes
272
+ */
273
+ async pollTrackingJob(jobId: string): Promise<{
274
+ newEvents: number
275
+ shipmentsCreated: number
276
+ shipmentsUpdated: number
277
+ }> {
278
+ const em = this.deps.em()
279
+ const job = await em.findOne(TrackingJob, { id: jobId }, { populate: ['shipments'] })
280
+
281
+ if (!job) {
282
+ throw new Error(`TrackingJob not found: ${jobId}`)
283
+ }
284
+
285
+ if (job.status !== 'active') {
286
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
287
+ }
288
+
289
+ // Tenant scope — used for the fetch below and for BIC enrichment later.
290
+ const scope = { tenantId: job.tenantId, organizationId: job.organizationId }
291
+
292
+ // Fetch events. Two provider paths converge on the same downstream pipeline:
293
+ // 'carrier' → direct DCSA adapter (rate-limited, config-driven auth)
294
+ // 'shipsgo' → aggregator, register-then-poll (no local adapter/config)
295
+ let fetchedEvents: CarrierFetchedEvent[]
296
+ let carrierResult: CarrierResultSummary
297
+
298
+ if (job.provider === 'shipsgo') {
299
+ // Resolve config + rate limit in one config read.
300
+ const pollContext = await this.deps.shipsGoProvider.resolvePollContext(scope, job.mode)
301
+ // Per-tenant rate limit — ShipsGo caps at 100 req/min account-wide, so a
302
+ // bulk import of unsupported/air legs could otherwise be throttled (and
303
+ // waste credits). The identity is per-tenant for a BYO token and shared
304
+ // for the platform account, so env-fallback tenants share one budget.
305
+ // Skip this tick when over the limit; the schedule retries it, and the job
306
+ // is left untouched (no error, no retry burn).
307
+ const limitResult = await checkRateLimit(
308
+ this.deps.cacheService,
309
+ pollContext.rateLimitIdentity,
310
+ 'shipsgo',
311
+ pollContext.rateLimit.requests,
312
+ pollContext.rateLimit.windowSeconds,
313
+ )
314
+ if (!limitResult.allowed) {
315
+ trackingLogger.debug('ShipsGo rate limited, skipping poll', {
316
+ trackingJobId: jobId,
317
+ retryAfterSeconds: limitResult.retryAfterSeconds,
318
+ })
319
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
320
+ }
321
+
322
+ try {
323
+ const result = await this.deps.shipsGoProvider.fetchOrRegister(job, pollContext.config)
324
+ fetchedEvents = result.events
325
+ carrierResult = {
326
+ vesselName: result.vesselName,
327
+ vesselImo: result.vesselImo,
328
+ bookingNumber: result.bookingNumber,
329
+ bolNumber: result.bolNumber,
330
+ awbNumber: result.awbNumber,
331
+ flightNumber: result.flightNumber,
332
+ airlineCode: result.airlineCode,
333
+ airlineName: result.airlineName,
334
+ airStatus: result.airStatus,
335
+ extra: result.extra,
336
+ }
337
+ } catch (error) {
338
+ // Credit exhaustion (HTTP 402) is an account-level condition, not a
339
+ // per-job fault. Routing it through recordJobError would burn the
340
+ // 10-strike failure budget and flip healthy jobs to 'failed' after ~10
341
+ // polls — while re-POSTing to an exhausted account each time. Instead
342
+ // back the job off and leave retryCount untouched, so it resumes on its
343
+ // own once credits are topped up.
344
+ if (error instanceof ShipsGoCreditsExhaustedError) {
345
+ await this.recordCreditExhaustion(em, job)
346
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
347
+ }
348
+ // 429 (rate limit) and 403 (auth) are transient/account-level like 402:
349
+ // back the job off without burning its retry budget so it recovers on
350
+ // its own once the window resets / the token is fixed.
351
+ if (error instanceof ShipsGoRateLimitedError) {
352
+ const backoffMs = (error.retryAfterSeconds ?? 60) * 1000
353
+ await this.recordTransientBackoff(em, job, error.message, backoffMs)
354
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
355
+ }
356
+ if (error instanceof ShipsGoAuthError) {
357
+ await this.recordTransientBackoff(em, job, error.message, 30 * 60 * 1000)
358
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
359
+ }
360
+ const message = error instanceof Error ? error.message : 'Unknown ShipsGo fetch error'
361
+ await this.recordJobError(em, job, message)
362
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
363
+ }
364
+ } else {
365
+ // Resolve carrier adapter
366
+ const adapter = this.deps.carrierRegistry.get(job.carrierCode)
367
+ if (!adapter) {
368
+ await this.recordJobError(em, job, `No adapter registered for carrier: ${job.carrierCode}`)
369
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
370
+ }
371
+
372
+ // Load carrier config for rate limiting and auth
373
+ const carrierConfig = await findOneWithDecryption(
374
+ em,
375
+ CarrierConfig,
376
+ {
377
+ carrierCode: job.carrierCode,
378
+ organizationId: job.organizationId,
379
+ tenantId: job.tenantId,
380
+ isActive: true,
381
+ },
382
+ undefined,
383
+ scope,
384
+ )
385
+
386
+ // Check rate limit
387
+ if (carrierConfig) {
388
+ const limitResult = await checkRateLimit(
389
+ this.deps.cacheService,
390
+ job.tenantId,
391
+ job.carrierCode,
392
+ carrierConfig.rateLimitRequests,
393
+ carrierConfig.rateLimitWindowSeconds,
394
+ )
395
+
396
+ if (!limitResult.allowed) {
397
+ trackingLogger.debug('Rate limited, skipping poll', {
398
+ trackingJobId: jobId,
399
+ carrierCode: job.carrierCode,
400
+ retryAfterSeconds: limitResult.retryAfterSeconds,
401
+ })
402
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
403
+ }
404
+ }
405
+
406
+ try {
407
+ const result = await adapter.fetchEvents({
408
+ referenceType: job.referenceType,
409
+ referenceValue: job.referenceValue,
410
+ apiEndpoint: carrierConfig?.apiEndpoint,
411
+ authConfig: carrierConfig?.authConfig,
412
+ })
413
+ fetchedEvents = result.events
414
+ carrierResult = {
415
+ vesselName: result.vesselName,
416
+ vesselImo: result.vesselImo,
417
+ bookingNumber: result.bookingNumber,
418
+ bolNumber: result.bolNumber,
419
+ }
420
+ } catch (error) {
421
+ const message = error instanceof Error ? error.message : 'Unknown fetch error'
422
+ await this.recordJobError(em, job, message)
423
+ return { newEvents: 0, shipmentsCreated: 0, shipmentsUpdated: 0 }
424
+ }
425
+ }
426
+
427
+ // Auto-infer origin/destination from events if not already set
428
+ if (fetchedEvents.length > 0 && (!job.originUnlocode || !job.destinationUnlocode)) {
429
+ const inferred = inferRouteFromEvents(fetchedEvents)
430
+
431
+ if (!job.originUnlocode && inferred.originUnlocode) {
432
+ job.originUnlocode = inferred.originUnlocode
433
+ trackingLogger.info('Auto-inferred origin', {
434
+ trackingJobId: job.id,
435
+ carrierCode: job.carrierCode,
436
+ origin: inferred.originUnlocode,
437
+ confidence: inferred.confidence.origin,
438
+ })
439
+ }
440
+
441
+ if (!job.destinationUnlocode && inferred.destinationUnlocode) {
442
+ job.destinationUnlocode = inferred.destinationUnlocode
443
+ trackingLogger.info('Auto-inferred destination', {
444
+ trackingJobId: job.id,
445
+ carrierCode: job.carrierCode,
446
+ destination: inferred.destinationUnlocode,
447
+ confidence: inferred.confidence.destination,
448
+ })
449
+ }
450
+ }
451
+
452
+ // Persist new events (deduplicate by source + sourceEventId).
453
+ // Estimated (EST) movements reuse a timestamp-independent sourceEventId (see
454
+ // the ShipsGo mapper), so a shifting estimate maps to the SAME row: refresh
455
+ // its timestamp in place rather than skipping (stale ETA) or inserting a new
456
+ // row every poll (unbounded churn + per-poll event noise). Actuals are
457
+ // immutable, so an existing ACT row is simply skipped.
458
+ const existingByKey = new Map(
459
+ (
460
+ await em.find(TrackingEvent, {
461
+ trackingJob: job,
462
+ sourceEventId: { $in: fetchedEvents.map((e) => e.sourceEventId) },
463
+ })
464
+ ).map((e) => [`${e.source}:${e.sourceEventId}`, e]),
465
+ )
466
+
467
+ const newEvents: TrackingEvent[] = []
468
+ let estRefreshed = false
469
+ for (const fetched of fetchedEvents) {
470
+ const dedupeKey = `${fetched.source}:${fetched.sourceEventId}`
471
+ const prior = existingByKey.get(dedupeKey)
472
+ if (prior) {
473
+ // Refresh a moving estimate in place; leave actuals untouched.
474
+ if (
475
+ fetched.eventClassifierCode === 'EST' &&
476
+ prior.eventDateTime.getTime() !== fetched.eventDateTime.getTime()
477
+ ) {
478
+ prior.eventDateTime = fetched.eventDateTime
479
+ prior.eventDateTimeOffset = fetched.eventDateTimeOffset ?? prior.eventDateTimeOffset
480
+ prior.description = fetched.description ?? prior.description
481
+ prior.rawData = fetched.rawData ?? prior.rawData
482
+ estRefreshed = true
483
+ }
484
+ continue
485
+ }
486
+
487
+ const trackingEvent = em.create(TrackingEvent, {
488
+ organizationId: job.organizationId,
489
+ tenantId: job.tenantId,
490
+ trackingJob: job,
491
+
492
+ // Source identification
493
+ source: fetched.source,
494
+ sourceEventId: fetched.sourceEventId,
495
+
496
+ // Core event fields
497
+ eventType: fetched.eventType,
498
+ eventCode: fetched.eventCode,
499
+ eventClassifierCode: fetched.eventClassifierCode,
500
+ eventDateTime: fetched.eventDateTime,
501
+ eventDateTimeOffset: fetched.eventDateTimeOffset,
502
+ description: fetched.description,
503
+ rawData: fetched.rawData,
504
+
505
+ // Equipment fields
506
+ equipmentReference: fetched.equipmentReference,
507
+ isoEquipmentCode: fetched.isoEquipmentCode,
508
+ emptyIndicatorCode: fetched.emptyIndicatorCode,
509
+ isTransshipmentMove: fetched.isTransshipmentMove,
510
+
511
+ // Location fields
512
+ locationName: fetched.locationName,
513
+ locationUnlocode: fetched.locationUnlocode,
514
+ locationCountry: fetched.locationCountry,
515
+ facilityCode: fetched.facilityCode,
516
+ facilityCodeListProvider: fetched.facilityCodeListProvider,
517
+ facilityTypeCode: fetched.facilityTypeCode,
518
+ facilityAddress: fetched.facilityAddress,
519
+ latitude: fetched.latitude,
520
+ longitude: fetched.longitude,
521
+
522
+ // Transport call fields
523
+ transportCallReference: fetched.transportCallReference,
524
+ modeOfTransport: fetched.modeOfTransport,
525
+ vesselName: fetched.vesselName,
526
+ vesselImo: fetched.vesselImo,
527
+ voyageNumber: fetched.voyageNumber,
528
+ carrierServiceCode: fetched.carrierServiceCode,
529
+ carrierExportVoyageNumber: fetched.carrierExportVoyageNumber,
530
+ carrierImportVoyageNumber: fetched.carrierImportVoyageNumber,
531
+ universalServiceReference: fetched.universalServiceReference,
532
+ universalExportVoyageReference: fetched.universalExportVoyageReference,
533
+ universalImportVoyageReference: fetched.universalImportVoyageReference,
534
+ portVisitReference: fetched.portVisitReference,
535
+
536
+ // Document references
537
+ relatedDocumentReferences: fetched.relatedDocumentReferences,
538
+
539
+ // Metadata fields
540
+ eventCreatedDateTime: fetched.eventCreatedDateTime,
541
+ retractedEventId: fetched.retractedEventId,
542
+ publisherName: fetched.publisherName,
543
+ publisherRole: fetched.publisherRole,
544
+
545
+ // Additional fields
546
+ delayReasonCode: fetched.delayReasonCode,
547
+ changeRemark: fetched.changeRemark,
548
+ seals: fetched.seals,
549
+ })
550
+ newEvents.push(trackingEvent)
551
+ existingByKey.set(dedupeKey, trackingEvent) // Prevent duplicates within same batch
552
+ }
553
+
554
+ // Persist new events / in-place EST refreshes (if any)
555
+ if (newEvents.length > 0 || estRefreshed) {
556
+ await em.flush()
557
+ }
558
+
559
+ // Load BIC config for facility enrichment
560
+ const bicConfig = await this.loadBicConfig(em, scope)
561
+
562
+ // Auto-discover containers and create/update Shipments
563
+ // Always run sync even without new events to backfill missing data (e.g. booking numbers)
564
+ const { shipmentsCreated, shipmentsUpdated, allComplete } = await this.syncShipmentsFromEvents(
565
+ em,
566
+ job,
567
+ carrierResult,
568
+ bicConfig,
569
+ )
570
+
571
+ // Update job
572
+ job.lastPollAt = new Date()
573
+ job.retryCount = 0
574
+ await em.flush()
575
+
576
+ // Emit events for each new tracking event
577
+ for (const event of newEvents) {
578
+ await this.emitTrackingEventCreated(event, job)
579
+ }
580
+
581
+ // Every tracked container has completed its journey (empty return for DCSA,
582
+ // delivery for COSCO), or the backup fired (arrived at destination long ago)
583
+ // → stop polling this job (self-heals already-active completed jobs on their
584
+ // next poll).
585
+ if (allComplete && job.status === 'active') {
586
+ await this.stopJobInline(em, job, 'completed')
587
+ trackingLogger.info('Tracking job completed (empty return / delivery, or stale after destination arrival)', {
588
+ trackingJobId: job.id,
589
+ tenantId: job.tenantId,
590
+ organizationId: job.organizationId,
591
+ })
592
+ }
593
+
594
+ return { newEvents: newEvents.length, shipmentsCreated, shipmentsUpdated }
595
+ }
596
+
597
+ /**
598
+ * Syncs Shipments based on TrackingEvents for a job.
599
+ * Auto-discovers containers from equipmentReference in EQUIPMENT events.
600
+ * For container-based tracking (referenceType='container'), creates exactly one Shipment.
601
+ * For booking/BOL tracking, creates one Shipment per unique equipmentReference.
602
+ */
603
+ /**
604
+ * Air counterpart to syncShipmentsFromEvents. Air has no container/equipment
605
+ * model, so it maintains exactly one AWB-keyed Shipment (mode='air') per job,
606
+ * fills flight/airline metadata, and derives timestamps + status from the air
607
+ * movements (via deriveShipmentStateFromEvents with the ShipsGo top-level
608
+ * airStatus). The job auto-completes once the shipment is DELIVERED.
609
+ */
610
+ private async syncAirShipmentFromEvents(
611
+ em: EntityManager,
612
+ job: TrackingJob,
613
+ carrierResult: CarrierResultSummary,
614
+ bicConfig: BicConfig | null,
615
+ ): Promise<{ shipmentsCreated: number; shipmentsUpdated: number; allComplete: boolean }> {
616
+ const allEvents = await em.find(
617
+ TrackingEvent,
618
+ { trackingJob: job },
619
+ { orderBy: { eventDateTime: 'asc' } },
620
+ )
621
+
622
+ const existing = await em.find(Shipment, { trackingJob: job, deletedAt: null })
623
+ let shipment = existing[0]
624
+ let shipmentsCreated = 0
625
+ let isNew = false
626
+
627
+ if (!shipment) {
628
+ shipment = em.create(Shipment, {
629
+ organizationId: job.organizationId,
630
+ tenantId: job.tenantId,
631
+ trackingJob: job,
632
+ carrierCode: job.carrierCode,
633
+ mode: 'air',
634
+ awbNumber: carrierResult.awbNumber ?? job.referenceValue,
635
+ originLocation: job.originUnlocode ? createBasicLocation(job.originUnlocode) : null,
636
+ destinationLocation: job.destinationUnlocode ? createBasicLocation(job.destinationUnlocode) : null,
637
+ status: deriveAirShipmentStatus(carrierResult.airStatus, 'PENDING') as ShipmentStatusEnum,
638
+ })
639
+ em.persist(shipment)
640
+ shipmentsCreated = 1
641
+ isNew = true
642
+ trackingLogger.info('Auto-created air Shipment', {
643
+ shipmentId: shipment.id,
644
+ awbNumber: shipment.awbNumber,
645
+ trackingJobId: job.id,
646
+ })
647
+ }
648
+
649
+ // Latest flight/airline metadata wins (backfill on new, refresh on existing).
650
+ if (carrierResult.flightNumber) shipment.flightNumber = carrierResult.flightNumber
651
+ if (carrierResult.airlineCode) shipment.airlineCode = carrierResult.airlineCode
652
+ if (carrierResult.airlineName) shipment.airlineName = carrierResult.airlineName
653
+ if (!shipment.awbNumber && carrierResult.awbNumber) shipment.awbNumber = carrierResult.awbNumber
654
+ // Merge provider shipment-level metadata (ShipsGo air cargo / status_extended) into extra.
655
+ if (carrierResult.extra) {
656
+ shipment.extra = { ...(shipment.extra ?? {}), ...carrierResult.extra }
657
+ }
658
+
659
+ const statusChanges: Array<{ shipment: Shipment; previousStatus: string; newStatus: string }> = []
660
+ const etaChanges: Array<{ shipment: Shipment; previousEta: Date; newEta: Date }> = []
661
+ const etdChanges: Array<{ shipment: Shipment; previousEtd: Date; newEtd: Date }> = []
662
+
663
+ let shipmentsUpdated = 0
664
+ if (allEvents.length > 0) {
665
+ const result = await this.deriveShipmentStateFromEvents(
666
+ em, shipment, allEvents, bicConfig, carrierResult.airStatus ?? null,
667
+ )
668
+ if (result.changed) {
669
+ shipmentsUpdated = 1
670
+ if (result.statusChange) statusChanges.push({ shipment, ...result.statusChange })
671
+ if (result.etaChange) etaChanges.push({ shipment, ...result.etaChange })
672
+ if (result.etdChange) etdChanges.push({ shipment, ...result.etdChange })
673
+ }
674
+ } else if (carrierResult.airStatus) {
675
+ // No movements yet — still reflect the ShipsGo top-level status.
676
+ const next = deriveAirShipmentStatus(carrierResult.airStatus, shipment.status)
677
+ if (next !== shipment.status) {
678
+ statusChanges.push({ shipment, previousStatus: shipment.status, newStatus: next })
679
+ shipment.status = next as ShipmentStatusEnum
680
+ shipmentsUpdated = 1
681
+ }
682
+ }
683
+
684
+ await em.flush()
685
+
686
+ if (isNew) {
687
+ await this.deps.eventBus.emit('shipment_tracking.shipment.created', {
688
+ id: shipment.id,
689
+ awbNumber: shipment.awbNumber,
690
+ trackingJobId: job.id,
691
+ tenantId: job.tenantId,
692
+ organizationId: job.organizationId,
693
+ })
694
+ }
695
+ await this.emitShipmentDerivedChanges([shipment], statusChanges, etaChanges, etdChanges)
696
+
697
+ // Air completes on DELIVERED, or — the backup, mirroring the ocean stale
698
+ // cutoff — once it has ARRIVED and its latest movement is older than
699
+ // STALE_AFTER_ARRIVAL_DAYS. Air last-mile delivery is frequently untracked,
700
+ // so without this an ARRIVED-but-never-DELIVERED shipment would poll forever.
701
+ const latestEventAt = allEvents.length > 0 ? allEvents[allEvents.length - 1].eventDateTime : null
702
+ const staleArrived =
703
+ shipment.status === 'ARRIVED' && latestEventAt != null && latestEventAt < staleArrivalCutoff()
704
+
705
+ return {
706
+ shipmentsCreated,
707
+ shipmentsUpdated,
708
+ allComplete: shipment.status === 'DELIVERED' || staleArrived,
709
+ }
710
+ }
711
+
712
+ private async syncShipmentsFromEvents(
713
+ em: EntityManager,
714
+ job: TrackingJob,
715
+ carrierResult: CarrierResultSummary,
716
+ bicConfig: BicConfig | null,
717
+ ): Promise<{ shipmentsCreated: number; shipmentsUpdated: number; allComplete: boolean }> {
718
+ // Air jobs have no container/equipment model — one AWB-keyed Shipment.
719
+ if (job.mode === 'air') {
720
+ return this.syncAirShipmentFromEvents(em, job, carrierResult, bicConfig)
721
+ }
722
+
723
+ // Get all events for this job
724
+ const allEvents = await em.find(
725
+ TrackingEvent,
726
+ { trackingJob: job },
727
+ { orderBy: { eventDateTime: 'asc' } },
728
+ )
729
+
730
+ if (allEvents.length === 0) {
731
+ return { shipmentsCreated: 0, shipmentsUpdated: 0, allComplete: false }
732
+ }
733
+
734
+ // Extract unique container numbers (equipmentReferences) from EQUIPMENT events
735
+ const containerNumbers = new Set<string>()
736
+ for (const event of allEvents) {
737
+ if (event.eventType === 'EQUIPMENT' && event.equipmentReference) {
738
+ containerNumbers.add(event.equipmentReference)
739
+ }
740
+ }
741
+
742
+ // For container-based tracking with no equipment events yet, use the reference value
743
+ if (containerNumbers.size === 0 && job.referenceType === 'container') {
744
+ containerNumbers.add(job.referenceValue)
745
+ }
746
+
747
+ // If still no containers found, nothing to do yet
748
+ if (containerNumbers.size === 0) {
749
+ return { shipmentsCreated: 0, shipmentsUpdated: 0, allComplete: false }
750
+ }
751
+
752
+ // Load existing shipments for this job
753
+ const existingShipments = await em.find(Shipment, {
754
+ trackingJob: job,
755
+ deletedAt: null,
756
+ })
757
+ const shipmentsByContainer = new Map(
758
+ existingShipments.map((s) => [s.containerNumber, s]),
759
+ )
760
+
761
+ let shipmentsCreated = 0
762
+ let shipmentsUpdated = 0
763
+
764
+ // Track shipments that need events emitted after flush
765
+ const newShipments: Array<{ shipment: Shipment; containerNumber: string }> = []
766
+ const statusChanges: Array<{
767
+ shipment: Shipment
768
+ previousStatus: string
769
+ newStatus: string
770
+ }> = []
771
+ const etaChanges: Array<{
772
+ shipment: Shipment
773
+ previousEta: Date
774
+ newEta: Date
775
+ }> = []
776
+ const etdChanges: Array<{
777
+ shipment: Shipment
778
+ previousEtd: Date
779
+ newEtd: Date
780
+ }> = []
781
+
782
+ // Create or update shipments for each container
783
+ for (const containerNumber of containerNumbers) {
784
+ let shipment = shipmentsByContainer.get(containerNumber)
785
+
786
+ if (!shipment) {
787
+ // Create new shipment - inherit origin/destination from tracking job
788
+ shipment = em.create(Shipment, {
789
+ organizationId: job.organizationId,
790
+ tenantId: job.tenantId,
791
+ trackingJob: job,
792
+ carrierCode: job.carrierCode,
793
+ containerNumber,
794
+ bookingNumber: job.referenceType === 'booking' ? job.referenceValue : carrierResult.bookingNumber,
795
+ bolNumber: job.referenceType === 'bol' ? job.referenceValue : carrierResult.bolNumber,
796
+ // Initialize with basic location from job's UN/LOCODE if available
797
+ // Will be enriched with full facility data from events later
798
+ originLocation: job.originUnlocode ? createBasicLocation(job.originUnlocode) : null,
799
+ destinationLocation: job.destinationUnlocode ? createBasicLocation(job.destinationUnlocode) : null,
800
+ status: 'PENDING',
801
+ })
802
+ em.persist(shipment)
803
+ shipmentsByContainer.set(containerNumber, shipment)
804
+ shipmentsCreated++
805
+ newShipments.push({ shipment, containerNumber })
806
+
807
+ trackingLogger.info('Auto-created Shipment', {
808
+ shipmentId: shipment.id,
809
+ containerNumber,
810
+ trackingJobId: job.id,
811
+ carrierCode: job.carrierCode,
812
+ })
813
+ }
814
+
815
+ // Backfill booking/BOL number from carrier result if still missing
816
+ if (!shipment.bookingNumber && carrierResult.bookingNumber) {
817
+ shipment.bookingNumber = carrierResult.bookingNumber
818
+ }
819
+ if (!shipment.bolNumber && carrierResult.bolNumber) {
820
+ shipment.bolNumber = carrierResult.bolNumber
821
+ }
822
+ // Merge provider shipment-level metadata (ShipsGo route summary) into extra.
823
+ if (carrierResult.extra) {
824
+ shipment.extra = { ...(shipment.extra ?? {}), ...carrierResult.extra }
825
+ }
826
+
827
+ // Update shipment state from events
828
+ const result = await this.deriveShipmentStateFromEvents(em, shipment, allEvents, bicConfig)
829
+ if (result.changed) {
830
+ shipmentsUpdated++
831
+ if (result.statusChange) {
832
+ statusChanges.push({
833
+ shipment,
834
+ previousStatus: result.statusChange.previousStatus,
835
+ newStatus: result.statusChange.newStatus,
836
+ })
837
+ }
838
+ if (result.etaChange) {
839
+ etaChanges.push({
840
+ shipment,
841
+ previousEta: result.etaChange.previousEta,
842
+ newEta: result.etaChange.newEta,
843
+ })
844
+ }
845
+ if (result.etdChange) {
846
+ etdChanges.push({
847
+ shipment,
848
+ previousEtd: result.etdChange.previousEtd,
849
+ newEtd: result.etdChange.newEtd,
850
+ })
851
+ }
852
+ }
853
+ }
854
+
855
+ // Flush all changes to database BEFORE emitting events
856
+ await em.flush()
857
+
858
+ // Now emit events - shipments are committed and can be fetched by subscribers
859
+ for (const { shipment, containerNumber } of newShipments) {
860
+ await this.deps.eventBus.emit('shipment_tracking.shipment.created', {
861
+ id: shipment.id,
862
+ containerNumber,
863
+ trackingJobId: job.id,
864
+ tenantId: job.tenantId,
865
+ organizationId: job.organizationId,
866
+ })
867
+ }
868
+
869
+ // Emit status/timestamp change events (shared with the real-time AIS refresh path)
870
+ const polledShipments = [...containerNumbers]
871
+ .map((cn) => shipmentsByContainer.get(cn))
872
+ .filter((s): s is Shipment => s != null)
873
+ await this.emitShipmentDerivedChanges(polledShipments, statusChanges, etaChanges, etdChanges)
874
+
875
+ // Whole shipment done once every tracked container has completed its
876
+ // journey (empty return for DCSA carriers, delivery for COSCO), OR — as a
877
+ // backup when that event never arrives — once it arrived at its destination
878
+ // port more than STALE_AFTER_ARRIVAL_DAYS ago.
879
+ const allComplete =
880
+ allContainersComplete(allEvents, containerNumbers) ||
881
+ destinationArrivedBefore(allEvents, job.destinationUnlocode, staleArrivalCutoff())
882
+
883
+ return { shipmentsCreated, shipmentsUpdated, allComplete }
884
+ }
885
+
886
+ /**
887
+ * Emit the shipment status/lifecycle/timestamp change events produced by a round of
888
+ * deriveShipmentStateFromEvents. Shared by the carrier poll (syncShipmentsFromEvents)
889
+ * and the real-time AIS refresh (refreshShipmentsFromEvents) so both paths surface the
890
+ * same downstream events. Call AFTER flushing so subscribers can fetch committed rows.
891
+ *
892
+ * @param updatedShipments - shipments to emit a bare `shipment.updated` for when they
893
+ * did not also produce a status change (status changes already emit `updated`).
894
+ */
895
+ private async emitShipmentDerivedChanges(
896
+ updatedShipments: Shipment[],
897
+ statusChanges: Array<{ shipment: Shipment; previousStatus: string; newStatus: string }>,
898
+ etaChanges: Array<{ shipment: Shipment; previousEta: Date; newEta: Date }>,
899
+ etdChanges: Array<{ shipment: Shipment; previousEtd: Date; newEtd: Date }>,
900
+ ): Promise<void> {
901
+ for (const { shipment, previousStatus, newStatus } of statusChanges) {
902
+ await this.deps.eventBus.emit('shipment_tracking.shipment.status_changed', {
903
+ id: shipment.id,
904
+ previousStatus,
905
+ newStatus,
906
+ tenantId: shipment.tenantId,
907
+ organizationId: shipment.organizationId,
908
+ })
909
+
910
+ // Emit specific lifecycle events
911
+ if (newStatus === 'BOOKED' && previousStatus === 'PENDING') {
912
+ await this.deps.eventBus.emit('shipment_tracking.shipment.booked', {
913
+ id: shipment.id,
914
+ tenantId: shipment.tenantId,
915
+ organizationId: shipment.organizationId,
916
+ })
917
+ }
918
+
919
+ if (newStatus === 'PRE_ARRIVAL') {
920
+ await this.deps.eventBus.emit('shipment_tracking.shipment.pre_arrival', {
921
+ id: shipment.id,
922
+ tenantId: shipment.tenantId,
923
+ organizationId: shipment.organizationId,
924
+ })
925
+ }
926
+
927
+ if (newStatus === 'DELIVERED') {
928
+ await this.deps.eventBus.emit('shipment_tracking.shipment.delivered', {
929
+ id: shipment.id,
930
+ tenantId: shipment.tenantId,
931
+ organizationId: shipment.organizationId,
932
+ })
933
+ }
934
+
935
+ await this.deps.eventBus.emit('shipment_tracking.shipment.updated', {
936
+ id: shipment.id,
937
+ tenantId: shipment.tenantId,
938
+ organizationId: shipment.organizationId,
939
+ })
940
+ }
941
+
942
+ // Emit shipment.updated for shipments that had data changes but no status change
943
+ // (status changes already emit updated above). This ensures cross-module subscribers
944
+ // (e.g., fms_files leg sync) pick up vessel info, timestamps, booking numbers, etc.
945
+ const alreadyEmittedIds = new Set(statusChanges.map((sc) => sc.shipment.id))
946
+ for (const shipment of updatedShipments) {
947
+ if (!alreadyEmittedIds.has(shipment.id)) {
948
+ await this.deps.eventBus.emit('shipment_tracking.shipment.updated', {
949
+ id: shipment.id,
950
+ tenantId: shipment.tenantId,
951
+ organizationId: shipment.organizationId,
952
+ })
953
+ }
954
+ }
955
+
956
+ // Emit ETA change events
957
+ for (const { shipment, previousEta, newEta } of etaChanges) {
958
+ await this.deps.eventBus.emit('shipment_tracking.transport.eta_updated', {
959
+ id: shipment.id,
960
+ previousEta: previousEta.toISOString(),
961
+ newEta: newEta.toISOString(),
962
+ tenantId: shipment.tenantId,
963
+ organizationId: shipment.organizationId,
964
+ })
965
+ }
966
+
967
+ // Emit ETD change events
968
+ for (const { shipment, previousEtd, newEtd } of etdChanges) {
969
+ await this.deps.eventBus.emit('shipment_tracking.transport.etd_updated', {
970
+ id: shipment.id,
971
+ previousEtd: previousEtd.toISOString(),
972
+ newEtd: newEtd.toISOString(),
973
+ tenantId: shipment.tenantId,
974
+ organizationId: shipment.organizationId,
975
+ })
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Re-derive denormalized state (cargoEvents, routeStops, status, ETA/ETD/ATA/ATD, seals,
981
+ * locations) for a tracking job's shipments from their persisted TrackingEvents, then emit
982
+ * the resulting change events. Used by real-time ingestion (e.g. AIS POI events over NATS)
983
+ * so the Journey Timeline and derived state update immediately instead of waiting for the
984
+ * next carrier poll. Idempotent: deriveShipmentStateFromEvents is a no-op when nothing changed.
985
+ *
986
+ * @param em - entity manager managing the (already flushed) new TrackingEvents
987
+ * @param trackingJobId - the job whose shipments to refresh
988
+ * @param shipmentIds - optional subset; defaults to all of the job's shipments
989
+ */
990
+ async refreshShipmentsFromEvents(
991
+ em: EntityManager,
992
+ trackingJobId: string,
993
+ shipmentIds?: string[],
994
+ ): Promise<void> {
995
+ const job = await em.findOne(TrackingJob, { id: trackingJobId })
996
+ if (!job) return
997
+
998
+ // Air shipments derive status from ShipsGo's top-level air status during the
999
+ // poll (deriveAirShipmentStatus), not from DCSA/AIS vessel events. Running
1000
+ // the DCSA re-derive here (no airStatus) would mis-set them. AIS is
1001
+ // vessel-only, so an air job should never reach this path — guard anyway.
1002
+ if (job.mode === 'air') return
1003
+
1004
+ const shipments = shipmentIds && shipmentIds.length > 0
1005
+ ? await em.find(Shipment, { trackingJob: job, deletedAt: null, id: { $in: shipmentIds } })
1006
+ : await em.find(Shipment, { trackingJob: job, deletedAt: null })
1007
+ if (shipments.length === 0) return
1008
+
1009
+ const allEvents = await em.find(
1010
+ TrackingEvent,
1011
+ { trackingJob: job },
1012
+ { orderBy: { eventDateTime: 'asc' } },
1013
+ )
1014
+ if (allEvents.length === 0) return
1015
+
1016
+ const bicConfig = await this.loadBicConfig(em, {
1017
+ organizationId: job.organizationId,
1018
+ tenantId: job.tenantId,
1019
+ })
1020
+
1021
+ const statusChanges: Array<{ shipment: Shipment; previousStatus: string; newStatus: string }> = []
1022
+ const etaChanges: Array<{ shipment: Shipment; previousEta: Date; newEta: Date }> = []
1023
+ const etdChanges: Array<{ shipment: Shipment; previousEtd: Date; newEtd: Date }> = []
1024
+ const changedShipments: Shipment[] = []
1025
+
1026
+ for (const shipment of shipments) {
1027
+ const result = await this.deriveShipmentStateFromEvents(em, shipment, allEvents, bicConfig)
1028
+ if (!result.changed) continue
1029
+ changedShipments.push(shipment)
1030
+ if (result.statusChange) statusChanges.push({ shipment, ...result.statusChange })
1031
+ if (result.etaChange) etaChanges.push({ shipment, ...result.etaChange })
1032
+ if (result.etdChange) etdChanges.push({ shipment, ...result.etdChange })
1033
+ }
1034
+
1035
+ if (changedShipments.length === 0) return
1036
+
1037
+ await em.flush()
1038
+ await this.emitShipmentDerivedChanges(changedShipments, statusChanges, etaChanges, etdChanges)
1039
+ }
1040
+
1041
+ /**
1042
+ * Derives and updates Shipment state from TrackingEvents.
1043
+ * Filters events by equipmentReference to get container-specific events.
1044
+ * TRANSPORT events (no equipmentReference) apply to all containers.
1045
+ *
1046
+ * Returns status change info and time changes so caller can emit events after flush.
1047
+ */
1048
+ private async deriveShipmentStateFromEvents(
1049
+ em: EntityManager,
1050
+ shipment: Shipment,
1051
+ allEvents: TrackingEvent[],
1052
+ bicConfig: BicConfig | null,
1053
+ airStatus?: string | null,
1054
+ ): Promise<{
1055
+ changed: boolean
1056
+ statusChange?: { previousStatus: string; newStatus: string }
1057
+ etaChange?: { previousEta: Date; newEta: Date }
1058
+ etdChange?: { previousEtd: Date; newEtd: Date }
1059
+ }> {
1060
+ // Filter events for this specific container
1061
+ // Include EQUIPMENT events with matching equipmentReference
1062
+ // Include TRANSPORT events (they apply to all containers)
1063
+ const containerEvents = allEvents.filter((event) => {
1064
+ if (event.eventType === 'TRANSPORT') return true
1065
+ if (event.eventType === 'EQUIPMENT') {
1066
+ return event.equipmentReference === shipment.containerNumber
1067
+ }
1068
+ return false
1069
+ })
1070
+
1071
+ if (containerEvents.length === 0) {
1072
+ return { changed: false }
1073
+ }
1074
+
1075
+ // Get UN/LOCODEs from JSONB location objects
1076
+ const originUnlocode = shipment.originLocation?.unlocode ?? null
1077
+ const destinationUnlocode = shipment.destinationLocation?.unlocode ?? null
1078
+
1079
+ const context = {
1080
+ originUnlocode,
1081
+ destinationUnlocode,
1082
+ }
1083
+
1084
+ // Extract times from events first - needed for both status derivation and updates
1085
+ const times = extractShipmentTimes(
1086
+ containerEvents.map((event) => ({
1087
+ eventCode: event.eventCode,
1088
+ eventClassifierCode: event.eventClassifierCode,
1089
+ eventDateTime: event.eventDateTime,
1090
+ eventDateTimeOffset: event.eventDateTimeOffset,
1091
+ eventCreatedDateTime: event.eventCreatedDateTime,
1092
+ locationUnlocode: event.locationUnlocode,
1093
+ })),
1094
+ context,
1095
+ )
1096
+
1097
+ // Get latest event for lastEventAt
1098
+ const latestEvent = containerEvents[containerEvents.length - 1]
1099
+
1100
+ // Find latest event with vessel info (gate operations often don't have vessel data)
1101
+ const latestVesselInfo = findLatestVesselInfo(containerEvents)
1102
+
1103
+ // Track previous primary timestamp values for change detection
1104
+ const previousEta = getPrimaryTimestampValue(shipment.etaTimestamps)
1105
+ const previousEtd = getPrimaryTimestampValue(shipment.etdTimestamps)
1106
+ const previousAtd = getPrimaryTimestampValue(shipment.atdTimestamps)
1107
+ const previousAta = getPrimaryTimestampValue(shipment.ataTimestamps)
1108
+
1109
+ // Merge extracted timestamps into existing arrays (with deduplication)
1110
+ const mergedTimestamps = mergeExtractedTimestamps(
1111
+ {
1112
+ etdTimestamps: shipment.etdTimestamps,
1113
+ etaTimestamps: shipment.etaTimestamps,
1114
+ atdTimestamps: shipment.atdTimestamps,
1115
+ ataTimestamps: shipment.ataTimestamps,
1116
+ },
1117
+ times,
1118
+ 'carrier_api',
1119
+ latestEvent?.sourceEventId,
1120
+ )
1121
+
1122
+ // Get new primary values after merge
1123
+ const newEta = getPrimaryTimestampValue(mergedTimestamps.etaTimestamps)
1124
+ const newEtd = getPrimaryTimestampValue(mergedTimestamps.etdTimestamps)
1125
+ const newAtd = getPrimaryTimestampValue(mergedTimestamps.atdTimestamps)
1126
+ const newAta = getPrimaryTimestampValue(mergedTimestamps.ataTimestamps)
1127
+
1128
+ // Derive status. Air (mode='air') has no container/vessel movement model, so
1129
+ // its status comes from ShipsGo's top-level status (LANDED→ARRIVED, …) rather
1130
+ // than DCSA event-location matching. Ocean/carrier use the DCSA status machine
1131
+ // with a time context for the PRE_ARRIVAL upgrade.
1132
+ const previousStatus = shipment.status
1133
+ const newStatus = airStatus !== undefined
1134
+ ? deriveAirShipmentStatus(airStatus, shipment.status)
1135
+ : deriveShipmentStatus(
1136
+ containerEvents.map((event) => ({
1137
+ eventCode: event.eventCode,
1138
+ eventClassifierCode: event.eventClassifierCode,
1139
+ locationUnlocode: event.locationUnlocode,
1140
+ })),
1141
+ context,
1142
+ shipment.status,
1143
+ { eta: newEta, ata: newAta },
1144
+ )
1145
+
1146
+ // Check if ETA/ETD changed (compare timestamps, handle null)
1147
+ const etaChanged = newEta && previousEta && newEta.getTime() !== previousEta.getTime()
1148
+ const etdChanged = newEtd && previousEtd && newEtd.getTime() !== previousEtd.getTime()
1149
+
1150
+ // Check if anything changed.
1151
+ // Includes an event-count delta so that appending events which don't move any derived
1152
+ // value (e.g. AIS POI port/terminal events, whose codes aren't read by time/status
1153
+ // extraction) still triggers a denormalized cargoEvents/routeStops rebuild — otherwise
1154
+ // they'd never reach the Journey Timeline.
1155
+ const changed =
1156
+ previousStatus !== newStatus ||
1157
+ mergedTimestamps.changed ||
1158
+ (newEtd && !previousEtd) || etdChanged ||
1159
+ (newEta && !previousEta) || etaChanged ||
1160
+ (newAtd && !previousAtd) || (newAtd && previousAtd && newAtd.getTime() !== previousAtd.getTime()) ||
1161
+ (newAta && !previousAta) || (newAta && previousAta && newAta.getTime() !== previousAta.getTime()) ||
1162
+ shipment.vesselName !== latestEvent?.vesselName ||
1163
+ containerEvents.length !== shipment.eventCount
1164
+
1165
+ if (!changed) {
1166
+ return { changed: false }
1167
+ }
1168
+
1169
+ // Update shipment
1170
+ shipment.status = newStatus as ShipmentStatusEnum
1171
+
1172
+ // Update timestamp arrays
1173
+ shipment.etdTimestamps = mergedTimestamps.etdTimestamps
1174
+ shipment.etaTimestamps = mergedTimestamps.etaTimestamps
1175
+ shipment.atdTimestamps = mergedTimestamps.atdTimestamps
1176
+ shipment.ataTimestamps = mergedTimestamps.ataTimestamps
1177
+
1178
+ // Update vessel info from latest event with vessel data
1179
+ if (latestVesselInfo) {
1180
+ shipment.vesselName = latestVesselInfo.vesselName ?? shipment.vesselName
1181
+ shipment.vesselImo = latestVesselInfo.vesselImo ?? shipment.vesselImo
1182
+ shipment.voyageNumber = latestVesselInfo.voyageNumber ?? shipment.voyageNumber
1183
+ }
1184
+
1185
+ // Extract ISO equipment code from the first EQUIPMENT event that has it
1186
+ if (!shipment.isoEquipmentCode) {
1187
+ const eventWithIsoCode = containerEvents.find(e => e.isoEquipmentCode)
1188
+ if (eventWithIsoCode) {
1189
+ shipment.isoEquipmentCode = eventWithIsoCode.isoEquipmentCode
1190
+ }
1191
+ }
1192
+
1193
+ // Extract booking number from document references if not already set
1194
+ if (!shipment.bookingNumber) {
1195
+ for (const event of containerEvents) {
1196
+ const bkgRef = event.relatedDocumentReferences?.find(ref => ref.type === 'BKG')
1197
+ if (bkgRef?.value) {
1198
+ shipment.bookingNumber = bkgRef.value
1199
+ break
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ // Extract BOL number from document references if not already set
1205
+ if (!shipment.bolNumber) {
1206
+ for (const event of containerEvents) {
1207
+ const bolRef = event.relatedDocumentReferences?.find(ref => ref.type === 'TRD' || ref.type === 'SHI')
1208
+ if (bolRef?.value) {
1209
+ shipment.bolNumber = bolRef.value
1210
+ break
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ // Update lastEventAt from actual latest event
1216
+ if (latestEvent) {
1217
+ shipment.lastEventAt = latestEvent.eventDateTime
1218
+ }
1219
+
1220
+ shipment.eventCount = containerEvents.length
1221
+
1222
+ // ─── Denormalize cargo events and route stops ────────────────────
1223
+ // Map TrackingEvent entities to CargoEventEntry format for JSONB storage
1224
+ const cargoEvents = containerEvents.map(mapTrackingEventToEntry)
1225
+ shipment.cargoEvents = cargoEvents
1226
+
1227
+ // Extract route stops from the mapped events
1228
+ shipment.routeStops = extractRouteFromEvents(cargoEvents, {
1229
+ originUnlocode,
1230
+ destinationUnlocode,
1231
+ })
1232
+
1233
+ // ─── Aggregate seals from all events ─────────────────────────────
1234
+ // Collect all unique seals seen across all cargo events for this container.
1235
+ // Deduplicate by seal number, keeping the most recent occurrence.
1236
+ const allSeals = containerEvents
1237
+ .flatMap(e => e.seals ?? [])
1238
+ .filter(seal => seal.number) // Ensure valid seal with number
1239
+
1240
+ if (allSeals.length > 0) {
1241
+ // Deduplicate by seal number - later occurrences (newer events) win
1242
+ const seenSeals = new Map<string, typeof allSeals[0]>()
1243
+ for (const seal of allSeals) {
1244
+ seenSeals.set(seal.number, seal)
1245
+ }
1246
+ shipment.seals = Array.from(seenSeals.values())
1247
+ } else {
1248
+ shipment.seals = null
1249
+ }
1250
+
1251
+ // ─── Build rich origin/destination locations ────────────────────
1252
+ // Find the best event for origin using priority:
1253
+ // 1. LOAD event at origin (actual loading at terminal)
1254
+ // 2. Any event at origin with facilityCode (terminal data available)
1255
+ // 3. Fallback: any event at origin location
1256
+ const originEventsAtLocation = containerEvents.filter(e =>
1257
+ e.locationUnlocode === originUnlocode
1258
+ )
1259
+ const originEvent =
1260
+ // Priority 1: LOAD event at origin
1261
+ originEventsAtLocation.find(e => e.eventCode === 'LOAD' && e.eventClassifierCode === 'ACT') ??
1262
+ // Priority 2: Any event with facility code at origin
1263
+ originEventsAtLocation.find(e => e.facilityCode != null) ??
1264
+ // Fallback: First event at origin, or LOAD event anywhere if no origin specified
1265
+ originEventsAtLocation[0] ??
1266
+ (!originUnlocode ? containerEvents.find(e => e.eventCode === 'LOAD' && e.eventClassifierCode === 'ACT') : null)
1267
+
1268
+ // Find the best event for destination using priority:
1269
+ // 1. DISC event at destination (actual discharge at terminal)
1270
+ // 2. Any event at destination with facilityCode (terminal data available)
1271
+ // 3. Fallback: last event at destination location
1272
+ const destEventsAtLocation = containerEvents.filter(e =>
1273
+ e.locationUnlocode === destinationUnlocode
1274
+ )
1275
+ const destEventsReversed = [...destEventsAtLocation].reverse()
1276
+ const destEvent =
1277
+ // Priority 1: DISC event at destination (last one)
1278
+ destEventsReversed.find(e => e.eventCode === 'DISC' && e.eventClassifierCode === 'ACT') ??
1279
+ // Priority 2: Any event with facility code at destination (last one)
1280
+ destEventsReversed.find(e => e.facilityCode != null) ??
1281
+ // Fallback: Last event at destination, or DISC/ARRI event anywhere if no destination specified
1282
+ destEventsReversed[0] ??
1283
+ (!destinationUnlocode ? [...containerEvents].reverse().find(e => (e.eventCode === 'DISC' || e.eventCode === 'ARRI') && e.eventClassifierCode === 'ACT') : null)
1284
+
1285
+ if (originEvent) {
1286
+ shipment.originLocation = buildLocationFromEvent({
1287
+ locationName: originEvent.locationName,
1288
+ locationUnlocode: originEvent.locationUnlocode,
1289
+ locationCountry: originEvent.locationCountry,
1290
+ facilityCode: originEvent.facilityCode,
1291
+ facilityCodeListProvider: originEvent.facilityCodeListProvider,
1292
+ facilityTypeCode: originEvent.facilityTypeCode,
1293
+ facilityAddress: originEvent.facilityAddress,
1294
+ latitude: originEvent.latitude,
1295
+ longitude: originEvent.longitude,
1296
+ })
1297
+ }
1298
+
1299
+ if (destEvent) {
1300
+ shipment.destinationLocation = buildLocationFromEvent({
1301
+ locationName: destEvent.locationName,
1302
+ locationUnlocode: destEvent.locationUnlocode,
1303
+ locationCountry: destEvent.locationCountry,
1304
+ facilityCode: destEvent.facilityCode,
1305
+ facilityCodeListProvider: destEvent.facilityCodeListProvider,
1306
+ facilityTypeCode: destEvent.facilityTypeCode,
1307
+ facilityAddress: destEvent.facilityAddress,
1308
+ latitude: destEvent.latitude,
1309
+ longitude: destEvent.longitude,
1310
+ })
1311
+ }
1312
+
1313
+ // ─── Enrich locations with BIC Facility API data ─────────────────
1314
+ // Only enrich if BIC config is enabled and locations are incomplete
1315
+ if (bicConfig) {
1316
+ await this.enrichShipmentLocationsWithBic(shipment, containerEvents, bicConfig)
1317
+ }
1318
+
1319
+ // ─── Apply location overrides ─────────────────────────────────────
1320
+ // Overrides take priority over BIC data and allow correcting incorrect terminal info
1321
+ const scope = { organizationId: shipment.organizationId, tenantId: shipment.tenantId }
1322
+ const carrierCode = shipment.carrierCode?.toUpperCase() ?? null
1323
+
1324
+ shipment.originLocation = await applyLocationOverrideIfExists(
1325
+ em, shipment.originLocation, carrierCode, scope
1326
+ )
1327
+ shipment.destinationLocation = await applyLocationOverrideIfExists(
1328
+ em, shipment.destinationLocation, carrierCode, scope
1329
+ )
1330
+
1331
+ // Apply overrides to route stops
1332
+ if (shipment.routeStops) {
1333
+ for (const stop of shipment.routeStops) {
1334
+ if (stop.facilityCode && stop.facilityCodeListProvider) {
1335
+ const overridden = await applyLocationOverrideIfExists(em, {
1336
+ name: stop.location,
1337
+ unlocode: stop.unlocode ?? null,
1338
+ countryCode: stop.unlocode?.slice(0, 2) ?? null,
1339
+ facilityCode: stop.facilityCode,
1340
+ facilityCodeListProvider: stop.facilityCodeListProvider,
1341
+ facilityTypeCode: stop.facilityTypeCode ?? null,
1342
+ address: stop.facilityAddress ?? null,
1343
+ coords: stop.coords ?? null,
1344
+ operatorName: null,
1345
+ source: 'dcsa',
1346
+ }, carrierCode, scope)
1347
+ if (overridden && overridden.source === 'manual') {
1348
+ stop.location = overridden.name
1349
+ stop.facilityAddress = overridden.address
1350
+ stop.coords = overridden.coords
1351
+ }
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ // Return change info so caller can emit events after flush
1357
+ const statusChange = previousStatus !== newStatus
1358
+ ? { previousStatus, newStatus }
1359
+ : undefined
1360
+
1361
+ // Only report ETA/ETD changes when the value actually changed (not initial set)
1362
+ const etaChange = etaChanged && previousEta && newEta
1363
+ ? { previousEta, newEta }
1364
+ : undefined
1365
+
1366
+ const etdChange = etdChanged && previousEtd && newEtd
1367
+ ? { previousEtd, newEtd }
1368
+ : undefined
1369
+
1370
+ return { changed: true, statusChange, etaChange, etdChange }
1371
+ }
1372
+
1373
+ /**
1374
+ * Emits events for a newly created TrackingEvent.
1375
+ */
1376
+ private async emitTrackingEventCreated(
1377
+ event: TrackingEvent,
1378
+ job: TrackingJob,
1379
+ ): Promise<void> {
1380
+ const eventPayload = {
1381
+ id: event.id,
1382
+ trackingJobId: job.id,
1383
+ tenantId: event.tenantId,
1384
+ organizationId: event.organizationId,
1385
+
1386
+ // Source
1387
+ source: event.source,
1388
+ sourceEventId: event.sourceEventId,
1389
+
1390
+ // Core event fields
1391
+ eventType: event.eventType,
1392
+ eventCode: event.eventCode,
1393
+ eventClassifierCode: event.eventClassifierCode,
1394
+ eventDateTime: event.eventDateTime?.toISOString(),
1395
+ description: event.description,
1396
+
1397
+ // Equipment fields
1398
+ equipmentReference: event.equipmentReference,
1399
+ isoEquipmentCode: event.isoEquipmentCode,
1400
+ emptyIndicatorCode: event.emptyIndicatorCode,
1401
+ isTransshipmentMove: event.isTransshipmentMove,
1402
+
1403
+ // Location fields
1404
+ locationName: event.locationName,
1405
+ locationUnlocode: event.locationUnlocode,
1406
+ locationCountry: event.locationCountry,
1407
+ facilityCode: event.facilityCode,
1408
+ facilityTypeCode: event.facilityTypeCode,
1409
+
1410
+ // Transport call fields
1411
+ vesselName: event.vesselName,
1412
+ vesselImo: event.vesselImo,
1413
+ voyageNumber: event.voyageNumber,
1414
+ carrierServiceCode: event.carrierServiceCode,
1415
+ modeOfTransport: event.modeOfTransport,
1416
+
1417
+ // Document references
1418
+ relatedDocumentReferences: event.relatedDocumentReferences,
1419
+
1420
+ // Metadata
1421
+ publisherName: event.publisherName,
1422
+ publisherRole: event.publisherRole,
1423
+ }
1424
+
1425
+ // Always emit generic event for backward compatibility
1426
+ await this.deps.eventBus.emit('shipment_tracking.tracking_event.created', eventPayload)
1427
+
1428
+ // Emit granular DCSA-compliant event if significant milestone
1429
+ if (isSignificantMilestone({
1430
+ eventType: event.eventType,
1431
+ eventCode: event.eventCode,
1432
+ eventClassifierCode: event.eventClassifierCode,
1433
+ })) {
1434
+ const dcsaEventType = mapDcsaEventToWebhookType({
1435
+ eventType: event.eventType,
1436
+ eventCode: event.eventCode,
1437
+ eventClassifierCode: event.eventClassifierCode,
1438
+ })
1439
+
1440
+ await this.deps.eventBus.emit(dcsaEventType, eventPayload)
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Loads BIC Facility API configuration for a tenant/organization.
1446
+ */
1447
+ private async loadBicConfig(
1448
+ em: EntityManager,
1449
+ scope: { tenantId: string; organizationId: string },
1450
+ ): Promise<BicConfig | null> {
1451
+ return findOneWithDecryption(
1452
+ em,
1453
+ BicConfig,
1454
+ {
1455
+ organizationId: scope.organizationId,
1456
+ tenantId: scope.tenantId,
1457
+ isEnabled: true,
1458
+ },
1459
+ undefined,
1460
+ scope,
1461
+ )
1462
+ }
1463
+
1464
+ /**
1465
+ * Enriches shipment origin/destination locations and route stops with BIC Facility API data.
1466
+ * Only fetches data for facilities that are incomplete (missing coords or address).
1467
+ * One API call per unique facility code per poll.
1468
+ */
1469
+ private async enrichShipmentLocationsWithBic(
1470
+ shipment: Shipment,
1471
+ events: TrackingEvent[],
1472
+ bicConfig: BicConfig,
1473
+ ): Promise<void> {
1474
+ // Collect unique facility codes that need enrichment
1475
+ const facilitiesToEnrich = new Map<string, { code: string; provider: FacilityCodeListProvider; unlocode: string }>()
1476
+
1477
+ // Check origin location
1478
+ if (shipment.originLocation && !isLocationComplete(shipment.originLocation) && shipment.originLocation.facilityCode) {
1479
+ const provider = shipment.originLocation.facilityCodeListProvider ?? 'SMDG'
1480
+ facilitiesToEnrich.set(shipment.originLocation.facilityCode, {
1481
+ code: shipment.originLocation.facilityCode,
1482
+ provider,
1483
+ unlocode: shipment.originLocation.unlocode ?? '',
1484
+ })
1485
+ }
1486
+
1487
+ // Check destination location
1488
+ if (shipment.destinationLocation && !isLocationComplete(shipment.destinationLocation) && shipment.destinationLocation.facilityCode) {
1489
+ const provider = shipment.destinationLocation.facilityCodeListProvider ?? 'SMDG'
1490
+ facilitiesToEnrich.set(shipment.destinationLocation.facilityCode, {
1491
+ code: shipment.destinationLocation.facilityCode,
1492
+ provider,
1493
+ unlocode: shipment.destinationLocation.unlocode ?? '',
1494
+ })
1495
+ }
1496
+
1497
+ // Check events for facility codes (for route stops enrichment)
1498
+ for (const event of events) {
1499
+ if (event.facilityCode && event.latitude == null && !event.facilityAddress) {
1500
+ const provider = event.facilityCodeListProvider ?? 'SMDG'
1501
+ if (!facilitiesToEnrich.has(event.facilityCode)) {
1502
+ facilitiesToEnrich.set(event.facilityCode, {
1503
+ code: event.facilityCode,
1504
+ provider,
1505
+ unlocode: event.locationUnlocode ?? '',
1506
+ })
1507
+ }
1508
+ }
1509
+ }
1510
+
1511
+ if (facilitiesToEnrich.size === 0) {
1512
+ return
1513
+ }
1514
+
1515
+ // Create BIC API client
1516
+ const client = new BicApiClient({
1517
+ baseUrl: bicConfig.baseUrl,
1518
+ username: bicConfig.username,
1519
+ password: bicConfig.password,
1520
+ })
1521
+
1522
+ // Fetch facility data (one call per unique facility)
1523
+ const facilityMap = new Map<string, BicFacility>()
1524
+ for (const [code, info] of facilitiesToEnrich) {
1525
+ try {
1526
+ const facility = await client.getFacility(info.code, info.provider, info.unlocode)
1527
+ if (facility) {
1528
+ facilityMap.set(code, facility)
1529
+ }
1530
+ } catch (error) {
1531
+ trackingLogger.error('Failed to fetch BIC facility', {
1532
+ facilityCode: code,
1533
+ codeProvider: info.provider,
1534
+ unlocode: info.unlocode,
1535
+ shipmentId: shipment.id,
1536
+ error: error instanceof Error ? error.message : String(error),
1537
+ })
1538
+ // Continue with other facilities - don't fail the whole enrichment
1539
+ }
1540
+ }
1541
+
1542
+ if (facilityMap.size === 0) {
1543
+ return
1544
+ }
1545
+
1546
+ // Enrich origin location
1547
+ if (shipment.originLocation?.facilityCode && facilityMap.has(shipment.originLocation.facilityCode)) {
1548
+ const bic = facilityMap.get(shipment.originLocation.facilityCode)!
1549
+ shipment.originLocation = this.mergeLocationWithBicFacility(shipment.originLocation, bic)
1550
+ }
1551
+
1552
+ // Enrich destination location
1553
+ if (shipment.destinationLocation?.facilityCode && facilityMap.has(shipment.destinationLocation.facilityCode)) {
1554
+ const bic = facilityMap.get(shipment.destinationLocation.facilityCode)!
1555
+ shipment.destinationLocation = this.mergeLocationWithBicFacility(shipment.destinationLocation, bic)
1556
+ }
1557
+
1558
+ // Enrich route stops
1559
+ if (shipment.routeStops) {
1560
+ for (const stop of shipment.routeStops) {
1561
+ if (stop.facilityCode && facilityMap.has(stop.facilityCode)) {
1562
+ const bic = facilityMap.get(stop.facilityCode)!
1563
+ const coords = BicApiClient.parseCoordinates(bic)
1564
+ const address = BicApiClient.formatAddress(bic)
1565
+
1566
+ if (!stop.coords && coords) {
1567
+ stop.coords = coords
1568
+ }
1569
+ if (!stop.facilityAddress && address) {
1570
+ stop.facilityAddress = address
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+
1576
+ // Enrich cargo events
1577
+ if (shipment.cargoEvents) {
1578
+ for (const event of shipment.cargoEvents) {
1579
+ if (event.facilityCode && facilityMap.has(event.facilityCode)) {
1580
+ const bic = facilityMap.get(event.facilityCode)!
1581
+ const coords = BicApiClient.parseCoordinates(bic)
1582
+ const address = BicApiClient.formatAddress(bic)
1583
+
1584
+ if (event.latitude == null && coords) {
1585
+ event.latitude = coords.latitude
1586
+ event.longitude = coords.longitude
1587
+ }
1588
+ if (!event.facilityAddress && address) {
1589
+ event.facilityAddress = address
1590
+ }
1591
+ }
1592
+ }
1593
+ }
1594
+ }
1595
+
1596
+ /**
1597
+ * Merges BIC facility data into a FacilityLocation (only fills missing fields).
1598
+ */
1599
+ private mergeLocationWithBicFacility(location: FacilityLocation, bic: BicFacility): FacilityLocation {
1600
+ const coords = BicApiClient.parseCoordinates(bic)
1601
+ const address = BicApiClient.formatAddress(bic)
1602
+ const operatorName = BicApiClient.getOperatorName(bic)
1603
+ const facilityName = BicApiClient.getFacilityName(bic)
1604
+
1605
+ return mergeLocationWithBicData(location, {
1606
+ name: facilityName ?? undefined,
1607
+ address: address ?? undefined,
1608
+ coords: coords ?? undefined,
1609
+ operatorName: operatorName ?? undefined,
1610
+ })
1611
+ }
1612
+
1613
+ /**
1614
+ * Stops an active tracking job (sets status to 'deactivated').
1615
+ * Tenant-scoped: refuses to stop a job not belonging to the caller's tenant.
1616
+ * Idempotent: returns { stopped: false } when the job is already inactive,
1617
+ * doesn't exist, or belongs to a different tenant.
1618
+ */
1619
+ async stopTrackingJob(
1620
+ jobId: string,
1621
+ ctx: { organizationId: string; tenantId: string },
1622
+ ): Promise<{ stopped: boolean }> {
1623
+ const em = this.deps.em()
1624
+ const job = await em.findOne(TrackingJob, {
1625
+ id: jobId,
1626
+ organizationId: ctx.organizationId,
1627
+ tenantId: ctx.tenantId,
1628
+ deletedAt: null,
1629
+ })
1630
+
1631
+ if (!job) {
1632
+ return { stopped: false }
1633
+ }
1634
+
1635
+ if (job.status !== 'active' && job.status !== 'paused') {
1636
+ return { stopped: false }
1637
+ }
1638
+
1639
+ await this.stopJobInline(em, job)
1640
+
1641
+ trackingLogger.info('Tracking job stopped', {
1642
+ trackingJobId: jobId,
1643
+ tenantId: ctx.tenantId,
1644
+ organizationId: ctx.organizationId,
1645
+ })
1646
+
1647
+ return { stopped: true }
1648
+ }
1649
+
1650
+ /**
1651
+ * Stop a job (target status defaults to `deactivated`) and emit
1652
+ * `tracking_job.completed`, operating on the given EntityManager (the job must
1653
+ * be managed by it). Shared by the public `stopTrackingJob` (externally
1654
+ * stopped → `deactivated`) and the carrier-poll auto-stop (cargo finished →
1655
+ * `completed`) so both routes flush + emit identically. Either status is
1656
+ * excluded from the `status: 'active'` poll filter, so polling stops.
1657
+ */
1658
+ private async stopJobInline(
1659
+ em: EntityManager,
1660
+ job: TrackingJob,
1661
+ status: TrackingJobStatusEnum = 'deactivated',
1662
+ ): Promise<void> {
1663
+ job.status = status
1664
+ job.nextPollAt = null
1665
+ await em.flush()
1666
+
1667
+ await this.deps.eventBus.emit('shipment_tracking.tracking_job.completed', {
1668
+ id: job.id,
1669
+ tenantId: job.tenantId,
1670
+ organizationId: job.organizationId,
1671
+ })
1672
+ }
1673
+
1674
+ /**
1675
+ * Reconciliation: stops any active TrackingJob that has no folder_legs row
1676
+ * referencing it via tracking_job_id. Used by the daily reconciliation cron
1677
+ * to clean up orphans created by failed Phase B/C re-tracking flows.
1678
+ *
1679
+ * Returns { stoppedCount } so the cron can surface to ops.
1680
+ * The optional folderLegsTracker param exists so this code can run from
1681
+ * @freighttech/shipment-tracking without a hard import on @freighttech/projects:
1682
+ * the caller passes a function that returns the set of tracking_job_ids
1683
+ * currently referenced by folder_legs.
1684
+ */
1685
+ async reconcileOrphanedTrackingJobs(
1686
+ folderLegTrackingJobIds: Set<string>,
1687
+ ctx?: { organizationId?: string; tenantId?: string },
1688
+ ): Promise<{ stoppedCount: number; checked: number }> {
1689
+ const em = this.deps.em()
1690
+ const filter: Record<string, unknown> = {
1691
+ status: 'active',
1692
+ deletedAt: null,
1693
+ }
1694
+ if (ctx?.organizationId) filter.organizationId = ctx.organizationId
1695
+ if (ctx?.tenantId) filter.tenantId = ctx.tenantId
1696
+
1697
+ const activeJobs = await em.find(TrackingJob, filter)
1698
+
1699
+ let stoppedCount = 0
1700
+ for (const job of activeJobs) {
1701
+ if (folderLegTrackingJobIds.has(job.id)) continue
1702
+ const result = await this.stopTrackingJob(job.id, {
1703
+ organizationId: job.organizationId,
1704
+ tenantId: job.tenantId,
1705
+ })
1706
+ if (result.stopped) stoppedCount++
1707
+ }
1708
+
1709
+ return { stoppedCount, checked: activeJobs.length }
1710
+ }
1711
+
1712
+ /**
1713
+ * Records an error on a tracking job.
1714
+ */
1715
+ /**
1716
+ * Backs a job off by `backoffMs` and logs `message` in its error history,
1717
+ * WITHOUT touching retryCount. For account-level/transient ShipsGo conditions
1718
+ * (402 credits, 429 rate limit, 403 auth) that must not consume the per-job
1719
+ * failure budget (which would flip the job to 'failed') nor keep re-hitting an
1720
+ * exhausted/throttled account every poll. The job resumes on its own.
1721
+ */
1722
+ private async recordTransientBackoff(
1723
+ em: EntityManager,
1724
+ job: TrackingJob,
1725
+ message: string,
1726
+ backoffMs: number,
1727
+ ): Promise<void> {
1728
+ const history = job.errorHistory ?? []
1729
+ history.push({ date: new Date().toISOString(), message })
1730
+ if (history.length > 20) history.splice(0, history.length - 20)
1731
+ job.errorHistory = history
1732
+ job.lastPollAt = new Date()
1733
+ job.nextPollAt = new Date(Date.now() + backoffMs)
1734
+ await em.flush()
1735
+ }
1736
+
1737
+ /** ShipsGo credit-exhaustion (HTTP 402): back off ~6h until credits are topped up. */
1738
+ private async recordCreditExhaustion(em: EntityManager, job: TrackingJob): Promise<void> {
1739
+ await this.recordTransientBackoff(em, job, 'ShipsGo credits exhausted (HTTP 402)', 6 * 60 * 60 * 1000)
1740
+ }
1741
+
1742
+ private async recordJobError(
1743
+ em: EntityManager,
1744
+ job: TrackingJob,
1745
+ message: string,
1746
+ ): Promise<void> {
1747
+ const history = job.errorHistory ?? []
1748
+ history.push({ date: new Date().toISOString(), message })
1749
+
1750
+ // Keep last 20 errors
1751
+ if (history.length > 20) {
1752
+ history.splice(0, history.length - 20)
1753
+ }
1754
+
1755
+ job.errorHistory = history
1756
+ job.retryCount = (job.retryCount || 0) + 1
1757
+ job.lastPollAt = new Date()
1758
+
1759
+ // Mark as failed after 10 consecutive errors
1760
+ if (job.retryCount >= 10) {
1761
+ job.status = 'failed'
1762
+ await em.flush()
1763
+
1764
+ await this.deps.eventBus.emit('shipment_tracking.tracking_job.failed', {
1765
+ id: job.id,
1766
+ carrierCode: job.carrierCode,
1767
+ retryCount: job.retryCount,
1768
+ lastError: message,
1769
+ tenantId: job.tenantId,
1770
+ organizationId: job.organizationId,
1771
+ })
1772
+ return
1773
+ }
1774
+
1775
+ await em.flush()
1776
+ }
1777
+ }