@freighttech/terminal-tracking 0.7.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 (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/generated/entities/terminal_config/index.js +53 -0
  4. package/dist/generated/entities/terminal_config/index.js.map +7 -0
  5. package/dist/generated/entities/terminal_event/index.js +59 -0
  6. package/dist/generated/entities/terminal_event/index.js.map +7 -0
  7. package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
  8. package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
  9. package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
  10. package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
  11. package/dist/generated/entities.ids.generated.js +26 -0
  12. package/dist/generated/entities.ids.generated.js.map +7 -0
  13. package/dist/index.js +20 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/modules/terminal_tracking/acl.js +14 -0
  16. package/dist/modules/terminal_tracking/acl.js.map +7 -0
  17. package/dist/modules/terminal_tracking/api/openapi.js +27 -0
  18. package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
  19. package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
  20. package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
  21. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
  22. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
  23. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
  24. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
  25. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
  26. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
  27. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
  28. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
  29. package/dist/modules/terminal_tracking/api/utils.js +12 -0
  30. package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
  31. package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
  32. package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
  33. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
  34. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
  35. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
  36. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
  37. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
  38. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
  39. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
  40. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
  41. package/dist/modules/terminal_tracking/ce.js +35 -0
  42. package/dist/modules/terminal_tracking/ce.js.map +7 -0
  43. package/dist/modules/terminal_tracking/commands/index.js +4 -0
  44. package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
  45. package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
  46. package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
  47. package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
  48. package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
  49. package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
  50. package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
  51. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
  52. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
  53. package/dist/modules/terminal_tracking/data/entities.js +337 -0
  54. package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
  55. package/dist/modules/terminal_tracking/data/validators.js +98 -0
  56. package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
  57. package/dist/modules/terminal_tracking/di.js +39 -0
  58. package/dist/modules/terminal_tracking/di.js.map +7 -0
  59. package/dist/modules/terminal_tracking/encryption.js +14 -0
  60. package/dist/modules/terminal_tracking/encryption.js.map +7 -0
  61. package/dist/modules/terminal_tracking/events.js +45 -0
  62. package/dist/modules/terminal_tracking/events.js.map +7 -0
  63. package/dist/modules/terminal_tracking/i18n/de.json +175 -0
  64. package/dist/modules/terminal_tracking/i18n/en.json +178 -0
  65. package/dist/modules/terminal_tracking/i18n/es.json +175 -0
  66. package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
  67. package/dist/modules/terminal_tracking/index.js +15 -0
  68. package/dist/modules/terminal_tracking/index.js.map +7 -0
  69. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
  70. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
  71. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
  72. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
  73. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
  74. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
  75. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
  76. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
  77. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
  78. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
  79. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
  80. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
  81. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
  82. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
  83. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
  84. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
  85. package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
  86. package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
  87. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
  88. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
  89. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
  90. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
  91. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
  92. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
  93. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
  94. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
  95. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
  96. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
  97. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
  98. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
  99. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
  100. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
  101. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
  102. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
  103. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
  104. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
  105. package/dist/modules/terminal_tracking/lib/availability.js +40 -0
  106. package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
  107. package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
  108. package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
  109. package/dist/modules/terminal_tracking/lib/holds.js +240 -0
  110. package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
  111. package/dist/modules/terminal_tracking/lib/logger.js +19 -0
  112. package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
  113. package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
  114. package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
  115. package/dist/modules/terminal_tracking/lib/stops.js +48 -0
  116. package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
  117. package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
  118. package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
  119. package/dist/modules/terminal_tracking/lib/totp.js +41 -0
  120. package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
  121. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
  122. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
  123. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
  124. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
  125. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
  126. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
  127. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
  128. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
  129. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
  130. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
  131. package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
  132. package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
  133. package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
  134. package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
  135. package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
  136. package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
  137. package/dist/modules/terminal_tracking/setup.js +23 -0
  138. package/dist/modules/terminal_tracking/setup.js.map +7 -0
  139. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
  140. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
  141. package/generated/entities/terminal_config/index.ts +36 -0
  142. package/generated/entities/terminal_event/index.ts +39 -0
  143. package/generated/entities/terminal_tracking_job/index.ts +28 -0
  144. package/generated/entities/terminal_vessel_visit/index.ts +30 -0
  145. package/generated/entities.ids.generated.ts +52 -0
  146. package/package.json +80 -0
  147. package/src/index.ts +44 -0
  148. package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
  149. package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
  150. package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
  151. package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
  152. package/src/modules/terminal_tracking/acl.ts +10 -0
  153. package/src/modules/terminal_tracking/api/openapi.ts +28 -0
  154. package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
  155. package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
  156. package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
  157. package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
  158. package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
  159. package/src/modules/terminal_tracking/api/utils.ts +10 -0
  160. package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
  161. package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
  162. package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
  163. package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
  164. package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
  165. package/src/modules/terminal_tracking/ce.ts +31 -0
  166. package/src/modules/terminal_tracking/commands/index.ts +3 -0
  167. package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
  168. package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
  169. package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
  170. package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
  171. package/src/modules/terminal_tracking/data/entities.ts +386 -0
  172. package/src/modules/terminal_tracking/data/validators.ts +113 -0
  173. package/src/modules/terminal_tracking/di.ts +46 -0
  174. package/src/modules/terminal_tracking/encryption.ts +12 -0
  175. package/src/modules/terminal_tracking/events.ts +48 -0
  176. package/src/modules/terminal_tracking/i18n/de.json +175 -0
  177. package/src/modules/terminal_tracking/i18n/en.json +178 -0
  178. package/src/modules/terminal_tracking/i18n/es.json +175 -0
  179. package/src/modules/terminal_tracking/i18n/pl.json +206 -0
  180. package/src/modules/terminal_tracking/index.ts +13 -0
  181. package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
  182. package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
  183. package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
  184. package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
  185. package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
  186. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
  187. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
  188. package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
  189. package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
  190. package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
  191. package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
  192. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
  193. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
  194. package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
  195. package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
  196. package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
  197. package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
  198. package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
  199. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
  200. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
  201. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
  202. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
  203. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
  204. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
  205. package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
  206. package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
  207. package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
  208. package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
  209. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
  210. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
  211. package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
  212. package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
  213. package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
  214. package/src/modules/terminal_tracking/lib/availability.ts +119 -0
  215. package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
  216. package/src/modules/terminal_tracking/lib/holds.ts +344 -0
  217. package/src/modules/terminal_tracking/lib/logger.ts +21 -0
  218. package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
  219. package/src/modules/terminal_tracking/lib/stops.ts +124 -0
  220. package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
  221. package/src/modules/terminal_tracking/lib/totp.ts +63 -0
  222. package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
  223. package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
  224. package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
  225. package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
  226. package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
  227. package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
  228. package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
  229. package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
  230. package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
  231. package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
  232. package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
  233. package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
  234. package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
  235. package/src/modules/terminal_tracking/setup.ts +24 -0
  236. package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
@@ -0,0 +1,912 @@
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 { TerminalConfig, TerminalTrackingJob, TerminalEvent, TerminalVesselVisit } from '../data/entities'
5
+ import type { ResolvedTerminalConfig, TerminalFetchedEvent, TerminalAdapter } from '../lib/terminal-adapter'
6
+ import type { TerminalRegistry } from './terminalRegistry'
7
+ import { checkRateLimit, type CacheService } from '../lib/rate-limiter'
8
+ import { semanticEventIdFor, pickVesselVisit, parseCargoCategory } from '../lib/adapters/n4/n4-semantics'
9
+ import { impedimentsChanged, isEmptyReady, isImportHoldsCleared } from '../lib/availability'
10
+ import { parseTerminalStops, stopsChanged, type TerminalStops } from '../lib/stops'
11
+ import { terminalLogger } from '../lib/logger'
12
+
13
+ /** Default TTL for a cached /VESSEL visit lookup. Vessel ETA/ATA move on the
14
+ * scale of hours and the shared rate bucket is mostly spent on /unit polls, so
15
+ * one hour balances freshness against token spend. */
16
+ const DEFAULT_VESSEL_CACHE_TTL_SECONDS = 3600
17
+
18
+ /** Max containers per /unit request. N4 accepts a comma-separated UNIT_NBR list
19
+ * (the terminal supports up to ~500); we cap lower to bound URL/response size
20
+ * and keep each batch to a single rate-limit token. */
21
+ const MAX_CONTAINERS_PER_UNIT_REQUEST = 100
22
+
23
+ function chunk<T>(items: T[], size: number): T[][] {
24
+ const out: T[][] = []
25
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
26
+ return out
27
+ }
28
+
29
+ /** A container's terminal lifecycle is complete when this poll returned events
30
+ * and every current leg has departed (`DEPA`). Empty results are NOT departed
31
+ * (container not found / unmapped states) — keep polling. */
32
+ function isFullyDeparted(events: TerminalFetchedEvent[]): boolean {
33
+ return events.length > 0 && events.every((e) => e.eventCode === 'DEPA')
34
+ }
35
+
36
+ type Deps = {
37
+ em: () => EntityManager
38
+ eventBus: EventBus
39
+ terminalRegistry: TerminalRegistry
40
+ cacheService: CacheService
41
+ }
42
+
43
+ const MAX_ERROR_HISTORY = 20
44
+ const MAX_RETRIES = 10
45
+
46
+ export type CreateJobInput = {
47
+ organizationId: string
48
+ tenantId: string
49
+ terminalCode: string
50
+ containerNumber: string
51
+ schedule?: string[] | null
52
+ }
53
+
54
+ export type PollJobResult = { newEvents: number; updatedEvents: number }
55
+ export type PollAllResult = { polled: number; newEvents: number; updatedEvents: number; failed: number }
56
+
57
+ type EventLike = Pick<
58
+ TerminalEvent,
59
+ | 'eventClassifierCode'
60
+ | 'eventDateTime'
61
+ | 'transitState'
62
+ | 'visitState'
63
+ | 'facilityCode'
64
+ | 'facilityCodeListProvider'
65
+ | 'unlocode'
66
+ | 'visitRefIn'
67
+ | 'visitRefOut'
68
+ | 'vesselName'
69
+ | 'voyageNumber'
70
+ | 'modeOfTransport'
71
+ | 'seals'
72
+ | 'vgmWeightKg'
73
+ | 'impediments'
74
+ | 'loadedAt'
75
+ >
76
+
77
+ /** Stable signature of an event's mutable container fields, to detect changes
78
+ * between polls. Excludes the identity (key) fields, which never change. */
79
+ function eventSignature(e: EventLike): string {
80
+ return JSON.stringify({
81
+ cls: e.eventClassifierCode ?? null,
82
+ dt: e.eventDateTime ? new Date(e.eventDateTime).toISOString() : null,
83
+ transitState: e.transitState ?? null,
84
+ visitState: e.visitState ?? null,
85
+ facilityCode: e.facilityCode ?? null,
86
+ facilityCodeListProvider: e.facilityCodeListProvider ?? null,
87
+ unlocode: e.unlocode ?? null,
88
+ visitRefIn: e.visitRefIn ?? null,
89
+ visitRefOut: e.visitRefOut ?? null,
90
+ vesselName: e.vesselName ?? null,
91
+ voyageNumber: e.voyageNumber ?? null,
92
+ modeOfTransport: e.modeOfTransport ?? null,
93
+ seals: e.seals ?? null,
94
+ vgmWeightKg: e.vgmWeightKg ?? null,
95
+ impediments: e.impediments ?? null,
96
+ loadedAt: e.loadedAt ? new Date(e.loadedAt).toISOString() : null,
97
+ })
98
+ }
99
+
100
+ type VesselLike = {
101
+ vesselName?: string | null
102
+ ibVoyage?: string | null
103
+ obVoyage?: string | null
104
+ line?: string | null
105
+ phase?: string | null
106
+ eta?: Date | null
107
+ etd?: Date | null
108
+ ata?: Date | null
109
+ atd?: Date | null
110
+ beginReceive?: Date | null
111
+ dryCutoff?: Date | null
112
+ }
113
+
114
+ /** Stable signature of a vessel visit's meaningful fields (ETA/ATA/etc.), to
115
+ * detect when /VESSEL data changes between polls. */
116
+ function vesselSignature(v: VesselLike): string {
117
+ const d = (x?: Date | null) => (x ? x.toISOString() : null)
118
+ return JSON.stringify({
119
+ vesselName: v.vesselName ?? null,
120
+ ibVoyage: v.ibVoyage ?? null,
121
+ obVoyage: v.obVoyage ?? null,
122
+ line: v.line ?? null,
123
+ phase: v.phase ?? null,
124
+ eta: d(v.eta),
125
+ etd: d(v.etd),
126
+ ata: d(v.ata),
127
+ atd: d(v.atd),
128
+ beginReceive: d(v.beginReceive),
129
+ dryCutoff: d(v.dryCutoff),
130
+ })
131
+ }
132
+
133
+ export class TerminalTrackingService {
134
+ constructor(private readonly deps: Deps) {}
135
+
136
+ private toResolvedConfig(config: TerminalConfig): ResolvedTerminalConfig {
137
+ return {
138
+ terminalCode: config.terminalCode,
139
+ adapterType: config.adapterType,
140
+ displayName: config.displayName,
141
+ baseUrl: config.baseUrl,
142
+ proxyUrl: config.proxyUrl ?? null,
143
+ endpoints: config.endpoints,
144
+ authType: config.authType,
145
+ tokenUrl: config.tokenUrl ?? null,
146
+ scope: config.scope ?? null,
147
+ clientId: config.clientId ?? null,
148
+ authConfig: config.authConfig ?? null,
149
+ rateLimitRequests: config.rateLimitRequests,
150
+ rateLimitWindowSeconds: config.rateLimitWindowSeconds,
151
+ unlocode: config.unlocode ?? null,
152
+ facilityCode: (config.smdgCodes ?? [])[0] ?? (config.bicCodes ?? [])[0] ?? null,
153
+ facilityCodeListProvider: (config.smdgCodes ?? []).length
154
+ ? 'SMDG'
155
+ : (config.bicCodes ?? []).length
156
+ ? 'BIC'
157
+ : null,
158
+ }
159
+ }
160
+
161
+ async testTerminalConfig(input: {
162
+ id: string
163
+ organizationId: string
164
+ tenantId: string
165
+ }): Promise<{ success: boolean; message: string; latencyMs?: number }> {
166
+ const em = this.deps.em().fork()
167
+ const config = await findOneWithDecryption(em, TerminalConfig, {
168
+ id: input.id,
169
+ organizationId: input.organizationId,
170
+ tenantId: input.tenantId,
171
+ deletedAt: null,
172
+ })
173
+ if (!config) return { success: false, message: 'Terminal config not found' }
174
+
175
+ const adapter = this.deps.terminalRegistry.get(config.adapterType)
176
+ if (!adapter) return { success: false, message: `No adapter registered for type '${config.adapterType}'` }
177
+
178
+ return adapter.testConnection(this.toResolvedConfig(config))
179
+ }
180
+
181
+ async createJob(input: CreateJobInput): Promise<{ trackingJobId: string; newEvents: number }> {
182
+ const em = this.deps.em().fork()
183
+
184
+ const existing = await em.findOne(TerminalTrackingJob, {
185
+ organizationId: input.organizationId,
186
+ tenantId: input.tenantId,
187
+ terminalCode: input.terminalCode,
188
+ containerNumber: input.containerNumber,
189
+ // Reuse only a still-open job (avoid duplicate in-flight tracking). A
190
+ // 'completed' job is NOT reused: a container that returns later gets a
191
+ // fresh tracking job with clean history.
192
+ status: { $in: ['active', 'paused'] },
193
+ deletedAt: null,
194
+ })
195
+
196
+ if (existing) {
197
+ // Re-poll the existing job so the caller gets fresh data.
198
+ const result = await this.pollJob(existing.id)
199
+ return { trackingJobId: existing.id, newEvents: result.newEvents }
200
+ }
201
+
202
+ const job = em.create(TerminalTrackingJob, {
203
+ organizationId: input.organizationId,
204
+ tenantId: input.tenantId,
205
+ terminalCode: input.terminalCode,
206
+ containerNumber: input.containerNumber,
207
+ status: 'active',
208
+ schedule: input.schedule ?? null,
209
+ nextPollAt: null,
210
+ })
211
+ await em.flush()
212
+
213
+ // The initial-poll subscriber performs the first poll on this event.
214
+ await this.deps.eventBus.emit('terminal_tracking.tracking_job.created', {
215
+ id: job.id,
216
+ terminalCode: job.terminalCode,
217
+ containerNumber: job.containerNumber,
218
+ tenantId: job.tenantId,
219
+ organizationId: job.organizationId,
220
+ })
221
+
222
+ return { trackingJobId: job.id, newEvents: 0 }
223
+ }
224
+
225
+ async pollJob(jobId: string): Promise<PollJobResult> {
226
+ const em = this.deps.em().fork()
227
+ const job = await em.findOne(TerminalTrackingJob, { id: jobId, deletedAt: null })
228
+ if (!job) throw new Error('Terminal tracking job not found')
229
+
230
+ try {
231
+ const config = await findOneWithDecryption(em, TerminalConfig, {
232
+ organizationId: job.organizationId,
233
+ tenantId: job.tenantId,
234
+ terminalCode: job.terminalCode,
235
+ isActive: true,
236
+ deletedAt: null,
237
+ })
238
+ if (!config) throw new Error(`No active terminal config for '${job.terminalCode}'`)
239
+
240
+ const limit = await checkRateLimit(
241
+ this.deps.cacheService,
242
+ job.tenantId,
243
+ job.terminalCode,
244
+ config.rateLimitRequests,
245
+ config.rateLimitWindowSeconds,
246
+ )
247
+ if (!limit.allowed) {
248
+ terminalLogger.debug('Rate limited, skipping poll', { jobId, terminalCode: job.terminalCode })
249
+ return { newEvents: 0, updatedEvents: 0 }
250
+ }
251
+
252
+ const adapter = this.deps.terminalRegistry.get(config.adapterType)
253
+ if (!adapter) throw new Error(`No adapter registered for type '${config.adapterType}'`)
254
+
255
+ const resolved = this.toResolvedConfig(config)
256
+ const result = await adapter.fetchEvents({ containerNumber: job.containerNumber, config: resolved })
257
+
258
+ // Enrich events with vessel ETA/ATA from /VESSEL (cache-backed, rate-limited).
259
+ const changedVesselRefs = await this.enrichWithVesselVisits(
260
+ em,
261
+ { organizationId: job.organizationId, tenantId: job.tenantId, terminalCode: job.terminalCode },
262
+ config,
263
+ resolved,
264
+ adapter,
265
+ result.events,
266
+ )
267
+
268
+ const { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey } = await this.persistEvents(
269
+ em,
270
+ job,
271
+ result.events,
272
+ changedVesselRefs,
273
+ )
274
+
275
+ job.lastPollAt = new Date()
276
+ job.nextPollAt = this.computeNextPoll(job.schedule)
277
+ job.retryCount = 0
278
+ // Every current leg departed → terminal lifecycle complete; drop from the
279
+ // due-set. A re-appearing leg reverts to active on the next manual poll.
280
+ job.status = isFullyDeparted(result.events) ? 'completed' : 'active'
281
+ await em.flush()
282
+
283
+ const vesselByEventId = new Map<string, TerminalFetchedEvent['vesselVisit']>(
284
+ result.events.map((e) => [e.sourceEventId, e.vesselVisit ?? null]),
285
+ )
286
+ for (const event of created) {
287
+ await this.emitCreated(event, vesselByEventId.get(event.sourceEventId) ?? null)
288
+ }
289
+ for (const event of updated) {
290
+ await this.emitUpdated(event, vesselByEventId.get(event.sourceEventId) ?? null)
291
+ }
292
+ await this.evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId)
293
+
294
+ return { newEvents: created.length, updatedEvents: updated.length }
295
+ } catch (err) {
296
+ await this.recordJobError(em, job, err)
297
+ return { newEvents: 0, updatedEvents: 0 }
298
+ }
299
+ }
300
+
301
+ async pollAllActiveJobs(tenantId: string, organizationId?: string): Promise<PollAllResult> {
302
+ const em = this.deps.em().fork()
303
+ const now = new Date()
304
+ const jobs = await em.find(TerminalTrackingJob, {
305
+ tenantId,
306
+ ...(organizationId ? { organizationId } : {}),
307
+ status: 'active',
308
+ deletedAt: null,
309
+ $or: [{ nextPollAt: null }, { nextPollAt: { $lte: now } }],
310
+ })
311
+
312
+ // Group due jobs per terminal config (org + terminalCode). Each group shares
313
+ // one /unit request budget and one vessel-visit cache, so we poll it in
314
+ // batches rather than one container at a time.
315
+ const groups = new Map<string, TerminalTrackingJob[]>()
316
+ for (const job of jobs) {
317
+ const key = `${job.organizationId}::${job.terminalCode}`
318
+ const arr = groups.get(key)
319
+ if (arr) arr.push(job)
320
+ else groups.set(key, [job])
321
+ }
322
+
323
+ let newEvents = 0
324
+ let updatedEvents = 0
325
+ let failed = 0
326
+ for (const group of groups.values()) {
327
+ try {
328
+ const r = await this.pollTerminalGroup(em, group)
329
+ newEvents += r.newEvents
330
+ updatedEvents += r.updatedEvents
331
+ failed += r.failed
332
+ } catch {
333
+ failed += group.length
334
+ }
335
+ }
336
+ return { polled: jobs.length, newEvents, updatedEvents, failed }
337
+ }
338
+
339
+ /**
340
+ * Poll one terminal group (same org + terminalCode) in batches. Each batch of
341
+ * up to MAX_CONTAINERS_PER_UNIT_REQUEST containers is a single /unit call, and
342
+ * vessel visits are resolved once across the whole batch (deduped + cached),
343
+ * minimising API requests. Adapters without batch support fall back to the
344
+ * per-container path.
345
+ */
346
+ private async pollTerminalGroup(
347
+ em: EntityManager,
348
+ jobs: TerminalTrackingJob[],
349
+ ): Promise<{ newEvents: number; updatedEvents: number; failed: number }> {
350
+ const first = jobs[0]
351
+ const scope = {
352
+ organizationId: first.organizationId,
353
+ tenantId: first.tenantId,
354
+ terminalCode: first.terminalCode,
355
+ }
356
+
357
+ let config: TerminalConfig | null = null
358
+ try {
359
+ config = await findOneWithDecryption(em, TerminalConfig, {
360
+ organizationId: scope.organizationId,
361
+ tenantId: scope.tenantId,
362
+ terminalCode: scope.terminalCode,
363
+ isActive: true,
364
+ deletedAt: null,
365
+ })
366
+ } catch {
367
+ config = null
368
+ }
369
+ const adapter = config ? this.deps.terminalRegistry.get(config.adapterType) : undefined
370
+ if (!config || !adapter) {
371
+ const err = new Error(`No active terminal config/adapter for '${scope.terminalCode}'`)
372
+ for (const job of jobs) await this.recordJobError(em, job, err)
373
+ return { newEvents: 0, updatedEvents: 0, failed: jobs.length }
374
+ }
375
+
376
+ // Adapters lacking batch support: fall back to the per-container poll path.
377
+ if (typeof adapter.fetchEventsBatch !== 'function') {
378
+ let newEvents = 0
379
+ let updatedEvents = 0
380
+ let failed = 0
381
+ for (const job of jobs) {
382
+ try {
383
+ const r = await this.pollJob(job.id)
384
+ newEvents += r.newEvents
385
+ updatedEvents += r.updatedEvents
386
+ } catch {
387
+ failed += 1
388
+ }
389
+ }
390
+ return { newEvents, updatedEvents, failed }
391
+ }
392
+
393
+ const resolved = this.toResolvedConfig(config)
394
+ let newEvents = 0
395
+ let updatedEvents = 0
396
+ let failed = 0
397
+
398
+ for (const batch of chunk(jobs, MAX_CONTAINERS_PER_UNIT_REQUEST)) {
399
+ const limit = await checkRateLimit(
400
+ this.deps.cacheService,
401
+ scope.tenantId,
402
+ scope.terminalCode,
403
+ config.rateLimitRequests,
404
+ config.rateLimitWindowSeconds,
405
+ )
406
+ if (!limit.allowed) {
407
+ terminalLogger.debug('Rate limited, skipping unit batch', {
408
+ terminalCode: scope.terminalCode,
409
+ batch: batch.length,
410
+ })
411
+ continue // jobs stay due; retried next cycle
412
+ }
413
+
414
+ try {
415
+ const containerNumbers = batch.map((j) => j.containerNumber)
416
+ const { events } = await adapter.fetchEventsBatch!({ containerNumbers, config: resolved })
417
+
418
+ // Resolve vessel visits once across the whole batch (deduped + cached).
419
+ const changedVesselRefs = await this.enrichWithVesselVisits(em, scope, config, resolved, adapter, events)
420
+
421
+ const byContainer = new Map<string, TerminalFetchedEvent[]>()
422
+ for (const e of events) {
423
+ const arr = byContainer.get(e.containerNumber)
424
+ if (arr) arr.push(e)
425
+ else byContainer.set(e.containerNumber, [e])
426
+ }
427
+
428
+ for (const job of batch) {
429
+ const jobEvents = byContainer.get(job.containerNumber) ?? []
430
+ const { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey } = await this.persistEvents(
431
+ em,
432
+ job,
433
+ jobEvents,
434
+ changedVesselRefs,
435
+ )
436
+
437
+ job.lastPollAt = new Date()
438
+ job.nextPollAt = this.computeNextPoll(job.schedule)
439
+ job.retryCount = 0
440
+ job.status = isFullyDeparted(jobEvents) ? 'completed' : 'active'
441
+ await em.flush()
442
+
443
+ const vesselByEventId = new Map<string, TerminalFetchedEvent['vesselVisit']>(
444
+ jobEvents.map((e) => [e.sourceEventId, e.vesselVisit ?? null]),
445
+ )
446
+ for (const ev of created) {
447
+ await this.emitCreated(ev, vesselByEventId.get(ev.sourceEventId) ?? null)
448
+ newEvents += 1
449
+ }
450
+ for (const ev of updated) {
451
+ await this.emitUpdated(ev, vesselByEventId.get(ev.sourceEventId) ?? null)
452
+ updatedEvents += 1
453
+ }
454
+ await this.evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId)
455
+ }
456
+ } catch (err) {
457
+ for (const job of batch) await this.recordJobError(em, job, err)
458
+ failed += batch.length
459
+ }
460
+ }
461
+
462
+ return { newEvents, updatedEvents, failed }
463
+ }
464
+
465
+ /**
466
+ * Resolve the vessel visit for each fetched event/leg and attach ETA/ETD/
467
+ * ATA/ATD + phase. The `TerminalVesselVisit` table is the cache: a row fresh
468
+ * within the TTL is reused without an API call, so the many containers that
469
+ * share one vessel visit cost a single /VESSEL fetch per cycle. Each fetch is
470
+ * rate-limit-gated against the same bucket as /unit; when the budget is spent,
471
+ * vessel enrichment is shed (the event still emits, sans vessel data) and
472
+ * filled on the next cycle.
473
+ */
474
+ private async enrichWithVesselVisits(
475
+ em: EntityManager,
476
+ scope: { organizationId: string; tenantId: string; terminalCode: string },
477
+ config: TerminalConfig,
478
+ resolved: ResolvedTerminalConfig,
479
+ adapter: TerminalAdapter,
480
+ events: TerminalFetchedEvent[],
481
+ ): Promise<Set<string>> {
482
+ // Visit refs whose /VESSEL data changed (or first resolved) this poll.
483
+ const changedRefs = new Set<string>()
484
+ if (typeof adapter.fetchVesselVisit !== 'function') return changedRefs
485
+ if (!resolved.endpoints.vessel) return changedRefs
486
+
487
+ const picks = events.map((event) => ({ event, pick: pickVesselVisit(event) }))
488
+ // All picked refs (for attaching cached data); only non-departed legs are
489
+ // fetched — a DEPA leg's visit has sailed and /VESSEL returns empty.
490
+ const allRefs = [...new Set(picks.map((p) => p.pick?.ref).filter((r): r is string => !!r))]
491
+ const fetchRefs = [
492
+ ...new Set(
493
+ picks
494
+ .filter((p) => p.pick && p.event.eventCode !== 'DEPA')
495
+ .map((p) => p.pick!.ref),
496
+ ),
497
+ ]
498
+ if (allRefs.length === 0) return changedRefs
499
+
500
+ const ttlMs = (config.vesselCacheTtlSeconds ?? DEFAULT_VESSEL_CACHE_TTL_SECONDS) * 1000
501
+ const now = Date.now()
502
+
503
+ const existing = await em.find(TerminalVesselVisit, {
504
+ organizationId: scope.organizationId,
505
+ tenantId: scope.tenantId,
506
+ terminalCode: scope.terminalCode,
507
+ visitRef: { $in: allRefs },
508
+ })
509
+ const byRef = new Map<string, TerminalVesselVisit>(existing.map((r) => [r.visitRef, r]))
510
+
511
+ for (const ref of fetchRefs) {
512
+ const cached = byRef.get(ref)
513
+ if (cached && now - cached.updatedAt.getTime() < ttlMs) continue // fresh cache hit
514
+
515
+ const limit = await checkRateLimit(
516
+ this.deps.cacheService,
517
+ scope.tenantId,
518
+ scope.terminalCode,
519
+ config.rateLimitRequests,
520
+ config.rateLimitWindowSeconds,
521
+ )
522
+ if (!limit.allowed) {
523
+ terminalLogger.debug('Rate limited, skipping vessel fetch', { terminalCode: scope.terminalCode, visitRef: ref })
524
+ continue
525
+ }
526
+
527
+ try {
528
+ const visit = await adapter.fetchVesselVisit!({ visitRef: ref, config: resolved })
529
+ if (!visit) continue
530
+ // A first-time resolution, or any field difference, counts as a change.
531
+ if (!cached || vesselSignature(cached) !== vesselSignature(visit)) changedRefs.add(ref)
532
+ const row =
533
+ cached ??
534
+ em.create(TerminalVesselVisit, {
535
+ organizationId: scope.organizationId,
536
+ tenantId: scope.tenantId,
537
+ terminalCode: scope.terminalCode,
538
+ visitRef: ref,
539
+ })
540
+ row.vesselName = visit.vesselName
541
+ row.ibVoyage = visit.ibVoyage
542
+ row.obVoyage = visit.obVoyage
543
+ row.line = visit.line
544
+ row.phase = visit.phase
545
+ row.eta = visit.eta
546
+ row.etd = visit.etd
547
+ row.ata = visit.ata
548
+ row.atd = visit.atd
549
+ row.beginReceive = visit.beginReceive
550
+ row.dryCutoff = visit.dryCutoff
551
+ row.rawData = visit.rawData
552
+ row.updatedAt = new Date()
553
+ byRef.set(ref, row)
554
+ } catch (err) {
555
+ terminalLogger.debug('Vessel fetch failed', {
556
+ terminalCode: scope.terminalCode,
557
+ visitRef: ref,
558
+ error: err instanceof Error ? err.message : String(err),
559
+ })
560
+ }
561
+ }
562
+
563
+ await em.flush()
564
+
565
+ // Attach the resolved visit onto each event (direction-correct voyage).
566
+ for (const { event, pick } of picks) {
567
+ if (!pick) continue
568
+ const row = byRef.get(pick.ref)
569
+ if (!row) continue
570
+ event.vesselName = row.vesselName ?? null
571
+ event.voyageNumber = (pick.dir === 'in' ? row.ibVoyage : row.obVoyage) ?? null
572
+ event.vesselVisit = {
573
+ visitRef: row.visitRef,
574
+ vesselName: row.vesselName ?? null,
575
+ ibVoyage: row.ibVoyage ?? null,
576
+ obVoyage: row.obVoyage ?? null,
577
+ line: row.line ?? null,
578
+ phase: row.phase ?? null,
579
+ eta: row.eta ?? null,
580
+ etd: row.etd ?? null,
581
+ ata: row.ata ?? null,
582
+ atd: row.atd ?? null,
583
+ beginReceive: row.beginReceive ?? null,
584
+ dryCutoff: row.dryCutoff ?? null,
585
+ rawData: row.rawData ?? null,
586
+ }
587
+ }
588
+
589
+ return changedRefs
590
+ }
591
+
592
+ /**
593
+ * Insert new events, and update existing ones whose container fields changed
594
+ * or whose vessel visit data changed this poll. Returns both lists so the
595
+ * caller can emit `.created` / `.updated` accordingly.
596
+ */
597
+ private async persistEvents(
598
+ em: EntityManager,
599
+ job: TerminalTrackingJob,
600
+ fetched: TerminalFetchedEvent[],
601
+ changedVesselRefs: Set<string>,
602
+ ): Promise<{
603
+ created: TerminalEvent[]
604
+ updated: TerminalEvent[]
605
+ currentByKey: Map<string, TerminalEvent>
606
+ priorImpedimentsByKey: Map<string, string[] | null>
607
+ priorStopsByKey: Map<string, TerminalStops>
608
+ }> {
609
+ if (fetched.length === 0) {
610
+ return { created: [], updated: [], currentByKey: new Map(), priorImpedimentsByKey: new Map(), priorStopsByKey: new Map() }
611
+ }
612
+
613
+ const existing = await em.find(TerminalEvent, {
614
+ job,
615
+ sourceEventId: { $in: fetched.map((e) => e.sourceEventId) },
616
+ })
617
+ const byKey = new Map(existing.map((e) => [`${e.source}:${e.sourceEventId}`, e]))
618
+ // Snapshot each existing event's holds BEFORE the update loop mutates them,
619
+ // so availability detection can see the blocked → clear transition.
620
+ const priorImpedimentsByKey = new Map<string, string[] | null>(
621
+ existing.map((e) => [e.sourceEventId, e.impediments ?? null]),
622
+ )
623
+ // Same for the STOP flags (derived from rawData), so the availability step
624
+ // can detect a stop-set change and emit `stops_updated`.
625
+ const priorStopsByKey = new Map<string, TerminalStops>(
626
+ existing.map((e) => [e.sourceEventId, parseTerminalStops(e.rawData)]),
627
+ )
628
+
629
+ const created: TerminalEvent[] = []
630
+ const updated: TerminalEvent[] = []
631
+ const handled = new Set<string>()
632
+ for (const e of fetched) {
633
+ const key = `${e.source}:${e.sourceEventId}`
634
+ if (handled.has(key)) continue
635
+ handled.add(key)
636
+
637
+ const prior = byKey.get(key)
638
+ if (!prior) {
639
+ const entity = em.create(TerminalEvent, {
640
+ organizationId: job.organizationId,
641
+ tenantId: job.tenantId,
642
+ job,
643
+ source: e.source,
644
+ sourceEventId: e.sourceEventId,
645
+ eventType: e.eventType,
646
+ eventCode: e.eventCode,
647
+ eventClassifierCode: e.eventClassifierCode ?? null,
648
+ eventDateTime: e.eventDateTime,
649
+ containerNumber: e.containerNumber,
650
+ ufvGkey: e.ufvGkey,
651
+ transitState: e.transitState ?? null,
652
+ visitState: e.visitState ?? null,
653
+ facilityCode: e.facilityCode ?? null,
654
+ facilityCodeListProvider: e.facilityCodeListProvider ?? null,
655
+ unlocode: e.unlocode ?? null,
656
+ visitRefIn: e.visitRefIn ?? null,
657
+ visitRefOut: e.visitRefOut ?? null,
658
+ vesselName: e.vesselName ?? null,
659
+ voyageNumber: e.voyageNumber ?? null,
660
+ modeOfTransport: e.modeOfTransport ?? null,
661
+ seals: e.seals ?? null,
662
+ vgmWeightKg: e.vgmWeightKg ?? null,
663
+ impediments: e.impediments ?? null,
664
+ loadedAt: e.loadedAt ?? null,
665
+ rawData: e.rawData ?? null,
666
+ })
667
+ created.push(entity)
668
+ continue
669
+ }
670
+
671
+ const containerChanged = eventSignature(prior) !== eventSignature(e)
672
+ const vesselChanged = !!e.vesselVisit && changedVesselRefs.has(e.vesselVisit.visitRef)
673
+ // The STOP flags live only in rawData, which eventSignature does not hash,
674
+ // so a stop-only change must be detected separately to persist rawData.
675
+ const stopFlagsChanged = stopsChanged(parseTerminalStops(prior.rawData), parseTerminalStops(e.rawData))
676
+ if (!containerChanged && !vesselChanged && !stopFlagsChanged) continue
677
+
678
+ if (containerChanged || stopFlagsChanged) {
679
+ prior.eventClassifierCode = e.eventClassifierCode ?? null
680
+ prior.eventDateTime = e.eventDateTime
681
+ prior.transitState = e.transitState ?? null
682
+ prior.visitState = e.visitState ?? null
683
+ prior.facilityCode = e.facilityCode ?? null
684
+ prior.facilityCodeListProvider = e.facilityCodeListProvider ?? null
685
+ prior.unlocode = e.unlocode ?? null
686
+ prior.visitRefIn = e.visitRefIn ?? null
687
+ prior.visitRefOut = e.visitRefOut ?? null
688
+ prior.vesselName = e.vesselName ?? null
689
+ prior.voyageNumber = e.voyageNumber ?? null
690
+ prior.modeOfTransport = e.modeOfTransport ?? null
691
+ prior.seals = e.seals ?? null
692
+ prior.vgmWeightKg = e.vgmWeightKg ?? null
693
+ prior.impediments = e.impediments ?? null
694
+ prior.loadedAt = e.loadedAt ?? null
695
+ prior.rawData = e.rawData ?? null
696
+ }
697
+ updated.push(prior)
698
+ }
699
+
700
+ if (created.length || updated.length) await em.flush()
701
+
702
+ // Current persisted entity for every fetched event (existing + created), so
703
+ // availability detection evaluates the freshest snapshot per source event.
704
+ const currentByKey = new Map<string, TerminalEvent>(existing.map((e) => [e.sourceEventId, e]))
705
+ for (const c of created) currentByKey.set(c.sourceEventId, c)
706
+
707
+ return { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey }
708
+ }
709
+
710
+ private buildEventPayload(event: TerminalEvent, vesselVisit: TerminalFetchedEvent['vesselVisit'] = null) {
711
+ return {
712
+ id: event.id,
713
+ jobId: event.job.id,
714
+ tenantId: event.tenantId,
715
+ organizationId: event.organizationId,
716
+ source: event.source,
717
+ sourceEventId: event.sourceEventId,
718
+ eventType: event.eventType,
719
+ eventCode: event.eventCode,
720
+ eventClassifierCode: event.eventClassifierCode ?? null,
721
+ eventDateTime: event.eventDateTime.toISOString(),
722
+ containerNumber: event.containerNumber,
723
+ ufvGkey: event.ufvGkey,
724
+ transitState: event.transitState ?? null,
725
+ // Direction of travel through the terminal ('import' | 'export' | null).
726
+ // Consumers use it to decide which road leg a gate movement belongs to:
727
+ // an import gates OUT onto the delivery leg, an export gates IN off the
728
+ // pre-carriage leg. Derived from raw data, so it also holds for rows
729
+ // stored before this field existed.
730
+ cargoCategory: parseCargoCategory(event.rawData),
731
+ facilityCode: event.facilityCode ?? null,
732
+ facilityCodeListProvider: event.facilityCodeListProvider ?? null,
733
+ unlocode: event.unlocode ?? null,
734
+ vesselName: event.vesselName ?? null,
735
+ voyageNumber: event.voyageNumber ?? null,
736
+ modeOfTransport: event.modeOfTransport ?? null,
737
+ seals: event.seals ?? null,
738
+ impediments: event.impediments ?? null,
739
+ // When the container was loaded onto transport (N4 `Loaded`), typed datetime.
740
+ loadedAt: event.loadedAt ? event.loadedAt.toISOString() : null,
741
+ // Per-mode load/pickup STOP flags derived from the raw N4 columns.
742
+ stops: parseTerminalStops(event.rawData),
743
+ // Nested vessel visit (from /VESSEL); null when unresolved. Dates ISO.
744
+ vesselVisit: vesselVisit
745
+ ? {
746
+ visitRef: vesselVisit.visitRef,
747
+ vesselName: vesselVisit.vesselName,
748
+ ibVoyage: vesselVisit.ibVoyage,
749
+ obVoyage: vesselVisit.obVoyage,
750
+ line: vesselVisit.line,
751
+ phase: vesselVisit.phase,
752
+ eta: vesselVisit.eta ? vesselVisit.eta.toISOString() : null,
753
+ etd: vesselVisit.etd ? vesselVisit.etd.toISOString() : null,
754
+ ata: vesselVisit.ata ? vesselVisit.ata.toISOString() : null,
755
+ atd: vesselVisit.atd ? vesselVisit.atd.toISOString() : null,
756
+ beginReceive: vesselVisit.beginReceive ? vesselVisit.beginReceive.toISOString() : null,
757
+ dryCutoff: vesselVisit.dryCutoff ? vesselVisit.dryCutoff.toISOString() : null,
758
+ }
759
+ : null,
760
+ rawData: event.rawData ?? null,
761
+ }
762
+ }
763
+
764
+ /** Emit a newly-created event: the raw `.created` event plus its semantic
765
+ * milestone (gate_in / discharged / …). */
766
+ private async emitCreated(
767
+ event: TerminalEvent,
768
+ vesselVisit: TerminalFetchedEvent['vesselVisit'] = null,
769
+ ): Promise<void> {
770
+ const payload = this.buildEventPayload(event, vesselVisit)
771
+ await this.deps.eventBus.emit('terminal_tracking.terminal_event.created', payload)
772
+ const semanticId = semanticEventIdFor(event.eventCode)
773
+ if (semanticId) await this.deps.eventBus.emit(semanticId, payload)
774
+ }
775
+
776
+ /** Emit `.updated` when an existing event's container or vessel data changed.
777
+ * Semantic milestone events are NOT re-emitted — the milestone already fired
778
+ * on creation; only the underlying data moved. */
779
+ private async emitUpdated(
780
+ event: TerminalEvent,
781
+ vesselVisit: TerminalFetchedEvent['vesselVisit'] = null,
782
+ ): Promise<void> {
783
+ const payload = this.buildEventPayload(event, vesselVisit)
784
+ await this.deps.eventBus.emit('terminal_tracking.terminal_event.updated', payload)
785
+ }
786
+
787
+ /**
788
+ * Evaluate the container's availability after a poll and emit the one-shot
789
+ * milestone when it first becomes collectable:
790
+ * - export empty ready for pickup (`equipment.empty_ready`)
791
+ * - import blocking holds cleared (`equipment.holds_cleared`)
792
+ *
793
+ * The job-level `emptyReadyAt` / `holdsClearedAt` markers guarantee each event
794
+ * fires at most once per container. A job may carry both an import and an
795
+ * export leg, so both are checked independently.
796
+ */
797
+ private async evaluateAndEmitAvailability(
798
+ em: EntityManager,
799
+ job: TerminalTrackingJob,
800
+ currentByKey: Map<string, TerminalEvent>,
801
+ priorImpedimentsByKey: Map<string, string[] | null>,
802
+ priorStopsByKey: Map<string, TerminalStops>,
803
+ vesselByEventId: Map<string, TerminalFetchedEvent['vesselVisit']>,
804
+ ): Promise<void> {
805
+ let emptyEvent: TerminalEvent | null = null
806
+ let holdsEvent: TerminalEvent | null = null
807
+ // Events whose raw impediment set changed since the last poll (incl. first
808
+ // appearance with holds). Unlike the one-shot markers above, every one emits.
809
+ const holdsChangedEvents: TerminalEvent[] = []
810
+ // Events whose STOP flags changed since the last poll (incl. first
811
+ // appearance). Mirrors holdsChangedEvents.
812
+ const stopsChangedEvents: TerminalEvent[] = []
813
+
814
+ for (const [sourceEventId, entity] of currentByKey) {
815
+ if (!job.emptyReadyAt && !emptyEvent && isEmptyReady(entity)) emptyEvent = entity
816
+ if (
817
+ !job.holdsClearedAt &&
818
+ !holdsEvent &&
819
+ isImportHoldsCleared(entity, priorImpedimentsByKey.get(sourceEventId) ?? null)
820
+ ) {
821
+ holdsEvent = entity
822
+ }
823
+ // `priorImpedimentsByKey` has no entry for events created this poll, so a
824
+ // first-seen container with holds is reported as a change (empty prior).
825
+ if (impedimentsChanged(priorImpedimentsByKey.get(sourceEventId), entity.impediments)) {
826
+ holdsChangedEvents.push(entity)
827
+ }
828
+ // Same for STOP flags: a created event has no prior entry, so an empty
829
+ // prior (all-null) surfaces a first-seen stop set as a change.
830
+ const priorStops = priorStopsByKey.get(sourceEventId) ?? { vsl: null, road: null, rail: null }
831
+ if (stopsChanged(priorStops, parseTerminalStops(entity.rawData))) {
832
+ stopsChangedEvents.push(entity)
833
+ }
834
+ }
835
+
836
+ if (!emptyEvent && !holdsEvent && holdsChangedEvents.length === 0 && stopsChangedEvents.length === 0) return
837
+
838
+ // Only the one-shot markers mutate the job; flush just for those.
839
+ if (emptyEvent || holdsEvent) {
840
+ const now = new Date()
841
+ if (emptyEvent) job.emptyReadyAt = now
842
+ if (holdsEvent) job.holdsClearedAt = now
843
+ await em.flush()
844
+ }
845
+
846
+ if (emptyEvent) {
847
+ await this.deps.eventBus.emit('terminal_tracking.equipment.empty_ready', {
848
+ ...this.buildEventPayload(emptyEvent, vesselByEventId.get(emptyEvent.sourceEventId) ?? null),
849
+ emptyReady: true,
850
+ })
851
+ }
852
+ if (holdsEvent) {
853
+ await this.deps.eventBus.emit('terminal_tracking.equipment.holds_cleared', {
854
+ ...this.buildEventPayload(holdsEvent, vesselByEventId.get(holdsEvent.sourceEventId) ?? null),
855
+ holdsCleared: true,
856
+ })
857
+ }
858
+ for (const entity of holdsChangedEvents) {
859
+ await this.deps.eventBus.emit('terminal_tracking.equipment.holds_updated', {
860
+ ...this.buildEventPayload(entity, vesselByEventId.get(entity.sourceEventId) ?? null),
861
+ impediments: entity.impediments ?? [],
862
+ })
863
+ }
864
+ for (const entity of stopsChangedEvents) {
865
+ await this.deps.eventBus.emit('terminal_tracking.equipment.stops_updated', {
866
+ ...this.buildEventPayload(entity, vesselByEventId.get(entity.sourceEventId) ?? null),
867
+ stops: parseTerminalStops(entity.rawData),
868
+ })
869
+ }
870
+ }
871
+
872
+ private computeNextPoll(schedule?: string[] | null): Date | null {
873
+ if (!schedule || schedule.length === 0) return null
874
+ const now = Date.now()
875
+ const upcoming = schedule
876
+ .map((s) => new Date(s).getTime())
877
+ .filter((t) => Number.isFinite(t) && t > now)
878
+ .sort((a, b) => a - b)
879
+ return upcoming.length ? new Date(upcoming[0]) : null
880
+ }
881
+
882
+ private async recordJobError(em: EntityManager, job: TerminalTrackingJob, err: unknown): Promise<void> {
883
+ const message = err instanceof Error ? err.message : String(err)
884
+ terminalLogger.warn('Poll failed', { jobId: job.id, terminalCode: job.terminalCode, message })
885
+
886
+ const history = job.errorHistory ?? []
887
+ history.unshift({ date: new Date().toISOString(), message })
888
+ job.errorHistory = history.slice(0, MAX_ERROR_HISTORY)
889
+ job.retryCount = (job.retryCount ?? 0) + 1
890
+ job.lastPollAt = new Date()
891
+ if (job.retryCount >= MAX_RETRIES) job.status = 'failed'
892
+
893
+ try {
894
+ await em.flush()
895
+ } catch (flushErr) {
896
+ terminalLogger.error('Failed to persist job error', {
897
+ jobId: job.id,
898
+ message: flushErr instanceof Error ? flushErr.message : String(flushErr),
899
+ })
900
+ }
901
+
902
+ await this.deps.eventBus.emit('terminal_tracking.tracking_job.poll_failed', {
903
+ id: job.id,
904
+ terminalCode: job.terminalCode,
905
+ containerNumber: job.containerNumber,
906
+ tenantId: job.tenantId,
907
+ organizationId: job.organizationId,
908
+ message,
909
+ retryCount: job.retryCount,
910
+ })
911
+ }
912
+ }