@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,219 @@
1
+ import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
2
+ import type { GctToken } from '../types'
3
+ import { n4Request } from '../../n4/http'
4
+ import { generateTotp } from '../../../totp'
5
+
6
+ /**
7
+ * GCT token acquisition + process-level cache.
8
+ *
9
+ * GCT mints a token from a plain `GET /gctapi/Auth/{company}/{login}/{password}`
10
+ * (credentials in the path — see the security note below), returning an ISO
11
+ * `expires` instant. The token is then replayed in the `Authorization` header on
12
+ * every data call. This mirrors `../../n4/auth/ropc.ts` in shape (cache keyed by
13
+ * principal, 30s safety margin, invalidate-on-401) but not in wire protocol.
14
+ *
15
+ * LIFECYCLE: a cached token is reused until 30s before `expires`. Once it lapses
16
+ * we first try to REFRESH it via the keep-alive endpoint (`GET /gctapi/ping`,
17
+ * current token in the header) — cheap, no 2FA code — and only RE-AUTHENTICATE
18
+ * (mint a new token with a fresh authenticator code) when the refresh fails.
19
+ *
20
+ * AUTH: GCT's login expects an authenticator one-time code where a password would
21
+ * normally go. We store the base32 authenticator *secret* (the seed behind the
22
+ * enrollment QR) in `auth_config` and derive the current 6-digit TOTP code at
23
+ * mint time — never a static password. A legacy static `loginPassword` is still
24
+ * honoured as a fallback so pre-existing configs don't hard-break.
25
+ *
26
+ * SECURITY: the credential (the derived code) is in the request URL, and the
27
+ * secret must never appear anywhere. We NEVER log the URL — the shared
28
+ * `n4Request` helper only logs the caller-supplied `label`, so we always pass an
29
+ * opaque label and never the URL, and errors below never echo it. Only the
30
+ * short-lived derived code ever reaches the URL; the seed does not.
31
+ */
32
+
33
+ type CachedToken = { token: string; expiresAt: number }
34
+
35
+ const tokenCache = new Map<string, CachedToken>()
36
+
37
+ export class GctAuthError extends Error {
38
+ constructor(message: string) {
39
+ super(message)
40
+ this.name = 'GctAuthError'
41
+ }
42
+ }
43
+
44
+ type GctCredentials = { companyCode: string; loginName: string; totpSecret: string; legacyPassword: string }
45
+
46
+ function credentials(config: ResolvedTerminalConfig): GctCredentials {
47
+ const auth = (config.authConfig ?? {}) as Record<string, unknown>
48
+ const companyCode = String(auth.companyCode ?? '')
49
+ const loginName = String(auth.loginName ?? '')
50
+ const totpSecret = String(auth.totpSecret ?? '')
51
+ // Legacy fallback: pre-TOTP configs stored a static `loginPassword`.
52
+ const legacyPassword = String(auth.loginPassword ?? '')
53
+ if (!companyCode || !loginName || (!totpSecret && !legacyPassword)) {
54
+ throw new GctAuthError(
55
+ `GCT config ${config.terminalCode} is missing companyCode/loginName/totpSecret in auth_config`,
56
+ )
57
+ }
58
+ return { companyCode, loginName, totpSecret, legacyPassword }
59
+ }
60
+
61
+ /**
62
+ * The value GCT expects in the password path segment: the current TOTP code
63
+ * derived from the authenticator secret, or a legacy static password when no
64
+ * secret is configured.
65
+ */
66
+ function loginCode(creds: GctCredentials): string {
67
+ return creds.totpSecret ? generateTotp(creds.totpSecret) : creds.legacyPassword
68
+ }
69
+
70
+ function cacheKey(config: ResolvedTerminalConfig, creds: GctCredentials): string {
71
+ // Keyed by principal, NOT org/tenant: identical credentials mint the same
72
+ // token, so sharing one cache entry is correct. Persisted rows stay
73
+ // org-scoped independently of this cache.
74
+ return `${config.terminalCode}:${creds.companyCode}:${creds.loginName}`
75
+ }
76
+
77
+ function joinUrl(base: string, path: string): string {
78
+ return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
79
+ }
80
+
81
+ /** Default GCT auth path; a config may override via `endpoints.auth`. */
82
+ const DEFAULT_AUTH_PATH = '/gctapi/Auth'
83
+
84
+ /** Default GCT token-refresh (keep-alive) path; override via `endpoints.refresh`. */
85
+ const DEFAULT_REFRESH_PATH = '/gctapi/ping'
86
+
87
+ function authUrl(config: ResolvedTerminalConfig, creds: GctCredentials, code: string): string {
88
+ const authPath =
89
+ (config.endpoints as { auth?: string } | undefined)?.auth?.trim() || DEFAULT_AUTH_PATH
90
+ const segments = [creds.companyCode, creds.loginName, code]
91
+ .map((s) => encodeURIComponent(s))
92
+ .join('/')
93
+ return joinUrl(config.baseUrl, `${authPath}/${segments}`)
94
+ }
95
+
96
+ function refreshUrl(config: ResolvedTerminalConfig): string {
97
+ const refreshPath =
98
+ (config.endpoints as { refresh?: string } | undefined)?.refresh?.trim() || DEFAULT_REFRESH_PATH
99
+ return joinUrl(config.baseUrl, refreshPath)
100
+ }
101
+
102
+ /** Parse GCT's ISO `expires` into epoch ms; null when unparseable. */
103
+ function parseExpires(value: string | undefined): number | null {
104
+ if (!value) return null
105
+ const ms = Date.parse(value)
106
+ return Number.isNaN(ms) ? null : ms
107
+ }
108
+
109
+ /** Fall back to a conservative 15-minute TTL when `expires` is missing/bad. */
110
+ function tokenTtl(parsed: GctToken, now: number): number {
111
+ return parseExpires(parsed.expires) ?? now + 15 * 60 * 1000
112
+ }
113
+
114
+ /**
115
+ * Full (re-)authentication: derive a fresh authenticator code (it rotates every
116
+ * 30s) and mint a new token from the path-GET Auth endpoint. Caches and returns
117
+ * the raw token. This spends one 2FA code, so it is the fallback path — callers
118
+ * try {@link refreshGctToken} first when they still hold a token.
119
+ */
120
+ async function mintGctToken(
121
+ config: ResolvedTerminalConfig,
122
+ creds: GctCredentials,
123
+ key: string,
124
+ now: number,
125
+ ): Promise<string> {
126
+ const { status, ok, text } = await n4Request(
127
+ authUrl(config, creds, loginCode(creds)),
128
+ { method: 'GET', headers: { Accept: 'application/json' } },
129
+ { proxyUrl: config.proxyUrl, label: `${config.terminalCode} auth` },
130
+ )
131
+
132
+ if (!ok) {
133
+ // Do not include the URL (it carries the derived auth code); status only.
134
+ throw new GctAuthError(`GCT token request failed (${status}) for ${config.terminalCode}`)
135
+ }
136
+
137
+ let parsed: GctToken
138
+ try {
139
+ parsed = JSON.parse(text) as GctToken
140
+ } catch {
141
+ throw new GctAuthError(`GCT token response was not JSON for ${config.terminalCode}`)
142
+ }
143
+ if (!parsed.token) {
144
+ throw new GctAuthError(`No token in GCT auth response for ${config.terminalCode}`)
145
+ }
146
+
147
+ const entry: CachedToken = { token: parsed.token, expiresAt: tokenTtl(parsed, now) }
148
+ tokenCache.set(key, entry)
149
+ return entry.token
150
+ }
151
+
152
+ /**
153
+ * Extend the current token via GCT's keep-alive endpoint
154
+ * (`GET /gctapi/ping`, current token replayed in the `Authorization` header).
155
+ * Returns the refreshed cache entry on success, or `null` on ANY failure —
156
+ * non-2xx, non-JSON, no token in the body, or a network error — so the caller
157
+ * falls back to a full re-authentication. Never throws (no 2FA code is spent).
158
+ */
159
+ async function refreshGctToken(
160
+ config: ResolvedTerminalConfig,
161
+ token: string,
162
+ now: number,
163
+ ): Promise<CachedToken | null> {
164
+ let res
165
+ try {
166
+ res = await n4Request(
167
+ refreshUrl(config),
168
+ { method: 'GET', headers: { Accept: 'application/json', Authorization: token } },
169
+ { proxyUrl: config.proxyUrl, label: `${config.terminalCode} refresh` },
170
+ )
171
+ } catch {
172
+ // Transient network failure exhausted its retries → re-authenticate.
173
+ return null
174
+ }
175
+ if (!res.ok) return null
176
+ let parsed: GctToken
177
+ try {
178
+ parsed = JSON.parse(res.text) as GctToken
179
+ } catch {
180
+ return null
181
+ }
182
+ if (!parsed.token) return null
183
+ return { token: parsed.token, expiresAt: tokenTtl(parsed, now) }
184
+ }
185
+
186
+ /**
187
+ * Acquire (and cache) a GCT token for the `Authorization` header. A cached token
188
+ * is reused until 30s before `expires`. Once it lapses, we first try to REFRESH
189
+ * it via the keep-alive endpoint (cheap — no 2FA code); only if that fails do we
190
+ * fully RE-AUTHENTICATE by minting a new token with a fresh authenticator code.
191
+ */
192
+ export async function acquireGctToken(config: ResolvedTerminalConfig): Promise<string> {
193
+ const creds = credentials(config)
194
+ const key = cacheKey(config, creds)
195
+ const now = Date.now()
196
+ const cached = tokenCache.get(key)
197
+ if (cached && cached.expiresAt > now + 30_000) return cached.token
198
+
199
+ // (Near-)expired but a token is still in hand: try the keep-alive refresh
200
+ // before spending a fresh 2FA code. Any refresh failure → full re-auth.
201
+ if (cached) {
202
+ const refreshed = await refreshGctToken(config, cached.token, now)
203
+ if (refreshed) {
204
+ tokenCache.set(key, refreshed)
205
+ return refreshed.token
206
+ }
207
+ }
208
+
209
+ return mintGctToken(config, creds, key, now)
210
+ }
211
+
212
+ /** Clear the cached token for a terminal (used after a 401). */
213
+ export function invalidateGctToken(config: ResolvedTerminalConfig): void {
214
+ try {
215
+ tokenCache.delete(cacheKey(config, credentials(config)))
216
+ } catch {
217
+ // Missing creds → nothing cached; ignore.
218
+ }
219
+ }
@@ -0,0 +1,136 @@
1
+ import type { GctContainer } from './types'
2
+ import { DEFAULT_TERMINAL_TIMEZONE, zonedNaiveToUtc } from '../zoned-time'
3
+ import type { TerminalFetchedEvent, ResolvedTerminalConfig } from '../../terminal-adapter'
4
+ import type { TerminalEventType, TerminalEventClassifierCode } from '../../../data/entities'
5
+
6
+ /**
7
+ * GCT returns a container *state snapshot*, not a timestamped milestone stream.
8
+ * We emit only the two milestones GCT actually timestamps — grounding (gate-in)
9
+ * and pickup (gate-out/departed) — rather than synthesizing discharge/load from
10
+ * a status code we cannot timestamp. The `eventCode`s (`GTIN`/`DEPA`) are the
11
+ * same the service maps to `equipment.gate_in` / `transport.departed`, so no
12
+ * adapter-specific wiring is needed downstream.
13
+ */
14
+
15
+ type MilestoneSpec = {
16
+ eventType: TerminalEventType
17
+ eventCode: string
18
+ classifier: TerminalEventClassifierCode
19
+ }
20
+
21
+ const GATE_IN: MilestoneSpec = { eventType: 'EQUIPMENT', eventCode: 'GTIN', classifier: 'ACT' }
22
+ const DEPARTED: MilestoneSpec = { eventType: 'TRANSPORT', eventCode: 'DEPA', classifier: 'ACT' }
23
+
24
+ /** The container's direction of travel, inferred from the GCT status prefix. */
25
+ export type GctCargoDirection = 'import' | 'export'
26
+
27
+ /**
28
+ * Map `CntrStatus` (XF/EM/XM/IF/XI) to a direction: `X*` = export, `I*` = import.
29
+ * `EM` (empty, no purpose) and any unknown value yield null. Kept deliberately
30
+ * lenient — the vendor docs contradict on `IF` (full vs empty) and list an
31
+ * undocumented `XI`, but Phase 1 does not branch on the full/empty nuance.
32
+ */
33
+ export function directionFromStatus(status: string | null | undefined): GctCargoDirection | null {
34
+ const s = (status ?? '').trim().toUpperCase()
35
+ if (!s) return null
36
+ if (s.startsWith('X')) return 'export'
37
+ if (s.startsWith('I')) return 'import'
38
+ return null
39
+ }
40
+
41
+ /**
42
+ * Parse a GCT datetime string. GCT is inconsistent — some fields are ISO-8601
43
+ * with an explicit offset, others look like N4's "yyyy-mm-dd hh:mm"
44
+ * (terminal-local, no offset). A field that carries its own timezone is parsed
45
+ * as-is; a naive one is interpreted in the terminal's timezone (Europe/Warsaw
46
+ * by default). Returns null when unparseable or empty.
47
+ */
48
+ export function parseGctDateTime(
49
+ value: string | null | undefined,
50
+ timeZone: string = DEFAULT_TERMINAL_TIMEZONE,
51
+ ): Date | null {
52
+ if (!value) return null
53
+ const raw = value.trim()
54
+ if (!raw) return null
55
+ // An explicit timezone (`Z` or `±hh:mm`) is authoritative — parse directly.
56
+ if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(raw)) {
57
+ const ms = Date.parse(raw.replace(' ', 'T'))
58
+ return Number.isNaN(ms) ? null : new Date(ms)
59
+ }
60
+ // Otherwise it is terminal-local-without-offset: interpret it in `timeZone`.
61
+ const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(raw)
62
+ if (!m) return null
63
+ const [, yyyy, mm, dd, hh, mi, ss] = m
64
+ return zonedNaiveToUtc(+yyyy, +mm, +dd, +hh, +mi, ss ? +ss : 0, timeZone)
65
+ }
66
+
67
+ function buildSourceEventId(terminalCode: string, ufvGkey: string, eventCode: string): string {
68
+ return `${terminalCode}:${ufvGkey}:${eventCode}`
69
+ }
70
+
71
+ function baseEvent(
72
+ container: GctContainer,
73
+ config: ResolvedTerminalConfig,
74
+ ufvGkey: string,
75
+ spec: MilestoneSpec,
76
+ eventDateTime: Date,
77
+ ): TerminalFetchedEvent {
78
+ const seals = (container.SealList ?? [])
79
+ .filter((n): n is string => typeof n === 'string' && n.trim() !== '')
80
+ .map((number) => ({ number, source: config.terminalCode }))
81
+ const holds = (container.HoldCodesList ?? []).filter(
82
+ (c): c is string => typeof c === 'string' && c.trim() !== '',
83
+ )
84
+ const direction = directionFromStatus(container.CntrStatus)
85
+
86
+ return {
87
+ source: 'terminal',
88
+ sourceEventId: buildSourceEventId(config.terminalCode, ufvGkey, spec.eventCode),
89
+ eventType: spec.eventType,
90
+ eventCode: spec.eventCode,
91
+ eventClassifierCode: spec.classifier,
92
+ eventDateTime,
93
+ containerNumber: container.CntrID,
94
+ ufvGkey,
95
+ transitState: container.CntrStatus ?? null,
96
+ visitState: null,
97
+ facilityCode: config.facilityCode ?? null,
98
+ facilityCodeListProvider: config.facilityCodeListProvider ?? null,
99
+ unlocode: config.unlocode ?? null,
100
+ visitRefIn: null,
101
+ visitRefOut: null,
102
+ // Vessel name/voyage are metadata only — GCT has no vessel-visit endpoint, so
103
+ // no schedule (ETA/ATA) enrichment is possible.
104
+ vesselName: container.VesselName ?? null,
105
+ voyageNumber: container.GCTVoyage ?? container.OwnerVoyage ?? null,
106
+ modeOfTransport: 'VESSEL',
107
+ seals: seals.length ? seals : null,
108
+ vgmWeightKg: typeof container.VGMWeight === 'number' ? container.VGMWeight : null,
109
+ impediments: holds.length ? holds : null,
110
+ loadedAt: null,
111
+ // Preserve the full snapshot plus a normalized direction for downstream use.
112
+ rawData: { ...container, Category: direction ?? null },
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Turn one GCT container snapshot into zero, one, or two normalized events:
118
+ * a gate-in when `GroundingDateTime` is set, and a departed when
119
+ * `PickupDateTime` is set. A snapshot with neither timestamp yields no events
120
+ * (nothing has provably happened yet).
121
+ */
122
+ export function mapContainerToEvents(
123
+ container: GctContainer,
124
+ config: ResolvedTerminalConfig,
125
+ ): TerminalFetchedEvent[] {
126
+ if (!container.CntrID) return []
127
+ const ufvGkey = (container.VisitNo && container.VisitNo.trim()) || container.CntrID
128
+
129
+ const events: TerminalFetchedEvent[] = []
130
+ const grounded = parseGctDateTime(container.GroundingDateTime)
131
+ if (grounded) events.push(baseEvent(container, config, ufvGkey, GATE_IN, grounded))
132
+ const pickedUp = parseGctDateTime(container.PickupDateTime)
133
+ if (pickedUp) events.push(baseEvent(container, config, ufvGkey, DEPARTED, pickedUp))
134
+
135
+ return events
136
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Response shapes for the GCT (Gdynia Container Terminal) API. GCT is a bespoke
3
+ * FastAPI JSON service (paths under `/gctapi/*`) — unrelated to Navis N4. Only
4
+ * the fields Phase 1 (read-only tracking) consumes are typed here; the full
5
+ * `Container` payload is preserved verbatim into `rawData`.
6
+ *
7
+ * Source: GCT DEV API OpenAPI 1.1.0 + Karta_kontenera_API.pdf.
8
+ */
9
+
10
+ /** `GET /gctapi/Auth/{company}/{login}/{password}` → token envelope. */
11
+ export type GctToken = {
12
+ token: string
13
+ token_type: string
14
+ /** ISO-8601 instant the token is valid until. */
15
+ expires: string
16
+ }
17
+
18
+ /**
19
+ * One container as returned by `POST /gctapi/GetContainerDetails`. Every field
20
+ * is optional in practice; GCT omits what it has no value for. `CntrID` is the
21
+ * only guaranteed member.
22
+ */
23
+ export type GctContainer = {
24
+ CntrID: string
25
+ /** Per-visit terminal key — the dedup anchor (→ `ufvGkey`). */
26
+ VisitNo?: string | null
27
+ /** Container status: XF/EM/XM/IF/XI (export/import, full/empty). */
28
+ CntrStatus?: string | null
29
+ CntrSize?: string | null
30
+ CntrType?: string | null
31
+ /** Grounding (gate-in) instant — the container's arrival on the terminal. */
32
+ GroundingDateTime?: string | null
33
+ /** Pickup (gate-out) instant — the container left the terminal. */
34
+ PickupDateTime?: string | null
35
+ /** Impediment ("stopki") codes currently on the container. */
36
+ HoldCodesList?: string[] | null
37
+ /** Seals verified at GCT. */
38
+ SealList?: string[] | null
39
+ VGMWeight?: number | null
40
+ VesselName?: string | null
41
+ /** Voyage per GCT numbering. */
42
+ GCTVoyage?: string | null
43
+ /** Voyage per the line's own numbering. */
44
+ OwnerVoyage?: string | null
45
+ DischargePort?: string | null
46
+ FinalDestinationPort?: string | null
47
+ LoadingPort?: string | null
48
+ OriginPort?: string | null
49
+ Location?: string | null
50
+ // Remaining fields are preserved via the index signature into rawData.
51
+ [key: string]: unknown
52
+ }
53
+
54
+ /** `POST /gctapi/GetContainerDetails` response body. */
55
+ export type GctContainerDetailsResponse = {
56
+ Total?: number
57
+ Containers?: GctContainer[]
58
+ }
@@ -0,0 +1,16 @@
1
+ import type { TerminalRegistry } from '../../services/terminalRegistry'
2
+ import { N4TerminalAdapter } from './n4/adapter'
3
+ import { GctTerminalAdapter } from './gct/adapter'
4
+ import { BctTerminalAdapter } from './bct/adapter'
5
+
6
+ /**
7
+ * Register all built-in terminal adapters. Adapters are keyed by `adapterType`;
8
+ * a single adapter serves every terminal of that type (N4 → Baltic Hub etc.,
9
+ * GCT → Gdynia Container Terminal, BCT → Bałtycki Terminal Kontenerowy via
10
+ * INCOS), which are configured as `TerminalConfig` rows, not hardcoded here.
11
+ */
12
+ export function registerAllAdapters(registry: TerminalRegistry): void {
13
+ registry.register(new N4TerminalAdapter())
14
+ registry.register(new GctTerminalAdapter())
15
+ registry.register(new BctTerminalAdapter())
16
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+
3
+ // The adapter's fetches run through `n4Request`, which drives undici's `fetch`
4
+ // (same instance as the dispatcher). Mock the undici module, keeping the real
5
+ // ProxyAgent/Agent so `dispatcher` is built exactly as in production.
6
+ const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }))
7
+ vi.mock('undici', async (orig) => {
8
+ const actual = await orig<typeof import('undici')>()
9
+ return { ...actual, fetch: fetchMock }
10
+ })
11
+
12
+ import { N4TerminalAdapter } from '../adapter'
13
+ import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
14
+
15
+ let counter = 0
16
+ function cfg(proxyUrl: string | null): ResolvedTerminalConfig {
17
+ // Unique terminalCode per config to bypass the process-level token cache.
18
+ return {
19
+ terminalCode: `proxytest-${counter++}`,
20
+ adapterType: 'n4',
21
+ displayName: 'x',
22
+ baseUrl: 'https://api2.baltichub.com/V1/',
23
+ proxyUrl,
24
+ endpoints: { unit: '/unit', vessel: '/VESSEL' },
25
+ authType: 'oauth2_password',
26
+ tokenUrl: 'https://token.example/token',
27
+ clientId: 'c',
28
+ authConfig: { username: 'u', password: 'p' },
29
+ rateLimitRequests: 200,
30
+ rateLimitWindowSeconds: 60,
31
+ }
32
+ }
33
+
34
+ function stubFetch() {
35
+ fetchMock.mockImplementation(async (_url: string | URL, init?: any) => {
36
+ if (init?.method === 'POST') {
37
+ return new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 }), { status: 200 })
38
+ }
39
+ return new Response(
40
+ JSON.stringify({ 'query-response': { 'data-table': { columns: { column: [] }, rows: null } } }),
41
+ { status: 200 },
42
+ )
43
+ })
44
+ return fetchMock
45
+ }
46
+
47
+ afterEach(() => fetchMock.mockReset())
48
+
49
+ describe('N4 adapter proxy dispatcher', () => {
50
+ it('attaches a dispatcher to the API + token fetches when proxyUrl is set', async () => {
51
+ stubFetch()
52
+ await new N4TerminalAdapter().fetchVesselVisit({ visitRef: '26AAA1', config: cfg('http://127.0.0.1:8888') })
53
+
54
+ const vesselCall = fetchMock.mock.calls.find((c) => String(c[0]).includes('/VESSEL'))
55
+ const tokenCall = fetchMock.mock.calls.find((c) => (c[1] as any)?.method === 'POST')
56
+ expect(vesselCall?.[1]?.dispatcher).toBeDefined()
57
+ expect(tokenCall?.[1]?.dispatcher).toBeDefined()
58
+ })
59
+
60
+ it('uses no dispatcher when proxyUrl is null (direct)', async () => {
61
+ stubFetch()
62
+ await new N4TerminalAdapter().fetchVesselVisit({ visitRef: '26BBB2', config: cfg(null) })
63
+
64
+ const vesselCall = fetchMock.mock.calls.find((c) => String(c[0]).includes('/VESSEL'))
65
+ const tokenCall = fetchMock.mock.calls.find((c) => (c[1] as any)?.method === 'POST')
66
+ expect(vesselCall?.[1]?.dispatcher).toBeUndefined()
67
+ expect(tokenCall?.[1]?.dispatcher).toBeUndefined()
68
+ })
69
+ })
@@ -0,0 +1,179 @@
1
+ import { parseUnitDataTable, type N4DataTableResponse } from '../parsers/data-table'
2
+
3
+ const COLUMNS = [
4
+ 'Unit Nbr', 'T-State', 'V-State', 'Category', 'Line Op', 'Type ISO', 'Frght Kind',
5
+ 'I/B Actual Visit', 'O/B Actual Visit', 'Time In', 'Time Out', 'Unit Impediments',
6
+ 'CEN Number', 'Cmdy Weight', 'VGM Weight', 'Cargo Wt (kg)', 'Tare Wt', 'Weight (kg)',
7
+ 'Seal Nbr1', 'Seal Nbr2', 'Seal Nbr3', 'Seal Nbr4', 'DSK Number', 'Loaded',
8
+ ]
9
+
10
+ // Real Baltic Hub /unit response shape for container GCXU5598460 (two UFV legs).
11
+ const REAL_RESPONSE: N4DataTableResponse = {
12
+ 'query-response': {
13
+ 'data-table': {
14
+ '@count': '2',
15
+ columns: { column: COLUMNS },
16
+ rows: {
17
+ row: [
18
+ {
19
+ '@primary-key': '11412344487',
20
+ field: ['GCXU5598460', 'Inbound', 'Active', 'Export', 'MAE', '45G1', 'FCL',
21
+ 'GEN_TRUCK', '26WIKI624', null, null,
22
+ '!TECHNICAL_FULL_CONTAINER,!CUSTOMS EXPORT PERMISSION,!MISSING_AGENT_PERMISSION',
23
+ null, '24600', '28300.0', '24600.0', '3700.0', '28300.0',
24
+ null, null, null, null, null, null],
25
+ },
26
+ {
27
+ '@primary-key': '11341499051',
28
+ field: ['GCXU5598460', 'Departed', 'Departed', 'Import', 'MAE', '45G1', 'FCL',
29
+ '26GIRO622', 'RST62379', '2026-05-27 23:41', '2026-06-03 01:58',
30
+ '!TECHNICAL_FULL_CONTAINER,!MISSING_AGENT_PERMISSION', '26PL32208D005FRKR0',
31
+ '6360', '10060.0', '6360.0', '3700.0', '10060.0',
32
+ 'CN7842852', null, null, null, '26GIRO622 2026-05-26 19:39', '2026-06-03 01:47'],
33
+ },
34
+ ],
35
+ },
36
+ },
37
+ },
38
+ }
39
+
40
+ describe('parseUnitDataTable', () => {
41
+ it('parses both UFV legs, mapping @primary-key to ufvGkey and fields by header name', () => {
42
+ const rows = parseUnitDataTable(REAL_RESPONSE)
43
+ expect(rows).toHaveLength(2)
44
+
45
+ const [exp, imp] = rows
46
+ // @primary-key -> ufvGkey (the N4 Ufv_Gkey)
47
+ expect(exp.ufvGkey).toBe('11412344487')
48
+ expect(imp.ufvGkey).toBe('11341499051')
49
+
50
+ // Positional field[] mapped by column header
51
+ expect(exp.unitNbr).toBe('GCXU5598460')
52
+ expect(exp.tState).toBe('Inbound')
53
+ expect(exp.category).toBe('Export')
54
+ expect(exp.ibActualVisit).toBe('GEN_TRUCK')
55
+ expect(exp.obActualVisit).toBe('26WIKI624')
56
+ expect(exp.vgmWeight).toBe(28300)
57
+ expect(exp.cargoWtKg).toBe(24600)
58
+
59
+ expect(imp.tState).toBe('Departed')
60
+ expect(imp.timeIn).toBe('2026-05-27 23:41')
61
+ expect(imp.timeOut).toBe('2026-06-03 01:58')
62
+ expect(imp.loaded).toBe('2026-06-03 01:47')
63
+ expect(imp.dskNumber).toBe('26GIRO622 2026-05-26 19:39')
64
+ })
65
+
66
+ it('splits impediments and collects non-empty seals', () => {
67
+ const [exp, imp] = parseUnitDataTable(REAL_RESPONSE)
68
+ expect(exp.impediments).toEqual([
69
+ '!TECHNICAL_FULL_CONTAINER', '!CUSTOMS EXPORT PERMISSION', '!MISSING_AGENT_PERMISSION',
70
+ ])
71
+ expect(exp.seals).toEqual([]) // all four seal columns null
72
+ expect(imp.seals).toEqual(['CN7842852']) // only Seal Nbr1 populated
73
+ })
74
+
75
+ it('preserves the full raw row (null for empty columns)', () => {
76
+ const [exp] = parseUnitDataTable(REAL_RESPONSE)
77
+ expect(exp.raw['Category']).toBe('Export')
78
+ expect(exp.raw['Time In']).toBeNull()
79
+ expect(Object.keys(exp.raw)).toHaveLength(COLUMNS.length)
80
+ })
81
+
82
+ it('tolerates XML->JSON single-element collapsing (row/column as objects, not arrays)', () => {
83
+ const single: N4DataTableResponse = {
84
+ 'query-response': {
85
+ 'data-table': {
86
+ '@count': '1',
87
+ columns: { column: COLUMNS },
88
+ rows: {
89
+ row: {
90
+ '@primary-key': '999',
91
+ field: ['MSKU1', 'Yard', 'Active', 'Import', 'MSC', '22G1', 'FCL',
92
+ 'V1', null, '2026-01-01 10:00', null, null, null, null, null, null, null, null,
93
+ null, null, null, null, null, null],
94
+ },
95
+ },
96
+ },
97
+ },
98
+ }
99
+ const rows = parseUnitDataTable(single)
100
+ expect(rows).toHaveLength(1)
101
+ expect(rows[0].ufvGkey).toBe('999')
102
+ expect(rows[0].unitNbr).toBe('MSKU1')
103
+ expect(rows[0].tState).toBe('Yard')
104
+ })
105
+
106
+ it('reports all-null stops for a pre-update payload lacking the STOP columns', () => {
107
+ const [exp, imp] = parseUnitDataTable(REAL_RESPONSE)
108
+ expect(exp.stops).toEqual({ vsl: null, road: null, rail: null })
109
+ expect(imp.stops).toEqual({ vsl: null, road: null, rail: null })
110
+ })
111
+
112
+ it('parses the new Stop-Vsl / Stop-Road / Stop-Rail columns when present', () => {
113
+ const withStops: N4DataTableResponse = {
114
+ 'query-response': {
115
+ 'data-table': {
116
+ '@count': '1',
117
+ columns: { column: [...COLUMNS, 'Stop-Vsl', 'Stop-Road', 'Stop-Rail'] },
118
+ rows: {
119
+ row: {
120
+ '@primary-key': '555',
121
+ field: ['MSKU2', 'Yard', 'Active', 'Import', 'MSC', '22G1', 'FCL',
122
+ 'V1', null, '2026-01-01 10:00', null, null, null, null, null, null, null, null,
123
+ null, null, null, null, null, null,
124
+ 'N', 'Y', null],
125
+ },
126
+ },
127
+ },
128
+ },
129
+ }
130
+ const [row] = parseUnitDataTable(withStops)
131
+ expect(row.stops).toEqual({ vsl: false, road: true, rail: false })
132
+ })
133
+
134
+ it('parses a real post-update Baltic Hub payload (27 columns, Stop-* = "false")', () => {
135
+ // Verbatim shape from a real /unit response (container ECMU7554418, 2026-07):
136
+ // the STOP columns arrive in Vsl, Rail, Road order with string "false" values,
137
+ // and empties report Frght Kind "Empty" (not "MTY").
138
+ const REAL_WITH_STOPS: N4DataTableResponse = {
139
+ 'query-response': {
140
+ 'data-table': {
141
+ '@count': '2',
142
+ columns: {
143
+ column: [...COLUMNS, 'Stop-Vsl', 'Stop-Rail', 'Stop-Road'],
144
+ },
145
+ rows: {
146
+ row: [
147
+ {
148
+ '@primary-key': '11643960646',
149
+ field: ['ECMU7554418', 'Departed', 'Departed', 'Export', 'CMA', '45G1', 'Empty',
150
+ 'BC1455XA', '26SPAI012', '2026-07-06 09:53', '2026-07-12 04:30', null, null, null,
151
+ null, '0.0', '4000.0', '4000.0', null, null, null, null, null, '2026-07-09 21:21',
152
+ 'false', 'false', 'false'],
153
+ },
154
+ {
155
+ '@primary-key': '11483997354',
156
+ field: ['ECMU7554418', 'Departed', 'Departed', 'Import', 'CMA', '45G1', 'FCL',
157
+ '26CCJA1FL1MA', 'BC1455XA', '2026-06-22 17:15', '2026-06-30 14:49',
158
+ '!TECHNICAL_FULL_CONTAINER,!MISSING_AGENT_PERMISSION', '26PL322080NSJAGEK7',
159
+ '18320', '22020.0', '18020.0', '4000.0', '22020.0', 'M7663322', null, null, null,
160
+ '26CCJA1FL1MA 2026-06-22 05:55', '2026-06-30 14:40', 'false', 'false', 'false'],
161
+ },
162
+ ],
163
+ },
164
+ },
165
+ },
166
+ }
167
+ const [mty, fcl] = parseUnitDataTable(REAL_WITH_STOPS)
168
+ expect(mty.raw['Frght Kind']).toBe('Empty')
169
+ expect(mty.stops).toEqual({ vsl: false, road: false, rail: false })
170
+ expect(fcl.stops).toEqual({ vsl: false, road: false, rail: false })
171
+ // Header order in the payload is Vsl, Rail, Road — name-indexed parsing is order-agnostic.
172
+ expect(mty.raw['Stop-Road']).toBe('false')
173
+ })
174
+
175
+ it('returns [] for an empty (rows: null) or malformed envelope', () => {
176
+ expect(parseUnitDataTable({ 'query-response': { 'data-table': { '@count': '0', columns: { column: COLUMNS }, rows: null } } })).toEqual([])
177
+ expect(parseUnitDataTable({})).toEqual([])
178
+ })
179
+ })